Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

01 — Product Overview (Shared Platform)

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

GoalMeasure
Tenant onboarding under 15 minutesTime from org creation to first login
Consolidated operationsNumber of workflows moved off paper/spreadsheets
Fast daily attendanceAttendance marking latency < 1 s
Reliable communicationNotification delivery P95 < 30 s
Enterprise securityNo cross-tenant data leakage; full audit trail
Platform availability99.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_PROVIDER interface — Appwrite (Phase 1), Cloudflare R2 (Phase 2), local dev
  • WebSockets: WsModule gateway 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 (version field), 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:144 puts 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.md and may not be in code yet; they are flagged (planned) in every doc.

10. Glossary

TermMeaning
TenantOne institution (organization) with isolated data
Org AdminTenant-level administrator
Permissionresource.action string (see permissions.constants.ts)
EnvelopeStandard API response wrapper {success, message, data, timestamp, requestId}
Soft deleteisDeleted: true; excluded from all queries
DLQDead-letter queue for failed BullMQ jobs
BaseSchema fieldstenantId, createdBy, updatedBy, isDeleted, deletedAt, deletedBy, version, createdAt, updatedAt
Academic structureAcademicYear → Grade → Class → Section; subjects assigned to classes
Bulk importCSV upload processed asynchronously by workers

02 — Design Tokens (Shared)

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).

RoleToken NameHexUsage
primaryInternational Klein Blue#0037B0CTAs, active nav, links, focus
onPrimaryWhite#FFFFFFText/icons on primary
primaryContainerTitan White#E9EDFFSelected chips, info banners
onPrimaryContainerInternational Klein Blue#0037B0Text on primaryContainer
secondaryPersian Blue#1D4ED8Secondary CTAs
onSecondaryWhite#FFFFFFText/icons on secondary
secondaryContainerPattens Blue#DBEAFETonal buttons, selection indicators
onSecondaryContainerPersian Blue#1D4ED8Text on secondaryContainer
tertiaryPurple Heart#712AE2Distinct accents, highlights
onTertiaryWhite#FFFFFFText/icons on tertiary
tertiaryContainerBlue Chalk#EADDFFTonal tertiary surfaces
onTertiaryContainerPurple Heart#712AE2Text on tertiaryContainer
errorThunderbird#BA1A1AValidation, destructive
onErrorWhite#FFFFFFText on error
errorContainerCinderella#FEE2E2Error banners, destructive surfaces
onErrorContainerSangria#93000AText on errorContainer
success (custom)Salem#16A34APaid, present, approved, healthy
onSuccess (custom)White#FFFFFFText/icons on success
successContainer (custom)Scandal#DCFCE7Success surfaces, badges
onSuccessContainer (custom)Jewel#15803DText on successContainer
warning (custom)Ecstasy#F97316Partial, late, attention
onWarning (custom)White#FFFFFFText/icons on warning
warningContainer (custom)Serenade#FFF7EDWarning surfaces, badges
onWarningContainer (custom)Cedar Wood Finish#7F2500Text on warningContainer

Note: success and warning are standalone roles, not aliases of secondary/tertiary (previous proposal). Dues/warnings that were formerly tertiary now map to warning; paid/healthy states map to success.

1.2 Surface scale (light)

RoleToken NameHexUsage
backgroundCatskill White#F8FAFCScreen background
onBackgroundEbony#0F172APrimary text on background
surfaceWhite#FFFFFFCard/screen surfaces
onSurfaceEbony#0F172APrimary text
surfaceVariantMystic#E2E8F0Input fills, chips
onSurfaceVariantFiord#475569Secondary text, icons
surfaceContainerLowestWhite#FFFFFFFlat cards on containers
surfaceContainerLowCatskill White#F8FAFCCard backgrounds
surfaceContainerAthens Gray#E5E7EBRaised surfaces, nav bars
surfaceContainerHighGeyser#CBD5E1Elevated cards, dialogs
surfaceContainerHighestGull Gray (extrapolated — next step down the neutral scale)#94A3B8TBD by designer
surfaceDimSlate Gray (extrapolated — dimmest step before onSurface tones)#64748BTBD by designer
surfaceBrightWhite (extrapolated — brightest neutral, standard M3 convention)#FFFFFFTBD by designer

1.3 Outline & inverse

RoleToken NameHex
outlineBoulder#767676
outlineVariantGeyser#CBD5E1
shadowBlack#000000
scrimBlack#000000
inverseSurfaceMirage#141B2B
inverseOnSurfaceCatskill White#F8FAFC
inversePrimaryMelrose#B7C4FF

1.4 Dark mode

Designer-provided dark surface scale (grey/azure family):

LevelToken NameHex
Surface DimEbony#0F172A
SurfaceMirage#141B2B
Surface ContainerEbony Clay#293040
Surface Container HighPickled Bluewood#334155
Surface Container HighestSlate 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) with onPrimary#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)

TokenHexNotes
Athens Gray#E5E7EBneutral
Beeswax#FEF9C3tertiary tint
Black#000000scrim / shadow
Blue Chalk#EADDFFtertiaryContainer
Boulder#767676outline
Brown Pod#390C00error-dark accent
Catskill White#F8FAFCsurface/background
Cedar Wood Finish#7F2500onWarningContainer
Cinderella#FEE2E2errorContainer
Ebony#0F172AonSurface (dark)
Ebony Clay#293040surface container (dark)
Ecstasy#F97316warning
Fiord#475569onSurfaceVariant
Fire#A73400error-dark accent
Flamingo#EF4444error accent
Foam#ECFDF5success tint
Fog#DCE1FFprimary tint
Fun Green#008542success accent
Geyser#CBD5E1outlineVariant
Ghost#C4C5D7neutral variant
Gray Chateau#9CA3AFneutral
Green Haze#059669success accent
Gull Gray#94A3B8neutral
Harp#F1F8F4success tint
Hawkes Blue#E1E8FDsecondaryContainer alt
Humming Bird#D1FAE5success tint
International Klein Blue#0037B0primary
Jewel#15803DonSuccessContainer
Link Water#DCE2F7neutral variant
Magic Mint#A7F3D0success tint
Mako#434655neutral dark
Melrose#B7C4FFinversePrimary
Mirage#141B2BinverseSurface
Mountain Meadow#10B981success accent
Mystic#E2E8F0surfaceVariant
Pale Sky#6B7280neutral
Pattens Blue#DBEAFEsecondaryContainer
Peach Schnapps#FFDAD6error tint
Periwinkle#CAD3FFprimary tint
Persian Blue#1D4ED8secondary
Pickled Bluewood#334155surface container (dark)
Provincial Pink#FEF2F2error tint
Pueblo#7C2D12warning-dark accent
Purple Heart#712AE2tertiary
Rich Gold#A16207warning-dark accent
Romantic#FFC9B7warning tint
Salem#16A34Asuccess
Sangria#93000AonErrorContainer
Scandal#DCFCE7successContainer
Serenade#FFF7EDwarningContainer
Slate Gray#64748Bneutral
Storm Gray#747686outline alt
Thunderbird#BA1A1Aerror
Titan White#E9EDFFprimaryContainer
Watercourse#047857success accent
Watusi#FFDBCFwarning tint
White#FFFFFFonPrimary / surface
Zircon#F1F3FFneutral tint
Zumthor#EFF6FFsecondary 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 fontSize scale steps or adjacent tiers per standard M3 conventions. TBD by designer — confirm before shipping.

M3 Scale RoleMapped TokenFontWeightSizeLine HeightLetter SpacingCase
Display LargeHeading 1 underlineInterExtra Bold48px48px-1.2none
Display Medium(extrapolated, fontSize step 14)InterBold36px44px-1.2none
Display Small(extrapolated, fontSize step 13)InterBold28px34px-0.72none
Headline LargeHeading 1InterSemi Bold20px28px-0.5none
Headline Medium(extrapolated — same tier as Heading 1, heavier)InterBold20px28px-0.5none
Headline SmallHeading 2InterBold18px27px0none
Title LargeDataInterSemi Bold16px24px0none
Title MediumHeading 3InterBold14px21px0none
Title SmallInter / MediumInterMedium14px20px0none
Body LargeInter / RegularInterRegular14px20px0none
Body MediumInputInterRegular14pxAUTO0none
Body SmallLabelInterRegular10.7px16.5px0none
Label LargeButtonInterBold12px18px0none
Label MediumButton upper / Cell upperInterBold12px16px1.2 / 0.6uppercase
Label SmallLinkInterRegular12px18px0none

2.2 Non-M3 typography tokens (designer-provided)

TokenFontWeightSizeLine HeightCase
Inter / Semi Bold upperInterSemi Bold10px15pxuppercase
Inter / Regular upperInterRegular10px15pxuppercase
Inter / Bold upperInterBold10px15pxuppercase
Inter / ItalicInterItalic16px26pxnone
Inter / BoldInterBold16px24pxnone
JetBrains Mono / RegularJetBrains MonoRegular13px18pxnone
JetBrains Mono / BoldJetBrains MonoBold13px18pxnone

Usage: mono for IDs, roll numbers, amounts, keys. Numerals (scores, amounts) use FontFeature.tabularFigures().


3. Spacing

3.1 Semantic scale (designer-provided)

TokenpxUsage
xs8Tight gaps between inline elements
s16Screen horizontal padding, card padding, form field gap
item-spacing.2424Item spacing, section gaps
m32Grouping gaps, modal padding
l48Large section separation
xl64Screen-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)

UseValueToken
Screen horizontal padding16s
Card padding16s
Card gap12derived (half of item-spacing.24) — TBD by designer
Section gap24item-spacing.24
List item height≥ 56
Form field gap16s
Toolbar height64xl

4. Radius

TokenValueSourceUsage
r-micro2.5pxcorner radius.2_5Tags, dense chips
r-xs4px(extrapolated — standard M3 4dp) TBD by designerSmall tags
r-sm8pxcorner radius.8Inputs, buttons, cards
r-md12pxcorner radius.12Cards (elevated), dialogs
r-lg16px(extrapolated — standard M3 16dp) TBD by designerBottom sheets, FAB
r-xl28px(extrapolated — standard M3 28dp) TBD by designerSheets, large surfaces
r-full9999pxcorner radius.9999Avatars, pills

5. Elevation & shadow

No elevation/shadow tokens exist in the export. The dp scale below is a proposal built from Black + the export's opacity tokens (shadow color #000000 at 10% per step). TBD by designer — every row is a starting point.

TokendpSuggested shadow
e-00none
e-110 1px 2px rgba(0,0,0,0.10), 0 1px 3px rgba(0,0,0,0.10)
e-230 1px 2px rgba(0,0,0,0.10), 0 2px 6px rgba(0,0,0,0.10)
e-360 1px 3px rgba(0,0,0,0.10), 0 4px 8px rgba(0,0,0,0.10)
e-480 2px 3px rgba(0,0,0,0.10), 0 6px 10px rgba(0,0,0,0.10)
e-5120 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).

TokenDurationCurveUsage
m-instant0 msValue changes, icon toggles
m-fast150 mseaseOutCubicHover, focus, ripple, small state
m-base250 mseaseInOutCubicScreen transitions, dialogs, sheets
m-slow400 mseaseInOutCubicHero, page fade, skeletons
m-entrance300 mseaseOutBackList 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)

ClassWidthLayout
Phone< 600 dpBottom nav, single pane
Tablet600–839 dpBottom nav + wider panes, 2-column lists
Landscape tablet840–1199 dpNavigation rail, master-detail
Desktop/web≥ 1200 dpNavigation 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)

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, Material3 ThemeData).


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: surfaceVariant fill, e-0. Flutter: custom Skeleton widget or shimmer package 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: displaySmall title, bodyMedium onSurfaceVariant. 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: error icon, titleLarge, bodyMedium. Flutter: ErrorWidget-free custom widget.

AppOfflineBanner

  • Purpose: top banner when connectivity lost.
  • Props: visible, text (i18n offline.banner), onRetry.
  • Behaviour: auto-dismiss on connectivity restore; sticky at top, e-1.
  • Tokens: tertiaryContainer background. Flutter: MaterialBanner or custom AnimatedContainer.

AppSnackbar / AppToast

  • Purpose: transient feedback (success, error, info, undo).
  • Props: type, message, actionLabel?, onAction?, duration.
  • Rules: one snackbar at a time (queue), error type persists ≥ 4 s, success < 3 s.
  • Tokens: inverse surface, error/success icon accents. Flutter: ScaffoldMessenger wrapper.

AppBanner

  • Purpose: persistent inline message (e.g., "results published", "pending KYC docs").
  • Props: severity (info|success|warning|error), title, body?, dismissible.
  • Tokens: *Container role 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: validator run on submit + on change after first error; error clears on edit.
  • A11y: label + Semantics(error) on error; error announced.
  • Tokens: surfaceVariant fill, outline border, error on error, focus primary.
  • Flutter: TextField/TextFormField with InputDecoration, InputFormatters.

AppDropdown (single select)

  • Props: label, items[{value,label}], value, onChanged, searchable?, loading?, emptyText, errorText, disabled.
  • Behaviour: opens AppBottomSheet picker or menu; selected value shown in field.
  • A11y: role "combobox", Semantics on options.
  • Flutter: custom field + showModalBottomSheet, or DropdownButtonFormField for ≤ 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 + debounce Timer.

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-base transition.
  • Flutter: showDatePicker/showTimePicker wrappers; MaterialLocalizations.

AppSwitch / AppCheckbox / AppRadio

  • Standard M3 controls; props: label, value, onChanged, disabled, subtitle?.
  • A11y: label semantics, Semantics(toggled:)/checked:.
  • Flutter: Switch, Checkbox, Radio with ListTile wrappers.

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.filled with InkWell.

AppListTile

  • Props: leading, title, subtitle, trailing, onTap, dense?, selected?, badge?, menu? (trailing overflow menu).
  • Height ≥ 56; dense ≥ 48. Flutter: ListTile with custom MenuAnchor.

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/custom Container.

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 Semantics summary text; provide tabular fallback when charts not meaningful.
  • Flutter: fl_chart (recommended, already industry standard; verify license) — wrap in own AppChart widget 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, IconButton with Tooltip.

AppDialog

  • Props: title, content, actions, dismissible (barrier), destructiveAction?.
  • Behaviour: barrier tap = cancel unless dismissible=false; ESC/back closes; focus moves into dialog; m-base scale+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: showModalBottomSheet with DragHandle (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, AppDestinations list).
  • AppNavigationDrawer: full menu (all sections) for large screens; slide-in on phone via hamburger.
  • AppNavigationRail: tablet/desktop ≥ 840 dp.
  • Rules: selected destination = primary indicator (pill); badges show unread counts.
  • Flutter: NavigationBar, NavigationDrawer, NavigationRail.

E. Content & media

AppSectionHeader

  • Props: title, actionLabel?, onAction?, subtitle?. Section gaps 24 top.
  • Flutter: Row with titleMedium + 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_scanner package (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-instant fade; "end of list" footer with bodySmall text.
  • 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)

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

ConcernValue
FrameworkFlutter Material 3 (useMaterial3: true)
ColorSchemeColorScheme.fromSeed(seedColor: primary) + overrides from 02_Design_Tokens.md
ThemeDataOne AppTheme.light() / AppTheme.dark(); themeMode from system or org branding
Text themeTextTheme from 02_Design_Tokens.md §2
SpacingAppSpacing extension class (semantic xs/s/item-spacing.24/m/l/xl + fixed scale)
RadiusAppRadius class (r-micro … r-full)
ElevationAppElevation class; shadow tokens via BoxShadow/elevation params
MotionAppMotion class (durations + Curves)
Localizationflutter_localizations + Intl; MaterialApp.localizationsDelegates
FontsTheme font Inter (default); mono via JetBrains Mono (fontFamilyFallback)

2. Widget → token mapping (core)

Flutter widgetM3 roleStudyLyon token
FilledButtonPrimaryprimary/onPrimary, r-md, m-fast ripple
FilledButton.tonalSecondarysecondaryContainer/onSecondaryContainer
OutlinedButtonTertiaryoutline, onSurface, r-md
TextButtonText actionprimary
IconButtonIcon actiononSurfaceVariant, tooltip required
TextFieldFilled inputfill surfaceVariant, border outline, focus primary, r-md
CardElevated cardsurfaceContainerLow, e-1, r-lg
ListTileList rowsurface, height 56, onSurface/onSurfaceVariant
NavigationBarBottom navsurfaceContainer, indicator secondaryContainer, 64 h
NavigationRailTablet/desktop navsurfaceContainerLow, indicator secondaryContainer
NavigationDrawerDrawersurfaceContainerLow, e-4, width 320
AppBarTop barsurface, titleLarge, centerTitle false (phone), true (desktop)
TabBarSection tabsindicator primary, labelLarge
FloatingActionButtonPrimary actionprimaryContainer, e-3, 56 dp
Dialog/AlertDialogDialogsurfaceContainerHigh, e-4, r-lg
BottomSheetSheetsurfaceContainerHigh, e-5, r-xl top corners
SnackBarToastinverseSurface, m-base slide
Chip (Filter/Input)Chipselected secondaryContainer, else surfaceVariant
Switch/Checkbox/RadioToggleprimary selected, onSurfaceVariant unselected
SliderSliderprimary, e-0
LinearProgressIndicatorProgressprimary, surfaceVariant track
RefreshIndicatorPull-refreshprimary spinner
BadgeBadgeerror for counts, tertiary for dues
TooltipTooltipinverseSurface, m-fast
HeroHero transitionm-slow flight
SemanticsA11y labelsall interactive nodes labeled

3. Typography mapping

M3 tokenTextTheme memberTypical widget
displaySmalldisplaySmallEmpty states
headlineMediumheadlineMediumScreen titles
titleLargetitleLargeCard/dialog titles
titleMediumtitleMediumSection headers, list titles
bodyLargebodyLargeBody, descriptions
bodyMediumbodyMediumLabels, secondary
bodySmallbodySmallCaptions, timestamps
labelLargelabelLargeButtons, tabs
labelMediumlabelMediumBadges
labelSmalllabelSmallOverlines

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: onSurfaceVariant default; primary active; error destructive.

5. Motion mapping

UseTokenFlutter
Page transitionm-base 250 msPageTransitionsTheme (FadeUpwards on Android, Cupertino on iOS)
Dialog/sheetm-baseshowDialog/showModalBottomSheet defaults
Herom-slow 400 msHero widget
Ripplem-fast 150 msInkWell/InkResponse
Hover/focusm-fastMouseRegion + AnimatedContainer/focus ring
List entrancem-entrance stagger 40 msAnimatedList/explicit TweenAnimationBuilder
Skeleton shimmerm-slow loopcustom AnimatedBuilder
Snackbarm-baseScaffoldMessenger

6. Layout tokens

TokenValue
Screen horizontal padding16 (phone), 24 (tablet/desktop)
Card padding16
Card gap12
Section gap24
Toolbar64
Bottom nav height64
List row56
Master-detail breakpoint840 dp

7. Theming rules (enforced)

  1. No literal Color in widget code — context.colorScheme.* / AppTokens.* only.
  2. No literal padding/margin — AppSpacing.* only.
  3. No literal durations — AppMotion.* only.
  4. Dark mode is a first-class theme, not an overlay.
  5. Tenant branding overrides primary at runtime via ThemeData copy.

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 LayoutBuilder breakpoints (02_Design_Tokens.md §8).
  • Scrollbars always visible on desktop.

05 — Global Information Architecture (Shared)

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
  • AppShell resolves 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)

DestinationRoute prefixRoles (default)
Home / Dashboard/homeall
Students/studentsadmin, staff, teacher (read)
Attendance/attendanceteacher, admin
Academics/academicsadmin, teacher (read)
Exams & Results/examsteacher, admin; results read: parent/student
Fees/feesaccountant, admin; read: parent
Communication/messages, /announcementsall
Notifications/notificationsall
CRM (Leads/Admissions)/crmadmin, staff
Staff/staffadmin, HR
Library/librarylibrarian, admin, student (read)
Transport/transporttransport manager, admin
Timetable/timetableteacher, admin, student (read)
Leave/leavestaff (self), admin
Homework/homeworkteacher, student, parent (read)
Reports/reportsadmin, accountant
Settings/settingsadmin
Users & Roles/users, /rolesadmin
Organization/organizationadmin
Search/searchall (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 with go_router StatefulShellRoute.
  • 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 StatefulShellBranch so 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)

RouteScreenAuth
/loginLoginpublic (redirects to /home if authed)
/login/2fa2FA challengepublic
/registerTenant registrationpublic
/verify-emailEmail verificationpublic
/reset-passwordPassword resetpublic
/homeDashboard/Homeauthed
/notificationsNotification centerauthed
/searchGlobal searchauthed
/settingsSettingsauthed
/users/:id, /roles/:id, …module routesauthed + 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)

  1. Snackbar/Toast — transient, never blocks.
  2. Bottom sheet — pickers, filters, quick actions (half/full height).
  3. Dialog — confirmations, forms ≤ 3 fields, destructive confirms.
  4. Full-screen page — forms with > 3 fields, detail screens, wizards.
  5. 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; Esc closes modal/sheet; + or N new 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 check user.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)

Recommended client state architecture (Bloc/Cubit). Decision status: proposal — flagged because no client exists yet. Module docs 13_State_Management.md define 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_it or providerrecommend get_it + injectable.
  • Server state: repository layer with dio; cache via Hive/shared_preferences for small config and drift only 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).
  • RefreshIndicator always bypasses cache.

3.4 Realtime

  • WS gateway (/ws via WsModule) delivers notification.new, message.new, announcement.published, attendance.changed, invoice.updated events 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 (dio interceptor, 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, Clear naming.
  • One Cubit per screen; shared selectors for cross-screen data (current user, current academic year, feature flags).
  • FeatureFlagsCubit gates UI per tenant (biometric, SMS, WhatsApp channels).

5. Error handling (client)

Server codeClient 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.
  • AppStateObserver logs transitions (dev only).
  • Every cubit has a widget-test pair driving AppSkeleton/AppErrorState/AppEmptyState permutations.

07 — API Conventions (Shared)

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 prefix v1; see version.constants.ts).
  • Content-Type: application/json; multipart for file uploads.
  • Auth: Authorization: Bearer <accessToken>; refresh via POST /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": "..."
}
HTTPCodeMeaning
400VALIDATION_ERRORInvalid input (details = per-field)
401UNAUTHENTICATEDMissing/expired/invalid token
403PERMISSION_DENIEDAuthed but not allowed
404RESOURCE_NOT_FOUNDMissing resource (also for cross-tenant IDs — do not leak existence)
409DUPLICATE_RESOURCEUnique constraint hit
422BUSINESS_RULE_VIOLATIONBusiness rule refused
429RATE_LIMITEDRate limit exceeded
5xxINTERNAL_SERVER_ERRORGeneric; never expose internals

4. Rate limits (client-relevant)

TierLimitNotes
auth10/minlogin, register, reset — client shows countdown, no auto-retry
api100/minnormal app usage; client backoff on 429
admin500/minadmin endpoints

5. Pagination & filtering conventions

  • Query: page (1-based), limit (1–100, default 20), sort (field or -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 tenantId claim; server derives tenant from token — client never sends tenantId in 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-Control guarantees 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}; topics notification.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-Key header (UUID) on critical POSTs where the module API supports dedup; retry-safe by design otherwise.

10. Offline strategy

  • Reads: last-good cache, offline banner, Retry on 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

  • v1 current; breaking changes → new version prefix; client negotiates via Accept: application/vnd.studylyon.v1+json if backend adds it (flag: not in code yet).

08 — Interaction & Motion (Shared)

Global interaction conventions and motion language. Module docs 10_Interaction_Specification.md specialize per module.


1. Touch gestures (phone/tablet)

GestureBehaviourWhere
TapPrimary action, ripple m-fastEverywhere
Long-pressContext menu (AppMenu), reorder, multi-select entryList rows, cards, attachments
Double-tapZoom on images/PDF; nothing elseMedia previews
Swipe left/rightRow actions (mark read, delete draft, archive)Lists with Dismissible-style actions (keep ≤ 2)
Pull downRefreshAll scrollables
PinchZoomMedia, charts (tablet)
DragReorder (timetable slots, dashboard widgets), image preview dismissModule-specific
Hardware back / back gesturePop; on root tab → exit app (Android) or previous shell branchAll
Edge swipeDrawer on phoneApp shell

2. Desktop/web (mouse + keyboard)

InputBehaviour
Hoverm-fast surface tint, tooltip after 500 ms
ClickTap equivalent
Right-clickContext menu (same as long-press)
WheelScroll; Ctrl+wheel zoom on media
ESCClose dialog/sheet/menu/search; cancel selection
Tab / Shift+TabFocus traversal (logical order), visible focus ring primary
EnterSubmit focused form; Space toggles focused control
Ctrl+KGlobal search
Ctrl/Cmd+EnterSubmit from text areas
ArrowsList navigation in menus/dropdowns

3. Haptics (mobile)

EventHaptic
SuccessHapticFeedback.lightImpact()
ErrorHapticFeedback.mediumImpact()
Long-press menuHapticFeedback.selectionClick()
Pull-to-refresh completionlightImpact()
Destructive confirmheavyImpact()
RuleHaptics 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 via AnimatedSwitcher.
  • 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 AnimatedSize for expanding sections.

5. Micro-interaction catalogue

InteractionDetail
Row tapInk ripple + 2% surface darken; 40 ms press latency feedback
FAB appearScale-in m-entrance; hide on scroll-down
Tab switchIndicator slide m-base
Badge updateScale pop on unread count change
Pull-to-refreshSpinner primary, m-slow; on success + lightImpact
Submit buttonLoading spinner replaces label; success → brief check + snackbar
Infinite scroll loadBottom 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-fast ripple only — no full-screen flashes.

09 — Accessibility Baseline (Shared)

The mandatory accessibility standard every screen must meet. Module docs reference this; QA verifies per 14_QA_Checklist.md and 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)

UseMinimum
Body text4.5:1
Large text (≥ 18 pt / 14 pt bold)3:1
UI components & iconography (required to identify)3:1
Disabled contentexempt but ≥ 3:1 vs background
Focus indicators3: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 FocusTraversalGroup per screen; modal dialogs trap focus.
  • ESC exits modals; focus returns to trigger.

7. Screen reader labels (canonical patterns)

ElementLabel pattern
NavigationBar destinationLabel + "selected/unselected"
FABVerb + noun: "Add student"
Chart"Attendance trend, October: 92% present" (summary text)
TableColumn headers announced; row summary
AvatarPerson'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/onSuccess with 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 headlineMedium per screen; logical h-hierarchy; sections use Semantics(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)

Cross-module quality gates every screen/module must pass. Module 14_QA_Checklist.md files add module-specific items to this baseline.


1. Performance budgets

MetricBudget
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 jank0 dropped frames in profiled scroll (60 fps)
Image list scroll< 8 ms frame build cost
APK/IPA sizedebug < 100 MB, release < 40 MB
Memoryno growth across 20 min session
Networkno duplicate parallel requests for same resource

2. Offline matrix

ScenarioExpected
Airplane mode → open appCached data renders + offline banner
Kill app offline, relaunchCached data + banner
Offline → attempt writeBlocked with guidance (unless module defines offline queue)
Offline → background → onlineAuto-flush queue, snackbar "Synced N items"
Slow network (3G throttle)Skeletons, no ANR, retry works
Network loss mid-uploadUpload shows pending/retry; no silent corruption
Network loss mid-paymentNever 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)

ClassDevices
PhonePixel 8, Galaxy S23, iPhone 14/SE, 2× mid-range Android
TabletiPad 10, Galaxy Tab S8 (portrait + landscape)
Desktop/webChrome, Edge, Safari, Firefox ≥ 1280 px
FoldablePixel Fold (cover + unfolded)
OSAndroid 10–15, iOS 16–18, Windows 11, macOS 14

5. Localization

  • i18n via flutter_localizations; backend i18n module exists (i18n controller) — 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 Intl locale.
  • 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)

LayerCoverage target
Unit (cubits, validators, formatters)≥ 80% logic
Widget testsEvery screen: 3 states (loading/error/success+empty)
Golden testsShared components + every screen (light/dark, 3 sizes)
IntegrationCritical 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)

How the Flutter client is built. Module docs 15_Flutter_Implementation_Guide.md extend 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, dates DateTime, money in minor units int).
  • Serialization with json_serializable (build_runner) or hand-written fromJson (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 → typed ApiException (code/status/fieldDetails), idempotency header helper, requestId header, timeout 15 s (uploads 120 s).
  • WS: WsClient (socket.io-client or dart web_socket_channel per backend gateway — verify backend protocol; flagged assumption) with auto-reconnect + backoff.

6. Router (go_router)

  • AppRouter: GoRouter with StatefulShellRoute.indexedStack branches per top-level destination; route guards: authGuard, permissionGuard('x.y'), tenantGuard.
  • Deep links: Uri route table in module docs; push-notification tap → navigate by path.
  • 403/404/500 error screens registered globally.

7. Theme

  • AppTheme.light()/dark() from 02_Design_Tokens.md; org branding override; MaterialApp(theme, darkTheme, themeMode, locale, localizationsDelegates).

8. Extensions (shared)

  • context helpers: spacing, colorScheme shortcuts, showAppSnackbar(type, message, action).
  • String: toDate, capitalize, maskedEmail, initials.
  • int: toMoney(currency), toOrdinal.
  • DateTime: toDisplayDate(locale), startOfDay, isSameDay, weekdayLabel.
  • List: groupBy, chunk.

9. Localization

  • .arb files: app_en.arb, app_fr.arb, … (start: en; add per org demand).
  • AppLocalizations generated (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

  • AnalyticsService interface (firebase_analytics recommended; swappable); events per 10_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_test journeys: 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

  • const constructors everywhere; ListView.builder mandatory for lists; RepaintBoundary on charts/media; image cache cached_network_image with 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)

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

#ItemStatusImpact
A1PRD 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 specsAll Flutter docs
A2Web-first UI exists only as roadmap; no web client specAssumed: same design system, responsive shell (05_Global_Information_Architecture.md)All
A3No brand assets, logo, or org branding defaults beyond organization.branding schema fieldAssumed: token defaults in 02_Design_Tokens.md; org override hooks onlyTheme, onboarding
A4Analytics SDK not chosenContract defined (10_QA_Baseline.md §8), SDK openAll analytics events
A5Supported locales beyond English unspecified (backend i18n module exists)Assumed: en + platform locales; org-driven additionsLocalization

B. API / backend

#ItemStatusImpact
B1Endpoints in docs/IMPLEMENTATION_PLAN.md not yet in code (full communication/leave/CRM-admission/timetable-slot APIs)Flagged (planned) in every doc where usedThose modules
B2WebSocket handshake/protocol not implemented (WsModule gateway exists)Assumed standard WS + bearer; verify before buildRealtime everywhere
B3No push-device registry/DeviceToken endpointForward-looking; docs mark push entry points as plannedNotifications, attendance
B4No QR-code backend support (no QR endpoints)QR entry points marked forward-looking onlyBiometric, transport, library
B5Server cache TTLs not enumerable from codeClient cache TTLs proposed per module (06 §3.3)All lists
B6Idempotency-Key header support not confirmed per endpointClient contract defined; verify per moduleWrites
B7File upload resume (chunked) not implementedUploads queued + retried, no resumeFiles, homework
B8Payments integration: gateway status per tenant unknown (manual reconciliation per PRD §8)Client shows "payment recorded/pending" from server state onlyPayments, fees

C. Product decisions deferred (no backend signal)

#ItemWho decides
C1Exact navigation order/destinations per role (proposed in 05 §2)Product owner
C2Which lists are infinite-scroll vs paginatedPer-module design owner
C3Offline write queues beyond attendance/homework draftsProduct owner
C4Biometric client gate (fingerprint for payments/admin)Security owner
C5Dashboard widget set + customization depthProduct owner
C6Notification channel defaults per role (email/push/in-app/WhatsApp)Tenant config (backend notification.read perms)

D. Design system

#ItemStatus
D102_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
D2Iconography pack unselectedMaterial Symbols assumed; no icon tokens in export
D3Flutter deps list unverified for licensingVerify before adopting (fl_chart, mobile_scanner, flutter_markdown)
D4Typeface resolvedInter (UI) + JetBrains Mono (data) — designer-provided; org font support planned
D5Dark chromatic roles (primary/secondary/tertiary/error/success/warning) absent from exportTBD by designer; working proposal = M3 tonal derivation from light roles (dark primaryinversePrimary #B7C4FF)
D6No elevation/shadow tokens in exportdp 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)

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, and docs/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:

EntityCollectionMeaningSource
Academic Yearacademic_yearsA session (e.g. "2026–2027") with dates, status, isCurrent flagschemas/academic-year.schema.ts:13
GradegradesA year level ("Grade 1", "Grade 10"), optional per-year, displayOrderschemas/grade.schema.ts:7
SectionsectionsA division of a grade ("A", "B", "C")schemas/section.schema.ts:7
ClassclassesThe concrete teaching unit: year + grade + section (e.g. "Grade 10 - A")schemas/class.schema.ts:7
SubjectsubjectsMaster catalog (code, marks configuration, credits)schemas/subject.schema.ts:7
Subject Assignmentsubject_assignmentsTeacher ↔ Subject ↔ Class ↔ Year bindingschemas/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 classId per year (blueprint 04-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

GoalMeasureEvidence
One-click year rolloverPATCH /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 dataunique-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 ladderdisplayOrder sort default on grades (grade.service.ts:39); index {tenantId, displayOrder} (grade.schema.ts:27)
Full multi-tenant isolationevery query tenant-scoped by BaseRepository.scopedFilter() (database/base.repository.ts:20-30)
Downstream consistencytimetable/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

DependencyRoleSource
Auth (JWT)every endpoint guarded by JwtAuthGuardacademics.module.ts:4,46; all controllers @UseGuards(JwtAuthGuard)
RBAC permissionsgap: no academics.* permission exists in ALL_PERMISSIONS — only JWT presence is enforced todayrbac/permissions.constants.ts:1-97 (see OQ-1)
TenantContextServicetenant scoping + platform-admin bypassrepositories/*.ts, database/base.repository.ts:20-30
Mongo indexes{tenantId, code} unique on subjects; per-entity tenant+ref indexessubject.schema.ts:38, class.schema.ts:38-40
Timetable / Attendance / Students / Homework / Examsconsumers of class/subject/year referencestimetable/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 status is 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-current un-flags every isCurrent then flags the target and sets status: active (academic-year.service.ts:72-79); the target may have had status: upcoming or archived.
  • Grade academicYearId is optional in both schema and DTO — grades can exist outside any year (grade.schema.ts:9-10, create-grade.dto.ts:5-8); class academicYearId is 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).
  • UpdateSubjectDto cannot change theoryMarks/practicalMarks (update-subject.dto.ts:4-42).
  • Assignments have no GET /subject-assignments/:id and no update endpoint — remove + recreate is the only correction path (subject-assignment.controller.ts:19-39).
  • class.campusId has ref: 'Class' in the schema (likely intended Campus) (class.schema.ts:12-13) and roomId is a free string, not a Room ref (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 default displayOrder: 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, batchId fields) 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-teacher lookups — they return bare arrays (subject-assignment.service.ts:19-31).

10. Open questions (module-level; global ledger in 00-shared/12)

#ItemImpact
OQ-1No 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-2No 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-3Deleting 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-4No 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-5Subject 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-6class.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-7PaginationQueryDto.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)

TermMeaning
Academic Yearacademic_years doc; status ∈ `upcoming
Gradegrades doc; level name + optional code + displayOrder; optional per-year binding
Sectionsections doc; division of a grade (name A, B, …), own capacity/teacher/room
Classclasses doc; the teaching unit year + grade + section + name; required refs to year/grade/section
Subjectsubjects doc; catalog entry with code (unique per tenant) and marks config (max/passing/theory/practical)
Subject Assignmentsubject_assignments doc; teacher×subject×class×year tuple
set-currentPATCH /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)

Personas for the Academics module, derived from blueprint 01-Product/USER_PERSONAS.md role model (roles examples: Super Admin, Organization Admin, Teacher, Parent, Student — COLLECTIONS.md:600-623) and the endpoints/semantics in src/modules/academics/**. Permission notes reflect the actual guard state: every endpoint is JWT-authenticated but no academics.* 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")

AttributeValue
Role / contextOwner/admin of one school tenant; member with organization.* perms; sees everything (base.repository.ts:20-30 scoping)
TechTablet (Galaxy Tab S8) + desktop web; occasional phone
GoalsSet 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 pointsRebuilding structure every year manually; accidental duplicates ("Grade 1" created twice); forgetting to demote the old current year
Key flowsCreate academic year → set current → bulk-create grades → create classes with sections; monitor statuses
Kill featurePATCH /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")

AttributeValue
Role / contextSenior teacher or dedicated coordinator; the day-to-day owner of the structure after the admin seeds it
TechDesktop web primary, phone for spot checks
GoalsWire 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 pointsAssignment matrix gets large (classes × subjects × teachers); no bulk ops; removing a bad assignment requires delete + recreate (subject-assignment.controller.ts:19-39)
Key flowsBrowse hierarchy per year → open class → view subjects+teachers → add/remove assignments; fix conflicts
Kill featureGET /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")

AttributeValue
Role / contextClass teacher of "Grade 10 - A" (via classTeacherId), subject teacher via assignments
TechPhone (Pixel 8) mostly; tablet in classroom
GoalsSee which classes/sections/subjects they teach this year; know their class's structure; enter timetable/attendance against the right classId
Pain pointsYears change → roster changes; needs year-scoped view, not a global dump
Key flowsMy classes (by-teacher) → class detail → subjects; timetable for own class
Kill featureGET /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)

AttributeValue
Role / contextStudent profile linked to a class via class_enrollments (RELATIONSHIPS.md:63-65); parent linked to student
TechPhone; light usage
GoalsSee "my grade, my section, my class, my subjects" for the current year; understand report/timetable references
Pain pointsCurrently 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 flowsHome → Academics (read-only) → structure browse filtered to own class
Kill featureSubject 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)

CapabilityAdminCoordinatorTeacherStudent/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)

End-to-end journeys computed from src/modules/academics/controllers/*, services/*, and the consumer modules (timetable, attendance). Mirror of the admin flow in docs/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 (all IsMongoId, 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's classId from 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)

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/** and 03-Database/RELATIONSHIPS.md:50-61.


1. Place in the global shell

Top-level destination (per 00-shared/05 §2):

DestinationRoute prefixRoles (default)
Academics/academicsadmin, 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.academicYearId optional — 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 hereToVia
Class detailTimetable of classGET /timetable?classId= (timetable.controller.ts:22-29)
Class detailAttendance of classattendance module (classId filter)
Class detailStudents in classStudents module (class_enrollments, RELATIONSHIPS.md:63-65)
Teacher rosterTeacher timetableGET /timetable?teacherId= (timetable.controller.ts:24-27)
Subject detailHomework / ExamsHomework + Exams modules (subjectId refs, 04-Modules/Exams.md:51)

5. Information grouping rules

  1. Year-first browsing: all admin reads default to the current year (isCurrent:true resolved from the years list, academic-year.service.ts:69-83); a global year switcher (AppBar chip) re-scopes every list that supports academicYearId.
  2. 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.
  3. 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.
  4. 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)

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
EndpointGET /academic-years (paginated) — academic-year.controller.ts:27-29
PurposeAll years, newest first (default sort=createdAt:-1, academic-year.service.ts:47); status badges; current-year flag
ActionsAdd year (FAB), set current (row menu), edit, delete (soft)
EmptyAppEmptyState: "No academic years yet — add your first"
RolesAdmin/coordinator full; teacher/student read

2. Academic Year Detail

Route/academics/years/:id
EndpointGET /academic-years/:id (academic-year.controller.ts:30-32); classes feed GET /classes/by-year/:academicYearId (class.controller.ts:30-35)
PurposeDates, status, isCurrent; inline list of the year's classes
Actionsset-current, edit dates/status, delete

3. Grades List

Route/academics/grades
EndpointGET /grades (paginated) — grade.controller.ts:27-29; default displayOrder:1 (grade.service.ts:39)
PurposeThe tenant's ladder (Grade 1…12) in defined order; optional code (G1), per-grade status (free string, grade.schema.ts:21-22)
ActionsAdd grade, edit, delete, reorder via displayOrder

4. Grade Detail (with sections)

Route/academics/grades/:id
EndpointGET /grades/:id (grade.controller.ts:30-32); sections GET /sections/by-grade/:gradeId (section.controller.ts:30-35)
PurposeGrade metadata + its sections (chips) + classes linking it (client-joined)
ActionsEdit grade; add section; jump to classes of grade

5. Classes List

Route/academics/classes (optional year filter via year switcher)
EndpointGET /classes (class.controller.ts:27-29) or GET /classes/by-year/:academicYearId (class.controller.ts:30-35)
PurposeAll classes; each row shows joined grade + section chips, capacity, status
ActionsAdd class (cascading pickers), edit, delete

6. Class Detail — MASTER screen

Route/academics/classes/:id
EndpointGET /classes/:id (class.controller.ts:36-38); roster GET /subject-assignments/by-class/:classId?academicYearId= (subject-assignment.controller.ts:24-29)
TabsOverview (meta + chips) · Subjects & teachers (assignment matrix rows) · Students (link to Students module, (planned) read-only count)
ActionsEdit class; add subject+teacher; replace/remove assignment; jump to timetable/attendance (consumers)

7. Section Detail

Route/academics/sections/:id
EndpointGET /sections/:id (section.controller.ts:36-38); classes of section = client-joined from GET /classes
PurposeSection meta (capacity, classTeacher, room) + classes using it
ActionsEdit section; delete

8. Sections List

Route/academics/sections
EndpointGET /sections (section.controller.ts:27-29) or by-grade
PurposeAll sections (or of one grade) with grade label

9. Subjects List

Route/academics/subjects
EndpointGET /subjects (subject.controller.ts:27-29); insertion order (subject.service.ts:35-47)
PurposeCatalog: code (unique), name, shortName, marks config preview, status
ActionsAdd subject, edit, delete

10. Subject Detail

Route/academics/subjects/:id
EndpointGET /subjects/:id (subject.controller.ts:30-32)
PurposeFull marks configuration (max/passing/theory/practical) + usage hint (classes assigning it — client-joined from rosters)
ActionsEdit (note: theory/practical not updatable — update-subject.dto.ts:4-42, OQ-5)

11. Subject Assignment Matrix

Route/academics/classes/:id/assign
Endpointroster by-class; create POST /subject-assignments (subject-assignment.controller.ts:21-23); delete DELETE /subject-assignments/:id (:36-38)
PurposeFull-screen matrix: rows = assigned (subject, teacher); add = subject picker → teacher picker
NotesNo update endpoint → "Replace" = delete+create; duplicates possible server-side (OQ-4)

12. Hierarchy Explorer

Route/academics/explore
Endpointcomposition of GET /academic-years, /grades, /sections/by-grade, /classes/by-year (client-side tree join; no tree endpoint exists)
PurposeOne-screen tree: Year → Grade → Section → Class, with subject count badges; used by coordinators for gap spotting
RolesAll (read); admin/coordinator get inline quick-actions

13. Teacher Roster (by-teacher)

Route/academics/teachers/:teacherId
EndpointGET /subject-assignments/by-teacher/:teacherId?academicYearId= (subject-assignment.controller.ts:30-35)
PurposeTeacher's teaching load per year: class + subject rows; conflict spotter (same subject in 2 classes)
RolesTeacher (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

ScreenReadsWrites
Year list / detailGET /academic-years(,/:id)POST, PATCH /:id, PATCH /:id/set-current, DELETE /:id
Grades list / detailGET /grades(,/:id), GET /sections/by-grade/:gradeIdPOST /grades, PATCH /grades/:id, DELETE /grades/:id
Sections list / detailGET /sections(,/:id)POST /sections, PATCH /sections/:id, DELETE /sections/:id
Classes list / detailGET /classes, GET /classes/by-year/:y, GET /classes/:idPOST /classes, PATCH /classes/:id, DELETE /classes/:id
Subjects list / detailGET /subjects(,/:id)POST /subjects, PATCH /subjects/:id, DELETE /subjects/:id
Assignment matrix / rosterGET /subject-assignments/by-class/:c?academicYearId=, by-teacher/:t?academicYearId=POST /subject-assignments, DELETE /subject-assignments/:id
Explorercomposition above

06 — Screen Specifications (Academics Module)

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 in data; lists paginated with meta{page,limit,totalItems,totalPages,hasNext,hasPrevious} (pagination-query.dto.ts:32-39).


0. Shared chrome & conventions (all screens)

  • Year switcher: AppDropdown chip in the AppBar (values = cached years, 13_State_Management.md §3); selection is a ReferenceScope in the cubit that re-scopes every academicYearId query; 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) for academics.* 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 AppEmptyState per entity with its primary CTA.
  • Loading: AppSkeleton(list) ≤ 200 ms to first frame; content ≤ 2 s (00-shared/10 §1).
  • Errors: AppErrorState with server code; 409/422 → inline banner; 5xx → generic + requestId hint; 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) — current variant primaryContainer.
  • Title: name (Ty.titleMedium).
  • Subtitle: startDate → endDate (locale Intl, dd MMM yyyy), bodySmall onSurfaceVariant.
  • Trailing: status badge + AppMenu (Set as current / Edit / Delete).

States:

  • idle → list from GET /academic-years (sort=-createdAt server default).
  • paginated: hasNext → infinite scroll footer spinner (Mo.m-instant).
  • pull-to-refresh: re-fetch page 1.
  • current-year row pinned first with a primaryContainer leading pill "Current" (client sort: isCurrent desc, 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):

  • headlineMedium name; 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/:academicYearId paginated (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: code chip (mono) if set; status badge (non-active only); 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.groups avatar.
  • 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 — classTeacherId is a user ObjectId per create-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, trailing AppMenu (Replace / Remove).
  • Conflict surface: duplicate subject rows → AppBanner(warning) "Same subject assigned twice" + row highlighted tertiary; 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 → 404 Assignment 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: code mono + shortName if 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 SubjectAssignmentRow as §6).
  • "Add" opens AppBottomSheet (2/3 height):
    • AppDropdown Subject (searchable) — pre-loaded catalog; entries already assigned to this class are disabled with "Already assigned" note.
    • AppDropdown Teacher (searchable) — user pickers filtered to teacher roles (Users module); (planned) server-side teachers list.
    • CTA "Assign" → POST /subject-assignments {teacherId, subjectId, classId, academicYearId} (all IsMongoId, 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 tertiary dot + "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)

ConcernSpec
Screen enterMo.m-base slide; list→detail uses Hero on row leading avatar → header card (Mo.m-slow)
FAB actionsscale-in Mo.m-entrance; hide on scroll-down
Chip toggles (filters, year switcher)Mo.m-fast fill/outline swap
Conflict bannerslide-down + fade Mo.m-base; live region
Row removeAnimatedList exit Mo.m-fast; snackbar confirm
Reduced motionall 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)

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 (AppSkeleton rows), empty (root AppEmptyState), error (AppErrorState), expanded/collapsed.
  • Row anatomy: leading expand icon (rotates Mo.m-fast), depth indent Sp.8 × level, entity icon by type, title Ty.titleMedium, subtitle counts bodySmall 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 guides outlineVariant 1 px; Flutter: custom ExpansionTile-free AnimatedSize list (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: Wrap of FilterChip/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, primaryContainer when conflict → tertiaryContainer), title subject.name, subtitle code mono + shortName, trailing teacher name bodyMedium + AppMenu (Replace/Remove).
  • Conflict state: tertiary left 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 with AnimatedContainer border.

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); isCurrent adds a filled primary pill "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 AppDropdown in a column with AnimatedSize validation 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; label labelMedium, numeral Ty.titleMedium with tabularFigures().
  • A11y: one group labeled "Marks configuration", each value announced.
  • Source: subject.schema.ts:21-32 defaults (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 hover surfaceVariant.
  • Flutter: DataTable wrapped with Scrollbar (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: tertiaryContainer background, onTertiaryContainer text, r-md; slide-down + fade Mo.m-base; live region.
  • Flutter: custom MaterialBanner-style AnimatedContainer (not AppBanner error — 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, yearId scope; exposes years, grades, sections, classes, subjects + refreshAll().
  • Behaviour: invisible; drives RefreshIndicator cascades; emits ReferenceChanged so timetable/attendance screens re-fetch their scoped lists (13 §5).
  • Flutter: InheritedNotifier over the ReferenceCubit.

Dependency map

ComponentShared baseData source
HierarchyTreeAppCard, AppSkeleton4 list endpoints (client join)
SectionChipsAppChipsGET /sections/by-grade/:gradeId
SubjectAssignmentRowAppListTile, AppMenuGET /subject-assignments/by-class/:c?academicYearId=
YearStatusBadgeAppChipsGET /academic-years
EntityStatusBadgeAppChipsGET /grades / /sections / /classes
CascadingEntityPickerAppDropdownyear/grades/sections lists
MarksSummaryTilesAppCardGET /subjects/:id
RosterTableDataTableroster endpoint
ConflictBannerAppBanner patternclient-computed

08 — Form Specifications (Academics Module)

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 → 400 VALIDATION_ERROR with 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-8601 YYYY-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:

FieldReqControlValidation (server)Notes / UI
nameAppTextFieldIsStringe.g. "2026-2027"; 409 on duplicate (academic-year.service.ts:25-29) — inline banner, server copy
startDateAppDatePickerIsDateStringISO date; helper "e.g. 2026-04-01"
endDateAppDatePickerIsDateStringclient rule: must be > startDate (server does not check — OQ-5 01; still block client-side)
statusAppDropdownIsStringenum 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: isCurrent is 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 forces status: 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:

FieldReqControlValidationNotes
nameAppTextFieldIsStringe.g. "Grade 1"; 409 on duplicate (grade.service.ts:21-24)
academicYearIdAppDropdown (year)IsMongoIdschema optional (grade.schema.ts:9-10) — label "Attach to year (optional)"; empty allowed, sent as null/omitted
codeAppTextFieldIsStringe.g. "G1"; auto-uppercase formatter
displayOrderAppTextField (number)IsInt, Min(0), default 0ladder position; default = next max+1 (client pre-fill, editable)
  • Success → list refresh (keeps displayOrder sort, 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:

FieldReqControlValidationNotes
gradeIdAppDropdown (grade)IsMongoIdpre-selected when arriving from grade detail
nameAppTextFieldIsStringe.g. "A"; client warns if (gradeId, name) duplicates (server does not check — OQ-4)
capacityAppTextField (number)IsInt, Min(1), default 40
classTeacherIdAppDropdown (users, teacher role)IsMongoIdlabel "Class teacher (user)" — create-section.dto.ts:19-21
roomIdAppTextFieldIsStringfree 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:

FieldReqControlValidationNotes
academicYearIdAppDropdown (year)IsMongoIdcascading picker part 1 (07 §6)
gradeIdAppDropdown (grade)IsMongoIdpart 2; disabled until year picked (UI only — server doesn't cross-check refs)
sectionIdAppDropdown (section, filtered by grade)IsMongoIdpart 3; disabled until grade picked
nameAppTextFieldIsStringauto-suggest "Grade {grade.name} - {section.name}" (editable) — example "Grade 10 - A" (create-class.dto.ts:22)
capacityAppTextField (number)IsInt, Min(1), default 40
campusIdAppDropdown (campus)IsMongoIdhidden (planned) — no campus module/endpoint; schema ref points at Class (OQ-6)
classTeacherIdAppDropdown (users, teacher role)IsMongoId"Class teacher (user)" — create-class.dto.ts:32-35
roomIdAppTextFieldIsStringfree 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:

FieldReqControlValidationNotes
codeAppTextFieldIsStringe.g. "MATH101"; 409 on duplicate (subject.service.ts:21-25) + DB unique index (subject.schema.ts:38); auto-uppercase
nameAppTextFieldIsStringe.g. "Mathematics"
shortNameAppTextFieldIsStringe.g. "Maths"
creditsAppTextField (number)IsInt, Min(0), default 0
maximumMarksAppTextField (number)IsInt, Min(1), default 100
passingMarksAppTextField (number)IsInt, Min(1), default 33
theoryMarksAppTextField (number)IsInt, Min(0), default 80
practicalMarksAppTextField (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:

FieldReqControlValidationNotes
classId(context, hidden)IsMongoIdfrom the class screen — not user-entered
academicYearId(context, hidden)IsMongoIdfrom year switcher
subjectIdAppDropdown (subjects, searchable)IsMongoIdalready-assigned subjects disabled (client guard, OQ-4)
teacherIdAppDropdown (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 errorText from error.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)

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 409 DUPLICATE_RESOURCE with 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-current is a global, one-way-ish flip: PATCH /academic-years/:id/set-current clears isCurrent on every other year, then sets the target isCurrent:true and status:"active" (academic-year.service.ts:69-83). The old current year does not go to archived automatically — it stays active minus 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 displayOrder asc (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 = editing displayOrder (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 status is 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-active value as inactive.

2. Common mistakes & countermeasures

MistakeRelatedUX countermeasure
Creating "Grade 1" twice ("1" vs "One" vs "Grade-01")409 name checkpre-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")409fixed format helper + example in the field; paste-tolerant parser (proposed)
Subject code case drift ("math101" vs "MATH101") — server matches exact stringunique 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 yearsetCurrent 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/classesno 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 classno 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 rowno update endpoint"Replace" flow (delete + recreate) with single confirm, not two steps
Typing passingMarks > maximumMarksno cross-field validation server-side (OQ-5)client-side block + inline error; keep server copy as fallback
Expecting search to filter server-sideq unused (OQ-7)label the search "Filter (on this device)" — or hide until server search lands
Reordering classes/sections by draginsertion order server-sideno 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, displayOrder auto-incremented).
  • New user (fresh tenant): first-run empty states chain — create year → create grades → create sections → create classes → assign subjects. Each AppEmptyState CTA 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); SectionChips overflow 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 quitWhat 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 subjectsno batch API — client memoizes in-progress sheet state; offer "finish later" row on class detail (proposed)
Delete confirm dialogssafe: nothing deleted until confirm; back = cancel

7. Frustration points → UX treatments (summary)

FrustrationTreatment
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 devicerefresh on screen focus; conflict banner + merge/remove action
Long class lists with no orderingyear filter chip defaults "This year"; client group-by-grade view toggle (proposed)
Orphaned structure after a delete elsewhereorphan 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 version counter incremented per update (base.repository.ts:57-66) but the client does not send it (PATCH bodies are plain $set DTOs, update-class.dto.ts:4-50); no 409/412 optimistic-concurrency errors surface. UI: refresh lists on focus; treat remote changes as truth.
  • set-current raced 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)

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

GestureBehaviourWhere
Tapnavigate rows, toggle tree nodes, open chips/sheets, submitall screens
Tap (row trailing)AppMenu (Edit / Delete / Replace / Remove / Set as current)lists, roster rows
Long-presssame as row menu (tree nodes, roster rows)explorer, matrix
Double-tapexpand/collapse tree node on the label (first tap = select, second = toggle) (proposed)explorer
Swipe leftno destructive swipe — deletes are destructive, keep confirm dialogsall lists
Pull downRefreshIndicator: re-fetch page 1 (lists) / refetch roster + cache (07 §10)lists, detail, explorer
Pinchnone (no media; tree depth ≤ 5 levels)
Hardware backpop to list; sheet → back closes sheet; root → exit shellall
Edge swipedrawer (phone shell)shell

2. Keyboard / desktop / mouse

InputBehaviour
Tablogical order Year → Grade → Section → Class → Subject (forms + explorer)
Entersubmits the visible form / confirms dialog primary action
Esccloses sheet/dialog, collapses popovers, cancels selection → focus returns to trigger
Nnew entity on list screens (year/grade/section/class/subject)
Ctrl+Entersubmit form; set-current confirm
Arrowsnavigate tree nodes (↑/↓ move, →/← expand/collapse), dropdown options
Spacetoggle selected chip / expand node
Right-clickcontext menu = row AppMenu (same actions)
Hoverrow/tree-node highlight surfaceVariant after 150 ms; tooltip on conflict icon (500 ms)
Focusvisible focus ring on every control (00-shared/09 §6)

3. Haptics (mobile)

EventHapticRef
Create success (year/grade/class/subject/assignment)lightImpact
409 duplicate surfacedmediumImpact
Delete confirm (destructive)heavyImpact
Tree node expand/collapseselectionClick
Chips / year switcher toggleselectionClick
Ruledisabled when system haptics off; none on desktop08 §3

4. Motion language (module extension of 08 §4)

TransitionDuration/curveUsed by
List → detail pushm-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 heightmatrix add sheet
Tree expand/collapseAnimatedSize m-base; children stagger m-fastexplorer
Conflict banner in/outslide-down + fade m-baseclass detail, roster, explorer
Row removalAnimatedList exit m-fast + snackbarlists, roster
Status badge change (set-current)scale pop m-fast on new "Current" pillyear list/detail
Chips filter togglefill/outline swap m-fastlists, explorer
FAB morph (Overview → Subjects tab)AnimatedSwitcher m-fastclass detail

Reduced-motion: all of the above degrade to fades ≤ m-instant; no slide or scale.

5. Micro-interactions catalogue

InteractionDetail
Cascading picker parent changechildren reset + disabled with AnimatedSize shrink; new options fade in; selection emits {academicYearId, gradeId, sectionId} or null (07 §6)
"Add subject" in matrixsheet opens; subject dropdown searchable; already-assigned entries disabled with "Already assigned" caption
Duplicate warn on class nameas the user types the name, cache lookup on (year, grade, section, name) shows inline "Duplicate class?" confirm on submit, not on keystroke
Subject form markslive summary line updates per keystroke: "theory 80 + practical 20 = 100 ≤ max 100"; invalid combo disables submit
Set as currentserver-first: button loading → 200 → badges re-render (old current loses pill with m-fast fade) → snackbar
Remove assignmentconfirm dialog → server-first → row exit AnimatedList; 404 stale id → treat as removed + refresh
Year switcher changeall scoped lists reload (year param), skeleton only in list body, AppBar chip swaps m-fast
Pull-to-refresh cascadelist 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-fast ripple.
  • 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.
  • 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 kDebugMode flag).
  • Roster table (desktop): SelectableText on code/IDs, Ctrl+C works.

8. Validation of interaction to server semantics

Interaction claimServer check
"Set as current" confirms global un-flaggingsetCurrent loops all isCurrent:true docs (academic-year.service.ts:72-77) — confirmed
Delete of grade shows orphan warning, no cascaderemove = bare softDelete (grade.service.ts:58-61), no children checks — confirmed
Replace assignment = delete + createno update endpoint (subject-assignment.controller.ts:19-39) — confirmed
Client duplicate guard only, never claims server rejectionno 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 paramby-year / by-class / by-teacher take academicYearId (class.controller.ts:30-35, subject-assignment.controller.ts:24-35) — confirmed
Status free-string treated conservativelyschema defaults only (grade.schema.ts:21-22) — confirmed

11 — Design System Mapping (Academics Module)

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)

ScreensThemeException
Structure lists (years/grades/classes/sections/subjects)standard shell surfacecurrent-year row = primaryContainer
Class detail (master)standard shellroster conflict rows = tertiaryContainer
Assignment matrix sheetsurfaceContainerLow sheetdisabled already-assigned options = surfaceVariant text
Hierarchy explorerstandard shellempty nodes / warning dots = tertiary
Read-only browse (student/parent)standard shell, all CTAs hiddenno destructive accents visible
Destructive actions (delete/remove/set-current)error + errorContainer dialogs

2. Widget → token

ComponentM3 / FlutterTokensElevRadius
Year row (current)AppListTile in AppCard tonalprimaryContainer, border outlineVariante-1r-lg
Grade ladder rowAppListTileleading displayOrder circle surfaceVariant, mono labelMediumr-full
Class row chipsAppChipsgrade chip secondaryContainer, section chip surfaceVariant, year chip outliner-sm
HierarchyTree nodecustom list (07 §1)hover surfaceVariant; indent guide outlineVariant 1 pxr-md
Node empty/warning dotAppBadgetertiary (empty), tertiaryContainer (warning)r-full
SectionChipsWrap of InputChipselected secondaryContainer; inactive status≠active outlinedr-sm
SubjectAssignmentRowcustom ListTileconflict: tertiaryContainer bg + 2 px tertiary left borderr-md
YearStatusBadgeAppChipsupcoming outline; active primaryContainer; archived surfaceVariantr-sm
EntityStatusBadgeAppChipsnon-active → neutral surfaceVariant, raw value truncatedr-sm
CascadingEntityPickercolumn of AppDropdownfill surfaceVariant, focus primary; disabled child surfaceContainerLowr-md
MarksSummaryTiles2×2 AppCard tilesnumerals titleMedium mono + tabularFigures; label labelMediume-1r-lg
RosterTable (≥840)DataTableheader surfaceContainerLow sticky; hover surfaceVariant
ConflictBannercustom MaterialBanner-styletertiaryContainer bg / onTertiaryContainer texte-1r-md
Matrix add sheetAppBottomSheetsurfaceContainerHighe-4r-xl (top)
Destructive dialogAppDialogerrorContainer icon + error confirme-5r-lg
FAB (add entity)FloatingActionButton.extendedprimaryContainere-3r-full
Empty stateAppEmptyStateicon onSurfaceVariant 40 %

3. Typography mapping

TokenScreen element
headlineMediumdetail header names (year/grade/class/subject)
titleLargeform titles, dialog titles, explorer root nodes
titleMediumlist row titles, tab labels, section headers
bodyMediummeta rows (capacity, teacher, room), roster teacher names
bodySmalldates, hints, "N sections · N classes" counts, marks summary
labelLargebuttons, FAB label, tabs
labelMediumchips, badges, displayOrder circles
monosubject 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)

PurposeTokenApplied to
List → detail pushm-base slide + Hero m-slowall detail pushes
Tree expand/collapsem-base (AnimatedSize)explorer
Sheet (add assignment)m-base bottom slidematrix
Row exit on delete/removem-fast (AnimatedList)lists, roster
Badge change (set-current)m-fast scale popyear screens
Conflict bannerm-base slide-down + fadedetail/explorer
Filter chips swapm-fast fill/outlinelists

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 pill primaryContainer with onPrimaryContainer text.
  • 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: outlineVariant on light, outline at 40 % on dark.

7. Breakpoint behaviour

ClassListsDetailExplorer / Matrix
phone <600single column, FAB bottompushed (full screen)tree full width; matrix sheet full-height
600–839 (tablet)two-column grid (classes by grade groups)master-detail splittree with search box pinned
≥840list + detail side-by-side (StatefulShellBranch, 00-shared/05 §3)right pane tabsroster as RosterTable; explorer 2-pane (tree
≥1200max content width 1280, centeredsamesame

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)

Exact wire contract for every screen → endpoint. Base /api/v1; envelope per 00-shared/07 §2-§3. All endpoints from src/modules/academics/controllers/*; business rules from services/*; field contracts from dto/*; defaults/enums from schemas/*. Guard today: JwtAuthGuard only (academics.module.ts:46, every controller @UseGuards(JwtAuthGuard)); RBAC (planned) — OQ-1.


0. Module-wide request envelope & client policy

AspectContract
Basehttps://api.<domain>/api/v1
HeadersAuthorization: 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}
TenancytenantId 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)
Paginationpage (≥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)
Retrybackoff on 5xx/network; no auto-retry on 429 (00-shared/07 §4)
Offlinewrites blocked + banner; reads from 24 h cache (13 §3)

1. Academic Years — academic-year.controller.ts

Method & PathBody / ParamsSuccess dataRules & Errors
POST /academic-years (:24){name*, startDate*, endDate*, status?}year doc409 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,qarray + metadefault sort=createdAt:-1 (:45-47); sort maps - prefix → desc (:45-47)
GET /academic-years/:id (:30)year doc404 "Academic year not found." (:36-40)
PATCH /academic-years/:id (:33)any of {name?, startDate?, endDate?, status?, isCurrent?}year docpartial $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 & PathBody / ParamsSuccess dataRules & Errors
POST /grades (:24){name*, academicYearId?, code?, displayOrder?}grade doc409 DUPLICATE_RESOURCE "Grade "X" already exists." (grade.service.ts:21-23); displayOrder default 0
GET /grades (:27)page,limit,sort,qarray + metadefault sort=displayOrder:1 (:37-39)
GET /grades/:id (:30)grade doc404 "Grade not found." (:28-32)
PATCH /grades/:id (:33)any of {academicYearId?, name?, code?, displayOrder?, status?}grade docpartial $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 & PathBody / ParamsSuccess dataRules & Errors
POST /sections (:24){gradeId*, name*, capacity?, classTeacherId?, roomId?}section docno duplicate guard (OQ-4) (section.service.ts:16-18); capacity default 40
GET /sections (:27)page,limit,sort,qarray + metainsertion order (:41-48)
GET /sections/by-grade/:gradeId (:30)page,limitarray + metascoped {gradeId} (:26-39)
GET /sections/:id (:36)section doc404 "Section not found." (:20-24)
PATCH /sections/:id (:39)any of {gradeId?, name?, capacity?, classTeacherId?, roomId?, status?}section docpartial $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 & PathBody / ParamsSuccess dataRules & Errors
POST /classes (:24){academicYearId*, gradeId*, sectionId*, name*, capacity?, campusId?, classTeacherId?, roomId?}class docno duplicate guard (OQ-4) (class.service.ts:16-18); capacity default 40
GET /classes (:27)page,limit,sort,qarray + metainsertion order (:41-53)
GET /classes/by-year/:academicYearId (:30)page,limitarray + metascoped {academicYearId} (:26-39)
GET /classes/:id (:36)class doc404 "Class not found." (:20-24)
PATCH /classes/:id (:39)any of {academicYearId?, campusId?, gradeId?, sectionId?, name?, capacity?, classTeacherId?, roomId?, status?}class docpartial $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 & PathBody / ParamsSuccess dataRules & Errors
POST /subjects (:24){code*, name*, shortName?, credits?, maximumMarks?, passingMarks?, theoryMarks?, practicalMarks?}subject doc409 "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,qarray + metainsertion order (:35-47)
GET /subjects/:id (:30)subject doc404 "Subject not found." (:29-33)
PATCH /subjects/:id (:33)any of {code?, name?, shortName?, credits?, maximumMarks?, passingMarks?, status?}subject docpartial $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 & PathBody / ParamsSuccess dataRules & Errors
POST /subject-assignments (:21){teacherId*, subjectId*, classId*, academicYearId*} (all IsMongoId)assignment docno 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

ScreenLoadingStreamingRealtime
All listsAppSkeleton rows ≤ 200 ms, infinite-scroll footer
Detailheader skeleton + section skeletons
Explorerper-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)

ScreencodeUI
Any create/update400 VALIDATION_ERRORper-field errorText from error.details[] (keys = DTO field names); focus first invalid
Any401 UNAUTHENTICATEDsilent refresh → replay once → sessionExpired
Any write403 PERMISSION_DENIEDhidden CTAs + guarded routes (planned) — OQ-1
Year/Grade create409 DUPLICATE_RESOURCEinline AppBanner(error) with server message verbatim
Subject create/update code409"Code already in use" + open existing row
Class/Section/Assignment(server never 409s)client duplicate guard only (OQ-4)
Delete / set-current404 RESOURCE_NOT_FOUNDtreat as already-removed → refresh; snackbar
Any429 RATE_LIMITEDcountdown banner; no auto-retry
Any422 BUSINESS_RULE_VIOLATION(planned) — no service throws it today; map as generic business error
Any5xx INTERNAL_SERVER_ERRORgeneric + requestId; never server internals

9. Pagination & sort (per list)

ListDefault sortServer filter supportq
YearscreatedAt:-1 (academic-year.service.ts:45-47)unused (OQ-7)
GradesdisplayOrder:1 (grade.service.ts:37-39)unused
Sectionsinsertion (section.service.ts:41-48)by-grade/:gradeIdunused
Classesinsertion (class.service.ts:41-53)by-year/:academicYearIdunused
Subjectsinsertion (subject.service.ts:35-47)unused
Assignmentsinsertion (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)

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 }
EventTransition
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 by academicYearId if 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)

  • ReferenceCubit fetches each collection on first touch with ttl 24 h (00-shared/06 §3.3): GET /academic-years, /grades, /sections, /classes, /subjects (page 1, limit 100 — fetch all pages when hasNext, 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_cache tier (00-shared/06 §3.3).
  • Offline: cache serves reads; writes blocked with AppOfflineBanner (00-shared/07 §10).

4. ReferenceScope (year switcher)

  • selectedYearId lives in ReferenceCubit; default = isCurrent:true year from cache, else first year, else null (academic-year.schema.ts:31-32).
  • Every list screen that supports a year param subscribes: ClassListCubit switches GET /classes/by-year/:id vs GET /classes when "All years" selected; assignment rosters pass academicYearId (subject-assignment.controller.ts:24-35).
  • Switching year re-scopes in-flight queries, cancels stale ones (bloc Emit after isClosed guard), keeps pagination state per year in a Map<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)

ScreenCubitEvents → State
Year listYearListCubitLoad, LoadMore, Refresh, SetCurrent(id), Delete(id){initial, loading, loaded(paged, isCurrentId), empty, error, busyId}
Grade listGradeListCubitLoad, LoadMore, Refresh, Delete(id) → same shape
Class listClassListCubitLoad(scope: all|yearId), LoadMore, Refresh, ScopeChanged, Delete(id) → same + per-year page map
Section listSectionListCubitLoad(scope: all|gradeId), LoadMore, Refresh
Subject listSubjectListCubitLoad, LoadMore, Refresh, Delete(id) + local filter(text) (server q unused — OQ-7)
Class detailClassDetailCubitLoad(id){loading, loaded(class, roster, joined), error}
Assignment matrixAssignmentMatrixCubitLoad, Add(subjectId,teacherId), Replace(oldId, subjectId,teacherId), Remove(id)
Roster (teacher)RosterCubitLoad(teacherId, yearId), Refresh
ExplorerExplorerCubitLoad, Expand(nodeId), Collapse(nodeId), ScopeChanged — tree built from cache (15 §4)
All forms*FormCubitSubmit(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

ActionErrorState →
create (any)409duplicate(serverMsg) → inline banner + autofocus first field
create400error(details[]) → per-field
set-current404refresh list; snackbar "Already current / removed"
assignment add400sheet stays, field error
assignment remove404treat-as-removed → refresh roster
delete404treat-as-deleted → refresh
any5xxgeneric + requestId
any (offline)connectivityblocked + AppOfflineBanner

8. Realtime

  • No WS endpoint for academics today; (planned) pushes: AcademicYearChanged, StructureChanged events to tenant room (00-shared/07 §8). Until then: refresh-on-focus + ReferenceChanged local bus only.
  • set-current performed 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 require authenticated; tenant change (multi-tenant user) wipes ReferenceCubit and 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): events academics.* — year_created, set_current, structure_created(type), assignment_added/removed, conflict_seen (00-shared/10 §8).

14 — QA Checklist (Academics Module)

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_RESOURCE message 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/:id with duplicate nameno 409 (update path has no name check, OQ-2) — client pre-checks from cache.
  • PATCH /subjects/:id cannot change theoryMarks/practicalMarks — fields not in DTO (update-subject.dto.ts:4-42); unknown fields → 400 VALIDATION_ERROR (whitelist validation).

2. Functional — set-current & status

  • set-current un-flags ALL other years, flags target, sets status:"active" (academic-year.service.ts:69-83) — verify with 3 years in DB.
  • set-current on an archived year → becomes active (server allows; confirm copy reflects).
  • Exactly one isCurrent:true after any sequence of set-current calls (singleton rule, COLLECTIONS.md:1023).
  • Year status accepts only upcoming|active|archived at the client (enum academic-year.schema.ts:7-11); server accepts any string (IsString) — client must never send unknown values.
  • Grade/section/class status free 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 displayOrder asc, 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/:id returns only that year's classes; GET /sections/by-grade/:id only that grade's sections (server-scoped).
  • sort=-createdAt query param maps to desc (prefix - handling, academic-year.service.ts:45-47); sort=displayOrder on grades works.
  • Pagination: limit clamped 1–100 (400 outside), page ≥ 1; meta totalPages/hasNext/hasPrevious correct at boundaries (page 1 of 1, last page, empty list → totalPages:1 via Math.max(1,…), pagination-query.dto.ts:46).
  • q param 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 isDeleted filter 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.
  • version increments 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/sectionIdserver accepts (no ref checks, class.service.ts:16-18) — client pickers prevent, but a pasted bad ObjectId is still renderable as "Unknown".
  • tenantId never 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.campusId accepts any ObjectId (ref typo Class, class.schema.ts:12-13, OQ-6) — client hides field; roomId free 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, Esc work; focus ring visible.
  • Conflict banners in live region; snackbars announced.
  • Contrast: body 4.5:1; conflict tertiaryContainer 3: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:false stops 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.
  • RosterTable sticky 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 ISO YYYY-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 + requestId shown.

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-current with 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)

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}; emits ReferenceChanged on any write/refresh (13 §5).
  • List cubits mix PaginationCubit (00-shared/06 §3.2); year-scoped variants hold Map<String, PageState> per year.
  • Form cubits: single Submit(dto); map 409 → duplicate state with server message; 400 → field errors from error.details keys (DTO names).
  • AssignmentMatrixCubit: Add guards against already-assigned pairs using the loaded roster (client-only guard, OQ-4); Replace = Remove + Add sequence.
  • ExplorerCubit: builds the tree from ReferenceCubit cache; 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 to grade.academicYearId (floaters shown in all years, grade.schema.ts:9-10); classes grouped under their year via class.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() (submit YYYY-MM-DD only — IsDateString, create-academic-year.dto.ts:9-15); DateTime.fromIso() for display.
  • YearStatusBadge mapping status enum (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 → Hive boxes academics.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; SectionChips overflow.
  • Golden: HierarchyTree, SectionChips, SubjectAssignmentRow, YearStatusBadge, CascadingEntityPicker light/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-class fetched on class detail open only.
  • const constructors; no whole-page rebuild on filter chips (scoped BlocBuilders).
  • Pagination: infinite scroll fetches next page only when hasNext (12 §9).

12. Proposals flagged to the team

  1. RBAC first (OQ-1): today any authenticated user can write structure — ship UI gating + flag backend perms (academics.read/.create/.update/.delete per entity) before student-facing read mode goes live.
  2. When server adds duplicate/conflict validation (OQ-4), remove client-only guards and render server 409/422 messages instead.
  3. When q search lands server-side (OQ-7), swap local filters for query params.
  4. When campus/room modules land (OQ-6), replace hidden campusId field + free roomId text with real pickers.
  5. Coaching extension (docs/IMPLEMENTATION_PLAN.md:314-317,402-429): sections → batches, subjectCategory/classType/batchId — gate behind FeatureFlagsCubit; models gain optional fields, UI unchanged until enabled.
  6. Analytics wiring waits shared AnalyticsService interface (00-shared/10 §8).

01 — Product Overview (Attendance 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/**, and src/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).

ResponsibilitySource
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 onceattendance.service.ts:48-54 (bulkMark)
Read class roster status for a dateattendance.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 eventsattendance.service.ts:37-44, 88-99
Queue events → BullMQ attendance-process workerevent-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

GoalMeasure
Classroom marking is fastSingle tap-cycle status per student; batch covers the whole class in one request (mark-attendance.dto.ts:51-54)
No double recordsUnique index {tenantId, studentId, date} (attendance.schema.ts:59-62) + upsert findOrCreate (attendance.repository.ts:17-37)
Corrections are auditableAttendanceUpdated 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 alertedAttendanceMarkedattendance-process queue → ≥3-absence alert path (attendance.worker.ts:69-80) — currently log-only, dispatch (planned) (OQ-1)
Reports without blocking the APIAsync report-generate queue + report_jobs status polling (reports.controller.ts:14-24)
Multi-tenant isolationEvery 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 in docs/IMPLEMENTATION_PLAN.md:432-468; the live schema has no session field (attendance.schema.ts).
  • POST /attendance/import bulk import endpoint — (planned); blueprint 04-Modules/Attendance.md:30 lists it; only the worker's bulk-import branch 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-sync queue + */15 * * * * cron exist (scheduler.service.ts:71-76, queue.constants.ts:7) but no worker consumes it; biometric.service.ts:14-16 only writes the log. Blueprint RELATIONSHIPS.md:100 intends biometric_logs.processed → attendance.
  • Absentee notifications to parents — (planned); attendance.worker.ts:69-80 only logs "alert recommended". PLAN.md row 5.4 describes the target (AttendanceMarkedin-app + push queues).
  • 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.edit are defined (permissions.constants.ts:29-30) and seeded (role.schema.ts:31), global guards are RateLimitGuard → 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; classId on 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), original classId wins — client must prevent cross-class remarking.
  • Invalid status on POST → 400 VALIDATION_ERROR (IsEnum, mark-attendance.dto.ts:27-28; e2e p1-operations.e2e-spec.ts:222-228).
  • Invalid status on PATCH → not enum-validated (update-attendance.dto.ts:8 is IsString only) — 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).
  • date is sent as YYYY-MM-DD string and stored as Date (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 against student.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) and class_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 AttendanceMarked events.

10. Open questions (module; global ledger in 00-shared/12)

#ItemImpact
OQ-1Absentee 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-2bulk-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-3biometric-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-4Attendance 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-5No 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-6PATCH 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-7Parent/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-8getSummary 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)

TermMeaning
Attendance docOne row: studentId + classId + date + status (+ checkIn/checkOut/source/remarks)
Statuspresent, absent, late, half_day, leave, holiday (attendance.schema.ts:7-14)
Sourcemanual, biometric, import, api (attendance.schema.ts:16-21)
MarkPOST create/upsert of one day's record
Remark / OverwriteSame student+date POST again → fields overwritten in place
BulkPOST /attendance/bulk — sequential marks for many records (attendance.service.ts:48-54)
PunchRaw biometric_logs row (student, device, timestamp) — not yet attendance
RosterStudents of a class (via student.classId) rendered for marking
Summary{total, summary:{status:count}} from GET /attendance/summary
Attendance reportAsync 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)

Five personas that drive the attendance surface. Every claim derives from src/modules/** — roles/permissions from role.schema.ts:8-65 and permissions.constants.ts:29-30,39-43; marking model from attendance.schema.ts and attendance.service.ts; alerting/reporting from attendance.worker.ts and reports.service.ts.


1. Teacher — "the marker"

"I need the whole class done before the bell stops ringing."

AttributeValue
Roleteacher['student.read','attendance.mark','attendance.edit'] (role.schema.ts:31)
Frequency2–8 times/day, per class period, peak at class start
ContextStanding, tablet/phone in hand, 40 students, < 60 s per class
PainDouble-entry, slow grids, losing work to a bad connection
SuccessBulk mark in one tap; late students caught without re-marking everyone
Sourcesmark-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?"

AttributeValue
Roleorg_adminALL_PERMISSIONS incl. attendance.mark/edit, report.generate/read, biometric.* (role.schema.ts:23; permissions.constants.ts:29-30,39-43)
FrequencyDaily glance; weekly/monthly deep-dives; end-of-term
PainNo visibility into unmarked classes; slow aggregations
SuccessSummary counts per status (attendance.service.ts:103-113), monthly report job (reports.service.ts:110-137), device health
SourcesGET /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."

AttributeValue
Roleparent['student.read'] (role.schema.ts:55) — no attendance permission today (OQ-7)
FrequencyDaily at dismissal; on alert; weekly review
PainFinding out about absences days later; no context for a lone "absent"
SuccessAbsent alert the same day; month strip showing pattern (absences/lates cluster?)
SourcesAlert 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."

AttributeValue
Rolestudent[] permissions (role.schema.ts:63) — read-only via student.read surface today (OQ-7)
FrequencyWeekly; before exams (attendance % often gates exams)
PainNo per-status breakdown; "present" vs "late" impact on % unclear
SuccessOwn history: GET /attendance/student/:studentId (END_TO_END_USER_FLOWS.md:324-325,348) + summary derived client-side
Sourcesattendance.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?"

AttributeValue
RoleAdmin-adjacent staff with biometric.log.create/read, biometric.device.manage (permissions.constants.ts:41-43)
FrequencySetup; daily health check; incident response
PainDevices silently failing; punches stored but never becoming attendance
SuccessDevice status (active/inactive/offline, biometric-device.schema.ts:7-11), ingest count today, clear "logs only" vs "derived" pipeline status
SourcesPOST /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)
GapNo 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)

PersonaPrimary screensPermissions needed
TeacherS1 Today's roster, S2 Marking grid, S3 Batch edit, S4 History/calendarattendance.mark, attendance.edit, student.read
Org AdminS4 History, S5 Monthly report, S6 Report detail, S8 Device statusattendance.*, report.*, biometric.*
ParentS7 Student profile (child), alert deep-linkstudent.read (OQ-7)
StudentS7 Student profile (self)student.read (OQ-7)
OperatorS8 Device statusbiometric.device.manage, biometric.log.read

03 — User Journeys (Attendance Module)

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

JourneyScreensKey components
1. Mark todayS1, S2AttendanceGrid, StatusChip, MarkedCountBar
2. Batch updateS2, S3BatchEditSheet, StatusChip, selection model
3. Mark-lateS2StatusChip popover, time field
4. Monthly reportS5, S6CalendarHeatmap, SummaryDonut, status legend
5. Parent alertS7 (+ notifications)AbsenceAlertCard, day deep-link
6. BiometricS8BiometricDeviceTile, ingest trend

04 — Information Architecture (Attendance Module)

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-65 and permissions.constants.ts:29-30,39-43; routes use studylyon:// 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: /attendance stack: 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)

RouteScreenGuard
/attendanceS1 Today's roster (default today)attendance.mark (teacher) or student.read+report perms (admin)
/attendance/class/:classId/date/:dateS2 Marking grid (read/mark)attendance.mark (read: student.read)
/attendance/class/:classId/historyS4 History/calendarattendance.mark / attendance.edit
/attendance/class/:classId/report/:monthS5 Monthly reportreport.generate/report.read (admin)
/attendance/reports/:jobIdS6 Report detailreport.read
/attendance/devicesS8 Biometric device statusbiometric.device.manage / biometric.log.read
/students/:id → tab attendanceS7 Student attendance profilestudent.read (OQ-7)
studylyon://attendance/:dateDeep 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.

FromToWhy
Home dashboard KPI ("Today: 94% present")S2 grid for the classKPI from dashboard.service.ts:54-65
Timetable class cardS2 "Mark attendance" (from docs/user-flows/END_TO_END_USER_FLOWS.md:27)Daily teaching flow
S2 gridStudents module (student profile)view student, check context
S4 heatmap dayS2 grid for that dateday drill-down (read mode)
S5 reportS4/S7investigate an anomaly
Notifications (absent alert, (planned))S7 day viewparent 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)

  1. Snackbar — "Marked absent" / "Saved (3)" / undo.
  2. Bottom sheet — S3 batch edit, StatusChip popover (half-height), date/class pickers.
  3. Dialog — confirm bulk "Mark 34 as absent?" when selection is large (> 10, or leaving grid with unsaved offline queue); destructive confirms only.
  4. Full-screen — S6 report detail; S8 device add/edit (planned).
  5. 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/D cycle status on focused row (see 10_Interaction_Specification.md).

8. Role → surface map

SurfaceTeacherAdminParentStudentOperator
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)

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)

PurposePick 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 taskspick class, switch date (yesterday corrections), start marking, view unmarked count
Dataclasses via GET /api/v1/classes (academics module); existing marks need class+date → S2 loads them
ComponentsAppListTile (class cards + completion ring), AppDatePicker, AppSectionHeader ("Marked today"), AppBadge (unmarked count), AppFAB
Statesskeleton → class list (grouped by grade/section) → empty (no classes assigned)
NotesTeacher'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)

PurposeMark/overlay one status per student for a class+date; the fastest daily capture surface.
EntryS1 pick → push; deep-link /attendance/class/:id/date/:date
Primary taskstap-cycle status, sweep-all-absent, batch apply, mark-late w/ check-in, remark existing day (overwrite semantics), undo
DataGET /attendance/class/:classId?date= (existing docs); roster = students with classId (student.schema.ts:38-39); writes POST /attendance or POST /attendance/bulk
ComponentsAttendanceGrid, StatusChip, MarkedCountBar, StatusFilterChips, BatchEditSheet, UndoBar (see 07)
Statesloading skeleton → grid (rows default-present, existing marks overlaid) → offline (cached roster + queued marks)
Rulesone 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

PurposeApply one status to many selected rows in a single POST /attendance/bulk.
EntryS2: long-press row (multi-select) → sheet; or "Sweep all to absent" quick action
Primary tasksmulti-select rows, choose target status, confirm, see per-record result
DataBulkMarkAttendanceDto {records[]} (mark-attendance.dto.ts:51-54) → sequential marks, one AttendanceMarked per record (attendance.service.ts:48-54)
ComponentsBatchEditSheet (half-height), status chips grid, selection counter, AppButton "Apply to N"
NotesPartial 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

PurposeMonthly 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 tasksmonth navigation, status-filter heatmap, day detail, jump to S2 read-only for a date
DataGET /attendance/class/:classId?date= per day (N requests, OQ-4 unpaginated) or GET /attendance/student/:studentId?startDate&endDate for S7
ComponentsCalendarHeatmap, StatusLegend, DaySummaryCard, AppDatePicker
NotesClass-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)

PurposeClass 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 taskspick class + range, view summary, request generated report, open detail
DataGET /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)
ComponentsSummaryDonut (or AppCharts bar), AppStatTile per status, CalendarHeatmap (rate by week), ReportJobCard (queued/processing/completed/failed — report-job.schema.ts:13-18)
NotesBoth 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

PurposeFull generated attendance_summary result: totals, period, per-status breakdown; drill into students/days.
EntryS5 report card tap; /attendance/reports/:jobId
Primary tasksread result, filter status, drill to S4/S7, re-run for new range
DataGET /reports/:jobId{status, result:{total, summary, classId, period}} (reports.service.ts:131-136)
ComponentsAppInfoRow (period, class), AppCharts bar/donut, StatusLegend, AppEmptyState (no data)
Statesqueued → processing (progress) → completed / failed (error from report-job.schema.ts:38-39)

S7 — Student Attendance Profile (tab)

PurposeOne 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 tasksmonth nav, % present, day drill-down, mark/remark (teacher only)
DataGET /attendance/student/:studentId?startDate&endDate (attendance.service.ts:63-73)
ComponentsCalendarHeatmap, AppStatTile (% present, absent, late), StatusLegend, AbsenceAlertCard
NotesRole-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

PurposeDevice 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 taskslist devices, check last-punch freshness, view today's ingest count, understand derivation status
Datadevices via biometric module repository (no list endpoint today — (planned), OQ-3); logs GET-able via biometric.log.read (permissions.constants.ts:42)
ComponentsBiometricDeviceTile, AppBadge (status), IngestTrendChart (proposed), AppBanner ("punches captured — not yet applied", OQ-3)
NotesIngest 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)

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)

StatusDefault tap targetCycle order (tap)Visual
Present (default)1st tap → Absent; 2nd → Late; 3rd → back to Presentsuccess tint, check icon
Absent2nd tap→ Late → Presenterror tint, block icon
Late3rd tap→ Present → Absenttertiary/warning tint, clock icon
Half dayvia chip popover onlytertiary tint, half-circle
Leavevia chip popover onlyneutral surfaceVariant, vacation icon
Holidayvia chip popover only (bulk "mark holiday")neutral surfaceVariant, sun icon
  • Semantics: a row's status = the doc's status value (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 present would create docs for everyone; the UI must decide policy (see OQ-9 in 01: 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-29findOrCreate assigns new fields in place). The chip flips with Mo.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.assign only 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" labelLarge in batch bar; selected rows tint primaryContainer; deselect by tap again or Esc (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 a surfaceVariant tooltip on first mark of the day (OQ-10: remark keeps original classId, 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: checkIn time (AppTimePicker, default now, Sp 8 label "Check-in"), checkOut time (optional), remarks (AppTextField).
  • CTA "Save" → POST upsert with only changed keys. checkIn sent as ISO date-time (mark-attendance.dto.ts:32-33 accepts IsDateString).
  • Late rows keep the chip at tertiary; the sheet marks which fields are new vs existing ("checkIn currently 09:12").

5. Grid states

StateRender
Initial loadAppSkeleton list rows (40 × row skeleton, shimmer Mo.m-slow)
Loadedgrid; existing marks overlaid from GET /attendance/class/:classId?date=; unmarked = default-present chip (dashed outline to signal "not saved")
Mark in-flightchip shows mini CircularProgressIndicator (16 dp) inside chip; row disabled
Mark savedchip 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)
OfflineAppOfflineBanner top; marks go to local queue (chip shows dashed + queue icon); reads from last-good cache
Empty classAppEmptyState "No students in this class"
403/404AppErrorState 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 + AnimatedSwitcher icon morph.
  • Batch bar slide-up: Mo.m-base; sheet showModalBottomSheet default.
  • Sweep confirm dialog: Mo.m-base scale+fade; destructive tint only when overwriting (not for first-time marks — overwrite is the conflict path, §3).
  • Haptics: saved lightImpact; error mediumImpact; long-press selectionClick (00-shared/08 §3); none on desktop.
  • Reduced motion: m-instant fades 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; Esc exits selection; Tab enters 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 shortcuts P/A/L/H/V/D on focused row, arrows navigate rows.
  • Dark mode: chip tints use dark token variants (errorContainer #8C1D18 etc.); dashed "unsaved" chip outline outlineVariant visible 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 200 data:[docs] → grid reconciles each row by studentId (docs are 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). CalendarHeatmap cells 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 in 13_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 (rounded Math.round-style server formula mirrored: present/total dashboard.service.ts:57-64), absent count, late count, half-day count for the selected month.
  • CalendarHeatmap month; tap day → day detail (status + checkIn/checkOut + remarks + source badge — source from doc attendance.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 (from biometric_logs by deviceId+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 about biometric-sync cron (scheduler.service.ts:71-76) having no worker.
  • Add/edit device: (planned) form (name, deviceId unique per tenant biometric-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)

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)

StatusToken roleIcon (Material Symbols, filled)Label (i18n key)
presentsuccesscheck_circleatt.status.present
absenterrorblockatt.status.absent
latewarning (tertiary)scheduleatt.status.late
half_daywarning (tertiary)right_half_circleatt.status.halfDay
leaveneutral surfaceVariantbeach_accessatt.status.leave
holidayneutral surfaceVariantwb_sunnyatt.status.holiday
unmarkedoutlineVariantradio_button_uncheckedatt.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.builder rows (56 dp, itemExtent fixed — 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-fast color/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), AppTimePicker check-in (default now), check-out optional, AppTextField remarks, 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 plain Container/InkWell; month builds via GridView.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: Semantics summary text "Total 812: 750 present, 34 absent, …" (00-shared/09:60); tabular fallback list when summary empty (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 tone warning.

11. BiometricDeviceTile (S8)

  • Purpose: device row: health + ingest state.
  • Props: device (name, model, location, status from biometric-device.schema.ts:15-31), lastPunchAt, todayCount, onTap (detail).
  • States: status badge (active success / inactive neutral / offline error); "no punches in 24 h" warn chip; pipeline AppBanner per 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 → AppErrorState compact with server error string; completed → open S6.

Shared-component usage map

NeedsShared component
Class picker rowsAppListTile + AppAvatar (initials from name)
Date/month pickersAppDatePicker / AppTimePicker
Empty/error/loadingAppEmptyState, AppErrorState, AppSkeleton
ConfirmationsAppDialog (batch overwrite confirm)
Selection bars, sheetsAppBottomSheet (S3), AppMenu (row context)
Status legend & statsAppBadge, AppStatTile, AppCharts (S5/S6 bars)
KPI entryAppBanner (pipeline warning, conflict)

08 — Form Specifications (Attendance Module)

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-validator rules 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).

FieldTypeRequiredServer ruleClient control
studentIdMongoId string@IsMongoId() (mark-attendance.dto.ts:13-14)hidden (from selected row)
classIdMongoId string@IsMongoId() (mark-attendance.dto.ts:16-17)hidden (from class context)
dateYYYY-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)
statusenum@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)
checkInISO date-time stringoptional@IsOptional() + @IsDateString() (mark-attendance.dto.ts:30-33)AppTimePicker default now; sent as full ISO (new Date(...), attendance.service.ts:32)
checkOutISO date-time stringoptional@IsOptional() + @IsDateString() (mark-attendance.dto.ts:35-38)AppTimePicker
sourcestringoptional@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
remarksstringoptional@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 → records array, 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 by studentId.
  • 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).

FieldTypeServer ruleNotes
statusstring@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
checkInDate@IsOptional() (raw Date, no validator) (update-attendance.dto.ts:12)ISO string → Date
checkOutDate@IsOptional() (update-attendance.dto.ts:16)as above
remarksstring@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 fieldClient target
studentId/classIdhidden fields — treat as 422-class failure (snackbar, refresh roster)
datedate picker error text "Enter a valid date"
statuschip popover error; re-open with current value
checkIn/checkOuttime field error "Enter a valid time"
remarksremarks 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)

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

PatternFrequency driverUI support
Mark-by-exception (assume present)90%+ of classesDefault-present chips (dashed = unsaved); only changed rows send requests
Sweep all absentsurprise absences (sports day, late bus)FAB "Mark all absent" → bulk confirm → UndoBar
Tap-cycle to correctwalk-by marking1/2/3-tap cycle Present→Absent→Late→Present (06 §2)
Filter to "unmarked"end-of-class sweepStatusFilterChips with counts
Mark-late for stragglerslate arrivalspopover: late + checkIn=now (06 §4)
Batch for a subgrouplab sessions, early dismissalslong-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/holiday as appropriate (no delete endpoint exists — OQ-11: no DELETE /attendance/:id in attendance.controller.ts; a "wrong day" fix = set holiday or 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 lightImpact on 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, surfaceVariant 4% alpha) only when > 30 students; name is titleMedium + 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's classId (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)

SituationServer truthUI
Remark same statusno-op upsert (same values re-saved, attendance.repository.ts:25-27)chip flashes, no banner
Remark different status, single rowoverwritechip flips + "was: absent" transient label
Batch containing already-marked rowsoverwrite all in one bulkamber banner "N already marked — will be overwritten"; CTA "Overwrite N" (06 §3)
Offline queue collides with newer online data on flushlast-write-wins per student/date (upsert)flush preview lists collisions; user confirms or drops stale rows
Two devices/teachers raceunique index: one doc; both upserts, last write winsrare; 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/D set status on focused row, Enter opens popover, Esc exits 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

  1. Never block single-row marking with a dialog (exception: overwrite-conflict in batch).
  2. Every write is instantly visible in chip + MarkedCountBar (optimistic + pending indicator, 13_State_Management.md §5).
  3. Every failure is recoverable: snackbar retry; offline queue flush; undo where safe.
  4. Time/date shown in tenant-local time; wire values always YYYY-MM-DD / ISO-8601.
  5. Honest about pipeline gaps (biometric "captured, not applied", alert "planned").

10 — Interaction Specification (Attendance Module)

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)

GestureTargetAction
TapStatusChipCycle status: Present → Absent → Late → Present (06 §2); disabled while in-flight
Taprow (not chip)Open StatusChipPopover (full options + times + remarks)
Long-pressrowEnter multi-select; row becomes checkbox row; batch bar appears (selectionClick haptic)
Long-press + dragrowsRange-select (contiguous) — phone ergonomic equivalent of shift-click
Swipe leftrowQuick-mark absent (with row-confirm haptic; disabled in selection mode) — mirrors global swipe pattern (00-shared/08 §1); keep ≤ 2 swipe actions
Swipe rightrowQuick-mark present (same guard)
TapMarkedCountBar status chipFilter grid to that status (toggle; "All" resets)
Pull downgridRefresh: re-fetch class attendance + roster (bypasses cache, 00-shared/06 §3.3)
Back gesturegrid with selectionExit 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).
  • UndoBar action = one tap, 4 s window, m-base slide-in under batch bar.

3. Keyboard & desktop (S2/S4/S7)

KeyAction
Pset focused row → present
Aset focused row → absent
Lset focused row → late (popover for checkIn)
H / V / Dhalf_day / leave / holiday
Enteropen popover for focused row
Spacetoggle selection of focused row
Shift+clickrange-select in multi-select mode
Escexit selection → close popover → close sheet (priority order)
Ctrl+Kglobal search (unchanged, 00-shared/08 §2)
←/→month navigation in S4
Tabgrid 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)

InteractionToken
Chip status flipMo.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 entryMo.m-base slide-up, 250 ms easeInOutCubic
Sheet (S3/popover)platform sheet default (Mo.m-base)
MarkedCountBar count changeAnimatedSwitcher 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
UndoBarMo.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

EventHaptic
Chip status change appliedlightImpact()
Batch applied (N rows)lightImpact()
Mark failed / error snackbarmediumImpact()
Long-press selection entryselectionClick()
Overwrite-conflict bannermediumImpact() (once per batch, not per row)
Sweep-all-absent confirmheavyImpact() 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-fast scale 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)

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)

StatusM3 roleLight tokenDark tokenIcon (filled)
presentsuccess#16A34A / successContainerdark variant (§1.4)check_circle
absenterror#BA1A1A / errorContainerdark variant (§1.4)block
latewarning#F97316 / warningContainerdark variant (§1.4)schedule
half_daywarningas late (distinct icon)sameright_half_circle
leaveneutralsurfaceVariant fg onSurfaceVariantdark variantbeach_access
holidayneutralsurfaceVariantdark variantwb_sunny
unmarkedoutlineoutline dashed chipoutlineVariantradio_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 patternFlutter widgetsTokens
AttendanceGridTable-like listListView.builder + itemExtent, sticky header via SliverPersistentHeaderrow 56 dp, surface bg, zebra surfaceVariant 4%
StatusChipFilter/Input chipInkWell + Container (rounded r-full/r-md), AnimatedContainer*Container role colors, labelLarge
StatusChipPopoverBottom sheetshowModalBottomSheet + Wrap of chipssurfaceContainerHigh, e-5, r-xl top
BatchEditSheetBottom sheetshowModalBottomSheet, AppButtonas popover; AppBanner for conflict
MarkedCountBarSticky summary stripcustom Material strip + Wrap chipssurfaceContainerLow, e-2, labelMedium
StatusFilterChipsFilter chipsFilterChip with Badge countsselected secondaryContainer, else surfaceVariant
CalendarHeatmapCalendarGridView.builder (7 cols), InkWell cellscells surfaceVariant/status tints, labelMedium
DaySummaryCardCardCard.filled + AppInfoRowssurfaceContainerLow, e-1, r-lg
SummaryDonutChartAppCharts (fl_chart wrapper)02 chart tokens; Semantics summary
AbsenceAlertCardBannerAppBanner warningtertiaryContainer
BiometricDeviceTileList tileAppListTile + AppBadgerow 72 dp (device meta)
UndoBarSnackbar variantcustom bottom bar / SnackBar with actioninverseSurface
ReportJobCardCard + progressAppCard + LinearProgressIndicatore-1, primary progress

3. Typography mapping

UseTokenWidget
Screen title (grid)headlineMediumAppBar title
Grid subtitle (date + count)bodyMedium onSurfaceVariantunder title
Student nametitleMediumrow title
Roll number / admission nomono (tabular)row subtitle bodySmall
Status chip labellabelLargechip text
MarkedCountBar chipslabelMediumcounts
Heatmap day numberlabelMediumcell
Form labelsbodyMediumpopover 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 16 phone / Sp 24 tablet+.

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)

  1. Status colors come only from the mapping in §1 — never bespoke hexes in widgets.
  2. Zebra striping and "was:" transient labels must exist in dark mode too (contrast audit per 00-shared/09 §2).
  3. Tenant branding (organization.branding.color) overrides primary only — status colors must remain semantic and readable under any seed (02 §1:39-40).
  4. 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 primary 2 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)

Exact wire contract for every screen → endpoint. Base /api/v1; envelope per 00-shared/07. All endpoints from attendance.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

AspectContract
Basehttps://api.<domain>/api/v1
HeadersAuthorization: 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}
TenancytenantId from JWT claim — never in body (00-shared/07 §6)
Cachingattendance lists: last-good cache, SWR, TTL 5 min (00-shared/06 §3.3)
Offlinereads cached; writes queued (this module defines an offline queue — 00-shared/06 §3.7)
Retrybackoff 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)

EndpointPOST /attendance (attendance.controller.ts:25-27)
RequestMarkAttendanceDto: {studentId, classId, date('YYYY-MM-DD'), status, checkIn?, checkOut?, source?, remarks?} (mark-attendance.dto.ts:11-49)
Success201 → data = attendance doc (_id, studentId,classId,date,status,source,checkIn?,checkOut?,remarks?,markedBy?,version,createdAt,updatedAt)
Businessidempotent 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
Errors400 VALIDATION_ERROR (invalid status/date/ids — e2e p1-operations.e2e-spec.ts:222-228); 404 upstream refs; 409 race; 429; 5xx
Sourceattendance.service.ts:24-46
Offlinequeue locally; flush with Idempotency-Key

2. Batch mark — S2/S3

EndpointPOST /attendance/bulk (attendance.controller.ts:28-30)
RequestBulkMarkAttendanceDto {records: MarkAttendanceDto[]} (mark-attendance.dto.ts:51-54)
Successdata = array of docs, order preserved; one AttendanceMarked per record (attendance.service.ts:48-54)
Errors400 if any record invalid (sequential loop throws on first bad record — earlier records may already be saved, OQ-2); 409/429/5xx
Sourceattendance.service.ts:48-54; END_TO_END_USER_FLOWS.md:269-270,288
Offlineflush in chunks ≤ 100 records

3. Class-by-date roster — S2/S4

EndpointGET /attendance/class/:classId?date=YYYY-MM-DD (attendance.controller.ts:31-36)
Successdata = array of docs for that class+date (empty if none)
Errors400 missing date; 404 cross-tenant class; 429
Sourceattendance.service.ts:56-61; attendance.repository.ts:39-44; e2e p1-operations.e2e-spec.ts:211-220
Noteused per day; S4 month = 1 call/day (cache; OQ-4)

4. Student history — S7

EndpointGET /attendance/student/:studentId?startDate&endDate (both optional) (attendance.controller.ts:37-43)
Successdata = array of docs, date-range filtered (unfiltered = full history)
Errors404 cross-tenant; 429
Sourceattendance.service.ts:63-73; attendance.repository.ts:46-58; END_TO_END_USER_FLOWS.md:324-325,348,378-379,401
Noteparent/student gating open (OQ-7)

5. Class summary — S5 (live tile)

EndpointGET /attendance/summary?classId&startDate&endDate (all required) (attendance.controller.ts:44-50)
Successdata = {total, summary:{status:count}} (attendance.service.ts:103-113)
Errors400 missing params; 429
Sourceattendance.service.ts:103-113
Notein-memory aggregation (OQ-8)

6. Single record — S2 popover prefill / S7 edit

EndpointGET /attendance/:id (attendance.controller.ts:51-53)
Successdata = doc
Errors404 RESOURCE_NOT_FOUND "Attendance record not found." (attendance.service.ts:77)
Sourceattendance.service.ts:75-79

7. Update / correct — S4/S7

EndpointPATCH /attendance/:id (attendance.controller.ts:54-59)
RequestUpdateAttendanceDto {status?, checkIn?, checkOut?, remarks?} (update-attendance.dto.ts:4-22)
Successdata = updated doc (version +1, base.repository.ts:57-66); emits AttendanceUpdated {attendanceId, studentId, changes:[keys]} (attendance.service.ts:88-99)
Errors404 missing; 400; OQ-6 (no enum validation on status — client restricts); 429
Sourceattendance.service.ts:81-101
Notechanges keys → UI "what changed" toast; audit trail via event map (event-queue-map.ts:18-21)

8. Biometric ingest — S8 (machine→API)

EndpointPOST /biometric/ingest (biometric.controller.ts:14-18)
RequestCreateBiometricLogDto {studentId, deviceId, timestamp, mode?} (create-biometric-log.dto.ts:4-21)
Successdata = raw log doc (biometric-log.schema.ts:7-27) — stored only, not applied to attendance (OQ-3)
Sourcebiometric.service.ts:14-16
Notebiometric-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

EndpointPOST /reports/generate (reports.controller.ts:14-18)
Request{type:'attendance_summary', classId?, startDate?, endDate?} (report-job.schema.ts:8-9)
Successdata = {jobId, status:'queued'} (reports.service.ts:28-44)
PollGET /reports/:jobId → `data = {type, status(queued
Errors404 unknown job; 400 bad type; 429
Sourcereports.service.ts:110-137; report.worker.ts:7
Noteteacher role lacks report.* (role.schema.ts:31) — admin-only today (OQ)

10. Dashboard attendance KPI — home

EndpointGET /dashboard/overview (dashboard.service.ts:18-71)
KPIattendance: {total, summary, rate} — rate = round(present/total × 100) (dashboard.service.ts:57-64)
Tapdeep link to S2 for today's class

11. Scheduler & events (context, not client endpoints)

ItemSource
AttendanceMarked / AttendanceUpdated → queue attendance-process, job process-attendanceevent-queue-map.ts:14-21
attendance-process worker: absence watch (all absent docs ≥ 3 → log), bulk-import branchattendance.worker.ts:16,45-54,57-102
Repeatable attendance-report-daily 07:00 UTC → report-generatescheduler.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 availableattendance.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)

CodeAttendance-specific UX
400 VALIDATION_ERRORfield errors (§08_Form_Specifications.md §6); bulk → pre-validate client-side
401 UNAUTHENTICATEDrefresh once; else session expiry flow (00-shared/06 §5)
403 PERMISSION_DENIEDhide grid actions; batch bar disabled
404 RESOURCE_NOT_FOUNDrefresh roster; "record no longer exists" state
409 DUPLICATE_RESOURCEgrid refresh (upsert race); offline flush collision preview
422 BUSINESS_RULE_VIOLATIONcontext banner (not currently emitted by attendance service — future use)
429 RATE_LIMITEDbackoff, no auto-retry, countdown
5xxgeneric + requestId; retry offered

13 — State Management (Attendance Module)

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

CubitScreen(s)State
RosterCubitS1, S2classes → roster rows + existing marks + live mark status
MarkingCubitS2, S3per-row status transitions, pending/queued flags, selection, batch apply, undo stack
HistoryCubitS4, S7month heatmap (per-day fetch fan-out), day summary, student history
ReportCubitS5, S6summary tile + async report job lifecycle (poll)
DeviceCubitS8devices, 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 from GET /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 supports q/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.changed WS topic (00-shared/06 §3.4) for the current classId+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):

WritePolicyReason
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 banneroverwrite 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-absentoptimistic + UndoBarreversible 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 flagged queued (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) → sequential POST /attendance / POST /attendance/bulk per op, with Idempotency-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 call GET /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-cache att.days:{classId}:{date}).
  • Day cache invalidated when a mark lands for that date (local write) or attendance.changed WS 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 → poll GET /reports/:jobId every 2 s while queued|processing (cap 5 min), then completed|failed terminal 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_logs by deviceId+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.mark gates chip taps & FAB; attendance.edit gates PATCH entry points; report.read gates 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)

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 classId is retained (attendance.repository.ts:29-33); grid shows "already marked in Class A" tooltip.
  • Batch: N records → N docs, order preserved; one AttendanceMarked event per record (attendance.service.ts:48-54).
  • PATCH sends only changed fields: unchanged fields untouched ($set, base.repository.ts:57-66); version increments on every update.
  • PATCH status with 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

  • date sent as YYYY-MM-DD in tenant-local calendar day — client never sends UTC-shifted dates (mark-attendance.dto.ts:21; stored as Date attendance.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-daily 07: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 + itemExtent verified.
  • 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/summary on 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 on completed|failed (report-job.schema.ts:13-18).

5. Offline marking

  • Airplane mode: cached roster renders, AppOfflineBanner shown, 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-Key per 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-import behavior (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 (no report.*, 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/editcurrently 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/ingest with 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, Esc selection 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 via Intl tenant locale; YYYY-MM-DD wire format independent of display format.
  • Long student names + long remarks do not break chip/row layout (RTL ready).

11. Performance budgets (module)

CheckBudget
Grid first frame (cached)< 300 ms (00-shared/10 §1)
Grid scroll 60 rows0 dropped frames
Chip mark round-trip feelpending 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)

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 dateDateTime (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).

  1. ListView.builder + fixed itemExtent (56 dp) — no variable-height rows; chips are one line. itemExtent skips layout measurement → cheapest scroll path.
  2. const constructors for row chrome; only the chip + trailing depend on state.
  3. Split widgets, don't rebuild rows: StatusChip is its own BlocSelector<MarkingCubit, StatusState, StatusChipState> — a status flip rebuilds one chip, not the list. Row body (name/roll/avatar) is const-built and never depends on mark state → excluded from rebuild via Selector.
  4. RepaintBoundary per row — isolates chip animations (pulse, morph) from list painting; prevents repaint storms during rapid taps.
  5. Grid header & MarkedCountBar as SliverPersistentHeader — sticky without re-layout of rows; counts updated via BlocBuilder on a counts-only state slice (store Map<Status,int> precomputed in cubit — never compute in build).
  6. Selection model outside rows: selection lives in the cubit (Set<studentId>); rows render a checkbox overlay only when selection.isNotEmpty — toggling selection mode rebuilds the batch bar once, not 60 rows (gate with Selector on mode flag).
  7. Icons: status icons drawn via Icon with a cached IconData map (no per-frame asset lookups); chips use AnimatedContainer (cheap) not AnimatedSwitcher inside lists (widget-tree churn); morph to AnimatedSwitcher only in the popover.
  8. Date keys: every row keyed by studentId (stable identity for Selectors).
  9. No setState in grid page — all state via cubit; page builds only scaffold chrome (layout, filters, batch bar).
  10. Benchmark hook: golden + WidgetTester timed 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 ConnectivityCubit offline; 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: opId header 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 by studentId+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 days att.days:{classId}:{date} 5 min.
  • WS: subscribe attendance.changed for current classId+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 (mirror attendance.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-228 scenarios against live backend.

8. Localization & analytics

  • .arb keys: att.status.*, att.grid.* (marked/unmarked counts, sweep, overwrite banner, conflict, offline queued, sync done, device statuses). Dates via Intl tenant locale; wire format always YYYY-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)

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/**, and studylyon-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.

ResponsibilitySource
Capture every domain event into audit_logs (wildcard onAny subscription)audit.handler.ts:18-25
Mask secrets (passwordHash, totpSecret, refreshToken, accessToken, token, password) before storageaudit.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/limitaudit.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 bridgews-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 todayqueue.constants.ts:11, system.worker.ts:5-13

2. Business goals

GoalMeasure
Legally-defensible historyEvery business action produces one append-only entry; records never updated/deleted (AUDITING.md:11-12)
Compliance retentionAudit logs retained 7 years minimum, outliving tenant purge (DATA_RETENTION.md:29, 54)
Immutability by constructionNo update/delete paths in code (audit-log.repository.ts:6); PLAN 19.3 (PLAN.md:191)
Tenant isolationEvery entry carries tenantId; queries always scoped (AUDITING.md:67-72, audit.service.ts:45)
Zero secret leakageSensitive fields masked to '***' at write time (audit.service.ts:20)
Non-blocking writesAudit persistence is async in-process (event handler, not request path) (audit.handler.ts:7-9; AUDITING.md:94)
QueryabilityRead 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 correlationId across 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

DependencyRoleSource
EventBus (in-process)wildcard '*' emit → AuditHandler.onAny captureevent-bus.service.ts:11-14, 20-22; audit.handler.ts:18
DomainEvent contracteventType, tenantId, actorId, occurredAt, correlationId, payloaddomain-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 snapshotse.g. users.service.ts:64-76
WsGateway + WsBridgeevery domain event broadcast to tenant:{tenantId} room → realtime append source for the listws-bridge.service.ts:16-22; ws.gateway.ts:50
BullMQ audit-write queuedeclared + routed (subset of events) but no-op worker — entries are persisted by the in-process handler, not the queuequeue.constants.ts:11; event-queue-map.ts:8-39; system.worker.ts:9-12
RBAC audit.read permissionexists 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 guardsRateLimitGuard (api tier 100/min default) → JwtAuthGuardRbacGuardapp.module.ts:129-133; rate-limit.constants.ts:6
Mongo collectionaudit_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_logs doc (dedupe by correlationId/_id on 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 / actorId returns correct scoped results; entityType filter 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/:id does not exist — detail is rendered from the list payload (OQ-4).
  • Pagination shape deviation: service returns {data, total} without meta → the envelope interceptor treats it as a non-paginated payload; client must read data.data[] + data.total (OQ-2, 12_API_Mapping).
  • Empty resource metadata: most emitters do not set entityType/entityId in payloads today → entityType filter matches nothing for most actions (OQ-1).
  • No date-range filter: blueprint promises "query by date range" (AUDITING.md:86) but no from/to params exist — (planned).
  • No search/sort: q and sort params from the shared convention (00-shared/07 §5) are not supported; sort is fixed occurredAt desc (audit-log.repository.ts:23).
  • TTL contradiction: migrate.ts:17-23 creates a 90-day TTL index on occurredAt, contradicting the 7-year retention policy (OQ-6).
  • Audit reads are unauthenticated-role-broad: controller is JwtAuthGuard only — any logged-in user (even student, permissions: []) can read the full tenant audit log (OQ-5).
  • Unbounded limit: no max clamp — limit=10000 is accepted (OQ-7).
  • WS payloads unmasked: WsBridge broadcasts raw payload (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 → Mongo CastError → 400 VALIDATION_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 (actorId only). Resolving names requires a client-side join against GET /users (proposed)actorId filter 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 WsBridge forwards 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)

#ItemImpact
OQ-1Emitters 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-2GET /audit-logs returns data:{data:[], total} (no meta) — deviates from the shared paginated envelope. Fix service to return meta (buildPaginationMeta)?Client parsing
OQ-3Platform/tenantId:null entries and actorType: platform promised in AUDITING.md:70-71 are not implemented (enum is `usersystem
OQ-4No entry-detail endpoint — deep links to a single entry can't refetch. Add GET /audit-logs/:id?Detail screen architecture
OQ-5Controller 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-6migrate.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-7limit unbounded (default 50, audit.controller.ts:20); no clamp to shared max 100 (00-shared/07 §5).List UX, perf
OQ-8WS 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)

TermMeaning
Audit entry / AuditLogone immutable doc in audit_logs (audit-log.schema.ts:13)
actionthe eventType string of the source event, e.g. UserCreated, StudentUpdated (audit.handler.ts:36)
actorId / actorTypewho performed the action; type is always user today (audit.handler.ts:35)
before / aftermasked snapshots; before is set only if the emitter provides it (rarely), after falls back to the full payload (audit.handler.ts:42-43)
correlationIdend-to-end trace id linking request → queue job → audit entry (domain-event.interface.ts:6)
occurredAtauthoritative timestamp (ISO-8601 UTC) of the action — not createdAt (schema uses timestamps: false, audit-log.schema.ts:13)
append-onlyno update/delete surface anywhere in the module (audit-log.repository.ts:6, PLAN 19.3)
Realtime appendWS 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)

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) and rbac/permissions.constants.ts:54 (audit.read). Critical nuance: the server enforces only JwtAuthGuard on GET /audit-logs (audit.controller.ts:9) — audit.read is 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

AttributeValue
PermissionsALL_PERMISSIONS incl. audit.read (role.schema.ts:23)
FrequencyDaily to weekly; desktop/tablet during school operations
Goal"What changed, by whom, when?" — dispute resolution (fees, marks, attendance), staff oversight, verifying destructive actions
Pain pointsCan't find the exact entry (no search/date filter today), actor names not in the response, no export
Core jobOpen audit list → filter by action/actorId → tap an entry → read who/when/what changed
ExpectedRealtime 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)

AttributeValue
PermissionsCustom role; must include audit.read (permissions.constants.ts:54); roles are per-tenant (role.schema.ts:68-85)
FrequencyMonthly/quarterly compliance windows; ad-hoc on disputes
GoalProduce a defensible record for a period: who touched student/fee records, when, with what before/after state
Pain pointsNo date-range filter ((planned)), no export ((planned)), before snapshots mostly absent (OQ-1)
ExpectedFilter + 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))

AttributeValue
AccessNot a first-class identity today — backend has no auditor persona (only `user
GoalVerify no unauthorized change occurred; spot-check entries against school records
ExpectedSame 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)

AttributeValue
Permissionspermissions: [] but JwtAuthGuard sets isPlatformAdmin=true when role present (jwt-auth.guard.ts:54) — bypasses seed
FrequencyOn-demand support / incident response
GoalCross-tenant visibility: did tenant X see tenant Y's data? correlate incidents via correlationId
RestrictionsPlatform 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)
ExpectedRead 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)

AttributeValue
AccessTenant token with audit.read (or dev tooling)
GoalReconstruct a failed flow: follow correlationId (audit-log.schema.ts:51-52) from an error requestId to the audit entry
ExpectedcorrelationId visible on entry detail; copyable for issue reports
Mental model"The audit log is the debug trail."

6. Role → audit-appearance matrix

Valueorg_admincompliance officerauditor (proposed)platform_adminteacher/staff/accountant/parent/student
audit.read permission✓ (role.schema.ts:23)via custom rolevia custom rolen/a (platform flag)✗ (not in any seed role, role.schema.ts:31-42)
Server guard todayJWT only (audit.controller.ts:9) — no perm checksamesamesameJWT only — currently can read (OQ-5)
Client route gatehidden
Filter + listper-tenant (planned)
Entry detail / diff
Export(planned) (AUDITING.md:87)primary needprimary 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)

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: AppSkeleton rows ≤ 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; total refreshes.

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 q param on GET /audit-logs (audit.controller.ts:19-25) — shared convention sort/q unsupported (OQ-2 in 00-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 backend q (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/after maps; 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 joins tenant:{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)

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 in design-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)

RouteScreenAuthPermission (client)Source API
/settings/auditAudit log list + filter barJWTaudit.readGET /audit-logs (audit.controller.ts:17)
/settings/audit/:idAudit entry detail (diff view)JWTaudit.readin-memory entry from list (no :id endpoint, OQ-4)
/settings/audit/exportExport sheet (planned)JWTaudit.readnone yet (AUDITING.md:87)
/settings/access-auditPermission audit preset (RBAC module)JWTaudit.readsame 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:

  1. Snackbar/toast — "New entry", "Copied JSON", "Filters cleared".
  2. Bottom sheet — realtime "New activity" banner expansion, JSON value inspector (nested values), export format picker (planned).
  3. Dialog — none destructive (module is read-only; no delete/confirm flows exist).
  4. Full-screen page — entry detail on phone (JSON tree needs space); list on desktop.
  5. LoaderAppSkeleton rows 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-audit reuses 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 › Entry on 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): / or Ctrl+F focuses filter bar, Esc clears filters, arrows navigate table rows, Enter opens detail, g then i (entry id) copy (proposed).
PatternTargetNote
studylyon://settings/auditAudit listJWT + audit.read
studylyon://settings/audit/:idEntry detailin-memory lookup; fallback: list + match by id on next page load (no :id endpoint, OQ-4)
studylyon://settings/access-auditPermission audit presetRBAC entry

9. Accessibility of IA

  • One headlineMedium per 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)

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)

FieldDetail
PurposeBrowse + filter the tenant's immutable action trail
EntrySettings → Audit log; RBAC "Access audit"; deep link
Exitrow → detail; back → Settings; export (planned)
SourceGET /api/v1/audit-logs?page&limit&action&entityType&actorId (audit.controller.ts:19-25)
CompositionAppBar "Audit log" (+ count total); AuditFilterBar; result list: desktop/tablet = AuditTable (Time, Action, Actor, Resource, Context), phone = AuditEntryRow cards; footer "N of M" + Load more
Statesloading (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)
RealtimeWS appends (journey 5) → prepend + banner "1 new entry"
Pull-to-refreshRefreshIndicatorload(page=1) bypassing cache
Analyticsaudit.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)

FieldDetail
PurposeAND-combined narrowing: action / entity type / actor (audit.controller.ts:26-29)
ControlsAppDropdown Action (free list from data), AppDropdown Entity type, AppDropdown/AppTextField Actor (id or name→id), Clear button, (planned) date range + search
Behaviourany change → page=1 + reload; clear → unfiltered reload
Statesdefault (all empty), active (chips with values + clear), empty-result hint when entityType chosen (see OQ-1)
Analyticsaudit.list.filter with values

3. Audit Entry Detail (/settings/audit/:id)

FieldDetail
PurposeInspect one entry: who/what/when + before/after snapshot
Entryrow tap; deep link (in-memory lookup, OQ-4)
Exitback → list (state preserved)
CompositionHeader: 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
Statesentry found / not found (deep link with no match → empty state + "back to list"), loading n/a (in-memory)
ActionsCopy ID, Copy JSON, Expand nested values, Export (planned)
Analyticsaudit.detail.view, audit.detail.copy_json, audit.detail.expand (proposed)

4. Export Sheet (/settings/audit/export) (planned)

FieldDetail
PurposeCSV/PDF export of current filter scope (blueprint: streamed, itself an audited action — AUDITING.md:87)
Entrylist overflow menu
CompositionFormat radio (CSV/PDF), scope summary (filters + pages), timezone selector, CTA "Export"
Statesdisabled with note — no backend endpoint (gated until backend lands)
Analyticsaudit.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)

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):

TimeActionActorEntityContext
02 Aug 2026, 10:32:14StudentUpdatedA. Kumarstudent_9f1…corr 9a1…
02 Aug 2026, 10:30:02UserLoggedInA. Kumarcorr 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): AppEmptyState icon receipt_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" increments page by 1.
  • Total from response data.total (not meta.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):

ControlComponentBehaviour
ActionAppDropdown searchablevalues = distinct action strings from loaded + cached pages (enum grows with new events — free list)
Entity typeAppDropdown searchablevalues = distinct entityType; helper note when set: "Entity type appears only on entries that carried it (currently rare)" (OQ-1)
ActorAppDropdown searchable OR AppTextField (id)exact string match (audit.controller.ts:24); names (proposed) resolved from users module
ClearTextButtonresets 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 + after maps (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 (mono labelMedium) | badge (changed tertiary, added success secondary, removed error) | 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 expands JsonTreeView (indent 16, mono).
  • Empty map → AppEmptyState compact "No snapshot captured for this action."
  • after fallback: when before absent and after === payload (audit.handler.ts:42-43), header note "Snapshot after action" and render as flat JSON tree — most entries today (emitters send payload without before, 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 Exportdisabled 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-base route transitions; cards El-e-1; sticky table header El-e-2.
  • Snackbar: success < 3 s, error ≥ 4 s (00-shared/03 A).
  • Keyboard: / filter focus, Esc clears 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, Scrollbar visible).
  • Dynamic type 200%: table degrades to stacked cards automatically at any text scale (no fixed-height rows).

07 — Component Library (Audit Module)

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 from audit-log.schema.ts:13-56 and the response contract in 12_API_Mapping.md.


A. Entry representations

AuditEntryRow (phone card)

  • Purpose: one audit entry in the mobile list.
  • Props: entry (AuditEntry model), onTap, highlighted (realtime-append flash).
  • Rendering: AppCard e-0: line 1 AuditActionChip(action) + bodyMedium local time (DateTime.toLocal()); line 2 actor (bodyMedium name if resolved (proposed), else actorId mono) + entityType/entityId mono when present; line 3 correlationId mono bodySmall onSurfaceVariant (truncated, tooltip full).
  • States: default / highlighted (primaryContainer flash m-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 (correlationId mono, copy icon).
  • Behaviour: sticky header (El-e-2); hover row tint m-fast; row tap → detail; Semantics column headers; footer row "Showing X of Y — End of audit trail".
  • Flutter: custom DataTable-free implementation — Table/TableRow inside horizontal SingleChildScrollView, ListView.builder for rows (virtualized); or DataTable2-style if licensed — wrap in AuditTable so it is swappable.

AuditActionChip

  • Purpose: compact action token with category color.
  • Props: action (string), outlined?.
  • Category mapping (proposed): prefixes/contains — create|created|registersuccess; delete|deleted|revoke|logouterror; update|updated|change|edit|enable|disabletertiary; login|loginprimary; else neutral surfaceVariant. Text = raw action (server vocabulary is free-form PascalCase eventType, audit.handler.ts:36 — never translated by mapping).
  • a11y: Semantics(label: action); color never sole signal — text always present.
  • Flutter: AppBadge/Chip with MaterialStateProperty background.

B. Filtering

AuditFilterBar

  • Purpose: AND-combined filters → GET /audit-logs params (audit.controller.ts:22-24).
  • Props: actions[], entityTypes[], actorId, onChanged(filters), onClear, busy.
  • Rendering: Wrap of AppDropdowns (searchable) + actor AppTextField + Clear; active filters mirrored as removable AppChips.
  • Behaviour: any change → page=1 reload (03 §2); Esc clears.
  • 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 before vs after maps (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 (changedtertiary, addedsuccess, removederror) · before (struck through, error tint) → after (success tint). Nested values → "Object (3 keys)" / "Array (5 items)" row → tap expands JsonTreeView. Empty maps → AppEmptyState compact "No snapshot captured".
  • a11y: each row semantics "field changed"; live region on expand.
  • Flutter: Column of custom DiffRow widgets; AnimatedSize expand.

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; SelectableText leaves; "Copy JSON" action (snackbar "Copied"); collapsed arrays/objects show counts; maxDepth guard with overflow marker.
  • a11y: tree exposes Semantics expand buttons + value labels; announce "3 keys".
  • Flutter: recursive Widget builder; 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 backend entityId filter (OQ-1) or client grouping.
  • Flutter: custom CustomPaint rail + list.

E. Status & feedback

  • Reuses AppSkeleton, AppEmptyState, AppErrorState, AppOfflineBanner, AppSnackbar from 00-shared/03 unchanged.
  • AuditNewEntryBanner: inline AppBanner 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)

ComponentFlutter widgets
AuditEntryRowCard + ListTile-style Row
AuditTableTable/custom rows in SingleChildScrollView
AuditActionChipChip + Badge
AuditFilterBarWrap + shared AppDropdown
AuditDiffViewcustom DiffRow column
JsonTreeViewrecursive 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 no AuditQueryDto, hence no class-validator rules; the client self-validates.


1. Audit Filter Form (non-mandatory, all fields optional)

#FieldControlQuery paramServer handlingClient UX / validation
1ActionAppDropdown (searchable, free list)actionoptional string → filter.action (audit.controller.ts:22,27)values = distinct actions from data; free text allowed (enum is open — audit.handler.ts:36)
2Entity typeAppDropdown (searchable)entityTypeoptional string → filter.entityType (audit.controller.ts:23,28)exact match against entityType; helper note: rarely populated by emitters (OQ-1)
3ActorAppDropdown/AppTextFieldactorIdoptional string → filter.actorId (audit.controller.ts:24,29)exact actorId string; name→id resolution (proposed) via users module
4Pageimplicitpagedefault 1, cast Number(page) (audit.controller.ts:20,31)client keeps internally; non-numeric → server CastError → 400 (http-exception.filter.ts:47-55)
5Limitimplicitlimitdefault 50, cast Number(limit) (audit.controller.ts:20,32)client uses 50; unbounded server-side (no max clamp, OQ-7)
6Date range(planned) — no from/to params existblueprint promises date-range querying (AUDITING.md:86) — backend (planned)
7Search q(planned)shared convention q (00-shared/07 §5) not supported by this controller
8Sortnone (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 (AppSearchBar default).
  • "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)

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.3 PLAN.md:191).
  • Timestamps are authoritative: display occurredAt in 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/actorIdaudit.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 in before/after (audit.service.ts:20); UI must not hint that secrets are retrievable.

2. Common mistakes & countermeasures

MistakeRelatedUX countermeasure
Expecting a search box that finds any textno 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 nothingemitters 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 recordbefore 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-onlyno edit paths exist; detail footer explains immutability; support workflow = new action
Misreading timestamps across timezonesUTC storage, local displayUTC in tooltip; column header note on desktop
Clicking rows expecting a server refetchno :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, Enter open, Ctrl+C copy 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 AuditEntryRow cards; filter bar collapses to a AppBottomSheet ("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)

FrustrationTreatment
"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)

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

GestureBehaviourWhere
Tapopen detail, open filter sheet, clear filters, copy id/json, expand JSON noderows, filter bar, detail
Long-pressAppMenu (View entry, Copy ID, Export (planned))list rows
Swipe leftnone destructive (read-only; no delete exists) — no swipe actions
Pull downrefresh → reset page 1 (RefreshIndicator, bypasses cache)list
Pinch/dragnone (no media)
Hardware backdetail → list (filters/scroll kept); list → Settingsall

2. Keyboard / desktop / mouse

InputBehaviour
/focus filter bar (global pattern: Ctrl+K is shell search; / is in-screen filter)
Tabfilter controls → table → footer; visible primary focus ring (00-shared/09 §6)
Arrow up/downmove table row selection
Enteropen selected entry detail
Escclear filters / close filter sheet / collapse JSON
Ctrl/Cmd+Ccopy JSON on detail (with SelectableText)
Ctrl/Cmd+Ffocus filter bar (alias of /); browser-find disabled on web via semantics where possible
Right-clickcontext menu (same as long-press) on rows
Hoverrow tint m-fast; tooltip 500 ms (UTC timestamp, truncated values, full action)

3. Haptics (mobile)

EventHapticRef
Realtime entry appendedselectionClick (subtle; not disruptive)
Copy ID/JSONselectionClick
Filter appliednone
Ruledisabled when system haptics off; none on desktop08 §3

4. Motion language (module extension of 08 §4)

TransitionDuration/curveUsed by
Route push (list→detail)m-base slide (phone); master-detail pane swap m-fast (≥ 840 px)detail
Realtime appendslide-in m-base + primaryContainer flashlist
First-load rowsm-entrance stagger 40 mslist
Filter changecross-fade m-slow (content swap, no pop)list
JSON expand/collapseAnimatedSize m-fastdetail
Table hoverm-fast tintdesktop table

Reduced-motion: fades only, no stagger, no flash.

5. Micro-interactions catalogue

InteractionDetail
Row tap (table)ripple m-fast; detail pane opens m-fast (master-detail)
Load morebottom spinner fade-in m-instant; button label "Loading…"
Realtime bannerslide-in AppBanner info; auto-dismiss 6 s; tap → scroll to top + flash row
Copy ID/JSONSnackbar "Copied" + lightImpact-less (selectionClick)
Clear filterschips remove one-by-one m-fast; "Clear" resets all
Diff expandAnimatedSize; badge colors never sole signal (text + icon)

6. Frustration controls (shared 08 §6 + module)

  • All presses respond < 100 ms (m-fast ripple).
  • 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 _id copy.
  • 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-22 vs audit.handler.ts:18-25) — dedupe by _id.
  • Timestamps always from occurredAt (schema timestamps: falsecreatedAt never used for display, audit-log.schema.ts:13, 42-43).

11 — Design System Mapping (Audit Module)

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)

SurfaceThemeException
List (cards/table)surface, rows surfaceContainerLowsticky table header surfaceContainerHigh El-e-2
Realtime-new row flashprimaryContainer transientback to surface after m-base
Diff addedsecondary (success) tinted rowtext on onSecondary-adjacent surface
Diff removederror tinted row, struck-throughnever color-only (icon + label)
Diff changedtertiary (warning) tinted row
Immutability footnotebodySmall onSurfaceVariant

2. Widget → token

ComponentM3 / FlutterTokensElevRadius
List card (phone row)AppCard flatsurfaceContainerLowe-0r-lg
Desktop tablecustom Tablesurface; header surfaceContainerHigh; divider outlineVariante-0/e-2 header
Filter barWrap + dropdownsfill surfaceVariant, border outline, focus primaryr-md
Active filter chipsAppChips selectedselected secondaryContainerr-sm
AuditActionChipChip/Badgecategory: success/error/tertiary/primaryContainerr-sm
Diff row — changedrow + Badge tertiarytertiary icon changer-sm
Diff row — addedrow + Badge secondarysecondary icon addr-sm
Diff row — removedrow + Badge errorerror icon remove + TextDecoration.lineThroughr-sm
JSON treenested SelectableTextmono; depth indent 16
Empty stateAppEmptyStatedisplaySmall title, bodyMedium onSurfaceVariant
Realtime bannerAppBanner infoprimaryContainere-1r-md
SnackbarsSnackBarinverseSurfacee-5r-md

3. Typography mapping

TokenScreen element
headlineMediumlist/detail screen titles
titleMediumrow action line, section headers
bodyMediumactor/time lines, filter labels
bodySmallcontext line (correlationId), footnotes, immutability note
labelMediumdiff key labels
monoactorId, 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)

PurposeTokenApplied to
Route push detailm-baselist→detail
Realtime appendm-base + flashnew row
First-load staggerm-entrance (40 ms)row entrance
Filter content swapm-slow cross-faderesult area
JSON expandm-fast AnimatedSizedetail
Hover tintm-fasttable 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; JSON mono on surfaceContainerLow.

7. Breakpoint behaviour

ClassLayout
phone < 600filter bar in bottom sheet; AuditEntryRow cards; detail pushed
600–839filter bar inline; cards 2-column (tablet)
840–1199master-detail: list left, detail right pane; table (min-width 720 scrolls)
≥ 1200full-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)

Exact wire contract for the Audit screens → the single read endpoint. Base /api/v1; envelope per 00-shared/07. All endpoints from src/modules/audit/audit.controller.ts; business rules from audit.service.ts + audit.handler.ts + audit-log.repository.ts. Global guards: RateLimitGuardJwtAuthGuardRbacGuard (app.module.ts:129-133). Append-only module: no create/update/delete/export endpoints exist.


0. Module-wide request envelope & client policy

AspectContract
Basehttps://api.<domain>/api/v1
HeadersAuthorization: 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)
TenancytenantId from JWT claim → TenantContextService.requireTenantId() (audit.controller.ts:30, jwt-auth.guard.ts:44-55); never in query/body
Rate limitdefault api tier 100/min (rate-limit.constants.ts:6) — no @RateLimit override on the controller
Permissionserver: JwtAuthGuard only (audit.controller.ts:9); audit.read (permissions.constants.ts:54) unenforced → Phase-5 (planned) (docs/IMPLEMENTATION_PLAN.md:241)
Cacheserver none; client last-good cache 5 min (volatile list, 00-shared/06 §3.3)
Offlinereads from cache + AppOfflineBanner; writes n/a (no writes exist)

Screen: Audit log list (and filter bar)

EndpointGET /audit-logs (audit.controller.ts:17)
Query paramspage (default 1), limit (default 50), action?, entityType?, actorId? (audit.controller.ts:20-24)
FiltersAND-combined: {action, entityType, actorId} → Mongo filter + tenant scope (audit.controller.ts:26-29, audit.service.ts:45-48)
Sortfixed occurredAt desc (audit-log.repository.ts:23); no sort/q support
AuthBearer JWT (valid = allowed today; audit.read recommended gate client-side, OQ-5)
Errors400 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} without meta (audit.service.ts:41-50). The envelope interceptor treats a payload as paginated only when it has both data and meta (response-envelope.interceptor.ts:25-32), so this response is wrapped as a plain object: client reads envelope.data.data[] and envelope.data.total. It does not receive meta:{page,limit,totalItems,totalPages,hasNext,hasPrevious} per the shared paginated contract (00-shared/07 §2). Client computes paging from total + requested page/limit.

Entry document fields (exact, audit-log.schema.ts:13-56 + inherited base.schema.ts:8-34):

FieldTypeNotes
_idstringrow key, dedupe key
tenantIdstringrequired, index (base.schema.ts:10-11)
actorIdstringrequired (audit-log.schema.ts:15-16)
actorTypeuser|system|api_keydefault user; handler always writes user (audit-log.schema.ts:18-19, audit.handler.ts:35)
actionstringrequired = eventType (PascalCase, e.g. UserCreated) (audit-log.schema.ts:21-22, audit.handler.ts:36)
entityType / entityIdstring?rarely populated today (OQ-1) (audit-log.schema.ts:24-28, audit.handler.ts:37-40)
ipAddress / device / browser / userAgentstring?schema fields, never written by current handler (OQ) (audit-log.schema.ts:30-40)
occurredAtISO-8601 UTCrequired; the display timestamp (audit-log.schema.ts:42-43); schema timestamps:false → no createdAt
before / afterobject?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)
correlationIdstring?trace link (audit-log.schema.ts:51-52)
metadataobject?payload.metadata passthrough (audit-log.schema.ts:54-55, audit.handler.ts:44)
isDeleted/version/createdBy/…inheritedalways 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)

SurfaceStatusEvidence
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-onlyaudit-log.repository.ts:6, 15-28; PLAN 19.3 (PLAN.md:191)
Export CSV/PDF(planned) — blueprint onlyAUDITING.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 implementedAUDITING.md:70-71 vs audit-log.schema.ts:7-11

Loading / streaming / realtime

ScreenLoadingStreamingRealtime
audit listAppSkeleton rowsLoad-more pagesWS append (ws-bridge.service.ts:16-22) — topics = eventType strings, joined room tenant:{tenantId} (ws.gateway.ts:50)
entry detailn/a (in-memory)
export(planned)(planned) streamed per blueprint

Client-side error mapping table (module)

ScreencodeUI
list/detail401silent refresh → fail → session-expiry (00-shared/06 §3.6)
list429countdown chip; no auto-retry
list400 (bad page/limit)reset to page 1, snackbar
list5xxAppErrorState generic + requestId
list403 (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)

Per-screen Cubits (Flutter/bloc; proposal, 00-shared/06) for the read-only audit trail. Backed by AuditRepository (dio) calling GET /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 eventCubitRepository call
open screenload()GET /audit-logs?page=1&limit=50 (audit.controller.ts:20-21)
Load moreloadMore()GET /audit-logs?page={page+1}&limit=50
Pull-to-refreshpullToRefresh()GET ?page=1 bypass cache
Filter change / clearchangeFilter(f)GET ?page=1&action=&entityType=&actorId= (only non-empty params, audit.controller.ts:26-29)
Retryretry()re-run last request
WS eventonWsEvent(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 sets notFound.
  • 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-joins tenant:{tenantId} (ws.gateway.ts:50).
  • AuditCubit listens to all eventType topics of the domain-event stream — the same events the handler persists (ws-bridge.service.ts:16-22 vs audit.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; RefreshIndicator bypasses 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-audit uses the same AuditCubit with a preset filter (action in RBAC set) — shared widget + state, different entry route (design-docs/rbac/05 S6).

6. Error states per action

ActionErrorState →
load/loadMore401session-expiry flow
load/loadMore429RATE_LIMITED → countdown, keep items
loadMore5xxsnackbar + keep isLoadingMore=false (retryable)
load5xxerror state → AppErrorState
load400 (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)

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-logs with defaults page=1&limit=50 (audit.controller.ts:20-21).
  • Response parsed per actual shape envelope.data.data[] + envelope.data.total (no meta — OQ-2; response-envelope.interceptor.ts:25-32).
  • Entries sorted occurredAt desc (never asc/unsorted) (audit-log.repository.ts:23).
  • Filters action/entityType/actorId sent 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.
  • entityType filter: 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 :id endpoint, OQ-4).
  • Diff view: changed/added/removed rows computed correctly; before absent → "Snapshot after action" label (audit.handler.ts:42-43); both absent → empty state.
  • Immutability checks: sending PATCH/DELETE to /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 after for a UserCreated-style event never contains passwordHash/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/limit handled: client never sends them; if server 400 arrives → reset page 1 (http-exception.filter.ts:47-55).
  • correlationId present 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 / removed error / changed tertiary) pass contrast in light and dark; never color-only (icon + text) (00-shared/09 §9).
  • Long correlationId/entityId mono strings truncate with tooltip, wrap at 200% text scale (no clipping).
  • Dark parity: sticky header surfaceContainerHigh, JSON on surfaceContainerLow.

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 Intl locale; 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:23 TTL 90 days on audit_logs.occurredAt vs 7-year policy (DATA_RETENTION.md:29) — product/security decision required; QA should re-verify retention after fix.

12. Server-backed edge mirrors

  • total shown matches count when filters applied (server count call, audit.service.ts:48).
  • Entry with metadata renders nested JSON tree (audit-log.schema.ts:54-55).
  • actorType other than user (future system/api_key writes) 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)

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
}
  • query omits empty filter params (audit.controller.ts:26-29); never sends sort/q (unsupported).
  • watchEvents subscribes 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-through error tint → after secondary tint; nested values → JsonTreeView (lazy).
  • after alone (before == null): show all keys as after snapshot with header "Snapshot after action" (audit.handler.ts:42-43).
  • Truncate values > 120 chars with expand; SelectableText for copy.

5. Table layout on desktop (≥ 1200 px)

  • AuditTable: custom Table inside SingleChildScrollView(horizontal) (min-width 720), vertical ListView.builder (virtualized — 00-shared/11 §13).
  • Sticky header via SliverPersistentHeader (or Table with 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× → stacked AuditEntryRow cards instead of table (no fixed-height rows).

6. Timeline UI (proposed)

  • Vertical CustomPaint rail + dots; entries grouped by entity/correlationId from loaded set; needs backend entityId filter to be meaningful (OQ-1) — gated off today.

7. Navigation

  • GoRoute /settings/auditAuditListPage; GoRoute /settings/audit/:idAuditDetailPage (guard: permissionGuard('audit.read'), 00-shared/11 §6).
  • Deep link entry miss → lookup cap (proposed) (OQ-4) → empty state.
  • RBAC /settings/access-audit reuses AuditListPage with 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 aliases success/error/tertiary (11 §1); mono for ids/JSON.

10. Testing

  • Unit: computeDiff permutations; 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; const constructors; lazy JSON rendering (collapsed by default); RepaintBoundary around 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 §1 on mid-range device (deep pages, 10k+ rows).

12. Proposals flagged to the team

  1. Backend: add entityType/entityId/before to emitters + ?entityId= filter (OQ-1) → enables real resource filtering + timeline.
  2. Backend: return meta (shared pagination shape) from AuditService.query (OQ-2).
  3. Backend: add GET /audit-logs/:id, date-range from/to, q, export endpoint (OQ-4, AUDITING.md:86-87).
  4. Backend: enforce audit.read (Phase-5, docs/IMPLEMENTATION_PLAN.md:241); resolve 90-day TTL vs 7-year retention (OQ-6).
  5. Realtime: mask payloads in WsBridge broadcast or filter client-side (OQ-8).

01 — Product Overview (Auth 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/**, and src/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.

ResponsibilitySource
Email + password login → JWT access/refresh pairauth.service.ts:123 login()
Refresh rotation with replay protectionauth.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 emailauth.service.ts:54 register()
Email verification + resendauth.service.ts:208 verifyEmail()
Forgot / reset password (1 h token; revokes all sessions)auth.service.ts:256, 285
TOTP 2FA enable / verify / disableauth.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

GoalMeasure
First log-in in secondsLogin round-trip < 1.5 s p95 (auth-account + RBAC role lookup + token sign)
Zero account-existence leaksidentical 401 message for unknown email vs wrong password (auth.service.ts:125,131,141)
Safe session lifecyclerefresh rotation: old session deleted before new pair issued (auth.service.ts:190-196)
No usable credential leaked at restArgon2id 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 resilienceauth endpoints rate-limited (per-tier), token TTLs 15m/7d (env.ts:29,34)
Enterprise security posturemandatory 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 (register returns 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_admin role; 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

DependencyRoleSource
Users moduleidentity profile (first/last/displayName, email)users/schemas/user.schema.ts
RBAC moduleseeded roles + member role resolution into JWT claimsrbac.service.ts, role.schema.ts:8
Config (env.ts)JWT secrets/ttl, TOTP_ISSUER (StudyLyon), SMTP, rate-limitconfig/env.ts:28-53,76-80
Redisrate-limit sliding window (rl:{tier}:{ip})rate-limit.guard.ts:40; rate-limit.constants.ts
BullMQ emailswelcome + password-reset email deliveryevent-queue-map.ts:7,9, email.worker.ts
BullMQ audit-writeUserLoggedIn → audit log jobevent-queue-map.ts:8
IdempotencyServicejob dedupidempotency.service.ts
Mongo collectionsauth_accounts, user_sessions, api_keysblueprint 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 sets lockedUntil (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 by auth.service.ts today — register() mints tokens immediately and login() never checks emailVerified (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 tenantId in 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 (totp qrCodeUri is an otpauth:// URI, auth.service.ts:330).

10. Open questions (module-level; global ledger in 00-shared/12)

#ItemImpact
OQ-1Server 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-2lockedUntil/failedLoginAttempts are stored+incremented but no threshold ever sets lockedUntil — permanent state? Add lockout-on-N?Lock-screen copy, retry UX
OQ-3Refresh reuse: rotation detected (old token fails re-use) but "revoke entire family" is not implemented — add?Security messaging on devices
OQ-4logout with missing body token throws generic Error (→500) instead of 400Client must never send empty token; error mapping
OQ-5No 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)

TermMeaning
AccessToken15 m JWT, {sub, tenantId, roles, type:'access', jti}
RefreshToken7 d JWT, {sub, tenantId, roles, type:'refresh', jti}; stored hashed
Sessionuser_sessions doc — one per token pair; device/browser/os/ip/location metadata
TOTPTime-based one-time password (default: SHA1, 6 digits, 30 s, issuer StudyLyon)
API keysk-style 64-hex raw value shown once; prefix (first 8 chars) + keyHash stored
Rate tier / slashRedis sliding-window per-IP limit on auth/api/public/admin
Envelope{success,message,data,meta?,timestamp,requestId}
jtiper-token-pair UUID tying access+refresh (auth.service.ts:458,460)

02 — User Personas (Auth Module)

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) and rbac/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)

AttributeValue
FrequencyOne-time; 10–15 min journey
GoalRegister their school + admin account, get tokens, verify email, enable 2FA
Pain pointsTypo'd emails, lost verification emails, rate-limit 429 on retry storms
Core jobPOST /auth/register (firstName,lastName,email,password≥8,tenantId) → tokens in data.accessToken/refreshToken
RestrictionsRate auth 5/min (auth.controller.ts:31); unknown tenantId accepted (created with that id)
Expected behaviourImmediate 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

AttributeDetail
FrequencySeveral times/day, many devices
PermissionsALL_PERMISSIONS (role.schema.ts:23) — org owner
GoalFast secure login, full device visibility, harden account with 2FA, API keys for integrations
Pain pointAccount lockouts, 2FA device loss, "unknown device" logins they didn't make
ExpectedLogin → 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
RestrictionsRevocation 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)

AttributeDetail
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)
Frequency1–3 logins/day phone/tablet
Goal"Just let me in", password reset without losing the day
Pain pointsForgot password over weekends; refresh expiry mid-work → single silent refresh; push→deep link login
ExpectedRemembered device → auto-login (secure storage + refresh); reset flow intuitive step 1→2→3
RestrictionsRegistration 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 noteThese roles never touch sessions/2FA/API keys normally (UI hides the Security section except self-service password).

4. Platform Admin (platform_admin, cross-tenant)

AttributeDetail
Permissionspermissions: [] but JwtAuthGuard sets isPlatformAdmin=true when role present (jwt-auth.guard.ts:54) — bypass seed
FrequencyOn-demand support; diagnostics
GoalResolve tenant issues; impersonation (planned); never logged into spec-touched accounts
RestrictionsMust 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)
ExpectedSession 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)

AttributeDetail
AuthAuthorization: Bearer <apiKey> (raw key); keys never logged
GoalScoped machine access: library sync, fee batch, report pull, transport
PermissionsKey scopes[] free-form strings from CreateApiKeyDto (create-api-key.dto.ts:12); not yet checked by any guard — scoping is metadata today (OQ)
RestrictionsPOST /auth/api-keys (name, scopes?) returns {id,prefix,key}key visible once only (auth.service.ts:414); revoke via DELETE /auth/api-keys/:id
LifecyclelistApiKeys{id,name,prefix,scopes,createdAt,lastUsedAt} (never full key); lastUsedAt updated elsewhere (api-key.repository.ts:35)

6. Visitor who abandons registration

AttributeDetail
FrequencyRegistration abandons
BehaviorMay return via /verify-email token; may retry register → 409 duplicate; may reset password on the pre-verify account

7. Role→auth-appearance matrix

Valueorg_adminteacher/staff/accountant/parent/studentplatform_adminAPI-key consumer
Login screen— (uses key)
Email verify interstitial (target)✓ (on register)via invite flow
2FA onboardingrecommendedoptionaln/a
Sessions screen✓ (self)
API keys screenadmin mayviews 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)

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 loading spinner 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, autofillHints on 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 passwordResetToken cleared (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 arbitrary Error → 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 (dio refresh 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 list returns 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, prefix abc12345…, 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.
EntryBehaviorStatus
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 2FAonly 2FA setup QR display (generated client-side); scanner not neededdisplay-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)

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 by JwtAuthGuard).

2. Route table (module-owned; extends 00-shared/05 §4)

RouteScreenAuthSource API
/loginLoginpublicPOST /auth/login
/login/2fa2FA challengepublic, token-carrying (planned)future challenge
/registerTenant registrationpublicPOST /auth/register
/verify-emailEmail verify (+result)publicPOST /auth/verify-email
/forgot-passwordForgotpublicPOST /auth/forgot-password
/reset-passwordResetpublicPOST /auth/reset-password
/settings/securitySecurity hubJWT
/settings/security/2fa2FA status + setupJWT/auth/2fa/enable,/2fa/verify,/2fa/disable
/settings/security/devicesSessions/devicesJWTGET/DELETE /auth/sessions(/:id)
/settings/security/api-keysAPI key listJWTGET/POST/DELETE /auth/api-keys(/:id)
/lockApp lock screen ((forward-looking))device biometrynone

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:

  1. Snackbar/toast — "Password reset email sent", "Logged out", "Copy", "Key revoked".
  2. Bottom sheet — API-key one-time reveal (needs full attention), session device detail.
  3. 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).
  4. Full-screen page — register (5 fields), login, reset.
  5. Loader — button spinner (submit), never full-screen dual column.

5. Screen relationships

  • Login → (success) → authenticated switch → 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 AuthCubit pivot → /login with reason.

6. Nested nav

  • Tablets/desktop: Security uses master-detail (/settings/security list left pane, details right); phone: push (module routes push like globals).
  • No StatefulShellBranch needed across zones; pre-auth stack is a simple Navigator.

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); Esc clears errors/closes sheets; Ctrl+K search n/a pre-auth.
PatternTargetNote
studylyon://loginLoginwarm start
studylyon://verify-email?token=…Verify emailtoken prefill
studylyon://reset-password?token=…Resettoken prefill
studylyon://settings/securitySecurity hubJWT required

9. Accessibility of IA

  • One headlineMedium per 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)

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)

FieldDetail
PurposeAuthenticate with email/password → token pair
Entrycold app, any deep link, session expiry, "log out" piggyback
Exitsuccess → /home; → /forgot-password; → /register; → /login/2fa (planned)
InputsAppTextField email (autofill username), AppTextField password (obscure, autofill current-password)
Primary CTALogin filled button (fullWidth)
Secondary"Forgot password?" (text), "Register your school" (text)
Statesloading (button), error (inline field/server), rate-limited (429 countdown), offline (banner+native retry), locked (specific copy)
Analyticsauth.login.submit, auth.login.success, auth.login.failure(code)
Keyboardemail → .emailAddress; password → .visiblePassword-ish + onSubmit
Adaptivephone single pane; tablet/desktop centered card ≤ 480 dp with brand mark
a11ylabels linked, error live-region, focus first invalid
Motionm-fast focus; error shake; success → swap to home m-base slide

2. 2FA Challenge Screen (/login/2fa) (planned)

FieldDetail
PurposeSecond factor check after password (server-dependent — blocked OQ-1)
Entryfrom login challenge signal
Exitsuccess → /home; failure → same screen with inline TOTP error
Inputone 6-digit TOTP field (auto-advance)
Primary CTAVerify
Statesloading, invalid code (401 → clear + focus), rate-limited; "remember device" toggle (client prefs only, no server flag)
a11ysingle semantics label "6-digit verification code"; keyboard number
NoteServer returns no challenge today: feature is forward-looking; keep route dormant

3. Register Screen (/register)

FieldDetail
PurposeCreate org admin + tenant in one step (server assigns org_admin)
Entry/login "register", fresh install
Exitsuccess → token stored → /home (+ "verify your email" banner)
InputsfirstName, lastName, email, password (min8), confirm (client-only), tenantId, phone optional
Auto-fillgiven-name, family-name, email, new-password, tel
Primary CTACreate account
Secondary"Back to login"
Statesas login; plus 409 duplicate (email) inline
Deep noteRegistration returns tokens immediately → user is authenticated; duplicate email → ConflictException 409 (auth.service.ts:57)
Contentinformational "what happens next" list (verify email, 2FA prompt)

4. Verify Email (/verify-email) + result

FieldDetail
PurposeConsume verification token from email link/banner
Entrydeep link /login/?token=; auto-fires; banner direct-entry
Exitsuccess → /home (or /login); error → form with "resend"
Inputimplicit token (hidden)
Primary CTAVerify email (auto-submit on prefilled token); Resend email
Statesverifying (spinner), success check, error invalid/expired, resend throttled (5/120s)
Adaptivecentered card; scroll when keyboard
a11ysuccess announced via live region

5. Forgot Password (/forgot-password)

FieldDetail
PurposeRequest one-time reset email (never reveals existence)
InputAppTextField email
Primary CTA"Send reset link"
Statessuccess screen-as-message identical for existing/missing (privacy auth.service.ts:259-282); rate (3/min)
Notedesign should play "email sent" neutrally

6. Reset Password (/reset-password)

FieldDetail
PurposeSet new password with 1 h token; revokes all sessions
Inputpassword (min8), confirm
Primary CTA"Set new password"
Statessuccess → login redirect; token expired/invalid 400 (inline + "request new link")
Security notesession-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)

FieldDetail
PurposeList current user's active sessions
Route/settings/security/devices (list) + sheet for detail
SourceGET /auth/sessions → array of documents (deviceName, browser, operatingSystem, platform, ipAddress, location, expiresAt, lastActivityAt)
WidgetsListTile group (current device labeled "You"), per-item icon inherits platform
Pull-to-refreshRefreshIndicator
Empty"No other devices"
Row actionsAppMenu → "Log out this device" (DELETE /auth/sessions/:id) with confirm dialog
CTAbottom button "Log out all devices" (POST /auth/logout-all)

10. API Keys — List (/settings/security/api-keys)

FieldDetail
SourceGET /auth/api-keys
WidgetsAppCard per key: prefix…, scopes chips, createdAt / lastUsedAt, menu (revoke)
FABFAB "New key" (one per screen)

11. API Keys — Create (bottom sheet)

FieldDetail
Purposetwo-step: form → one-time secret reveal
Formname (required), scopes chips (optional, free-text input chip)
Revealfull raw key + Copy + "I've saved it" (server never re-serves)
SourcePOST /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)

FieldDetail
Status tileTOTP on/off (badge)
Enable flowPOST /auth/2fa/enable → QR + secret, copy; input 6-digit → POST /auth/2fa/verify → set ON (server sets enabled only after this)
Disable flowinput current code → POST /auth/2fa/disable
Statessecret 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 + ScrollView resizeToAvoidBottomInset.

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 loading spinner replaces label, inputs disabled, no double-submit.
  • error inline (401): error text under field(s) errorContainer chip + "Invalid email or password." — server doesn't reveal which; copy matches server auth.service.ts:125.
  • error locked: banner "Account locked. Try again later." (auth.service.ts:135).
  • rate limited 429: inline AppBanner(error) + countdown labelMedium; inputs stay editable but CTA disabled until window; no auto-retry.
  • offline: AppOfflineBanner top; 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

ConcernSpecification
Groups"This device" pinned first (match on stored device fingerprint/local marker) then others
RowAppListTile leading device icon by platform (phone/tablet/desktop), title deviceName, subtitle "browser · OS · location · ip", trailing AppMenu (Log out this session)
Date linebodySmall "Last active 3h ago · expires in 5d" from lastActivityAt/expiresAt (user-session.schema.ts:36,39)
EmptyAppEmptyState icon devices_other, "No other sessions"
LoadingAppSkeleton(list)
ErrorAppErrorState.code UNAUTHENTICATED/INTERNAL
PullAppRefreshIndicator
CTA (bottom, fullWidth)Log out all devicesAppDialog confirm → POST /auth/logout-all
Row actionDELETE /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

ScreenSpec
ListHeader 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
RevokeAppDialog "Delete this key?" → DELETE /auth/api-keys/:id → optimistic removal + Snackbar

10. 2FA Detail Screen

UISpec
Off state (default)AppBanner "Two-factor is off"; TonalButton "Turn on" → POST /auth/2fa/enable → returns {secret, qrCodeUri}
Setup stageQR card (device-render qrCodeUri), secret mono + copy+backup note; "I've saved my secret" CTA
Verify stage6-digit TOTP input; "Enable" → POST /auth/2fa/verify; 401 → red ring + error; success → status ON with green badge; AppSnackbar "2FA enabled"
On statestatus row, Disable (destructive text) → enables TOTP input again → POST /auth/2fa/disable (code required by server auth.service.ts:376)
Safe areaQR 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-base route transitions; AppBar elevation El-1; cards El-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; Enter submits; error fields focus jump.
  • Safe areas: bottom nav-height respected only in authed; pre-auth uses MediaQuery padding.

07 — Component Library (Auth Module)

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 (primaryContainer tile). a11y: Semantics(image, label: "StudyLyon").
  • Flutter: AppAvatar-style CircleAvatar + Text with themed lockup; branding override from organization.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), strengthHint optional (register).
  • State: base / focus / error / disabled / loading(suffix spinner) / reveal.
  • Animation: Mo-fast icon swap; error slide-in.
  • a11y: label + Semantics(error), error announced; reveal button Semantics(toggled:obscure).
  • Flutter: TextFormField + suffix IconButton (visibility/visibility_off) with Tooltip "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 with authenticator.check tolerant drift).
  • Props: length (default 6), onCompleted(code), error, loading, autofocus, readOnly.
  • Behaviors: 6 boxes, auto-advance, overwrite, paste full code, Backspace to previous; digit-only filter.
  • States: empty, partial, valid, error (error ring) — validates against authenticator.check(code, secret) only server-side; client validates RegExp(r'^\d{6,8}$') locally.
  • A11y: single grouped field label "One-time code", Semantics groups boxes; talkback reads "digit 2 of 6".
  • Flutter: custom Row of 6 TextField 48dp focused with movement arrows / single TextField with LetterSpacing 8px + lengthFormatter (recommend single hidden-real-width input).
  • Motion: Mo-fast slide between boxes; error AnimatedSwitcher shake.

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 platform map, title, subtitle ("browser · OS · ip · location"), meta lastActivityAt/expiresAt, AppMenu (Log out).
  • Current device marker: primaryContainer tile + "You" chip.
  • data-* fields: exactly the user_session doc fields (user-session.schema.ts:15-29).
  • Flutter: ListTile inside AppCard (elevation e-0), icon map fn.

AppActiveBadge (AppBadge) — enumerates session count for Security Hub.

D. API-key artifacts

AppApiKeyCard

FieldSpec
PurposeOne non-revoked key summary
Propsprefix, name, scopes[], createdAt, lastUsedAt?, onRevoke
Renderingname titleMedium, prefix**** mono + copy-prefix icon, chips of scopes, footer ${lastUsed ?? 'never used'} · created ${createdAt} in bodySmall
a11ycard button + menu; Semantics(label: "API key <name>")
TokenssurfaceContainerLow, R-md, El-e-1

AppSecretReveal (one-time key reveal)

FieldSpec
PurposeDisplay raw key after create; warning that it's shown once (auth.service.ts:414).
Propssecret, copyable, dismissible
Renderingmono body, wrapped card with copy; warning AppBanner(warning) "Copy now – you won't see this again."
CopyClipboard.setData + Snackbar("Copied"), lightImpact.
a11ySemantics(liveRegion) on copy result; scrolled-into view.

E. Status & feedback

  • AppSecurityBanner — security anniversary: "Change your password" / "Verify email" prompts.
  • Reuses AppBanner, AppSnackbar, AppErrorState, AppEmptyState, AppOfflineBanner from 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 (findByUserId returns full arrays, user-session.repository.ts:19, api-key.repository.ts:20).

G. Flutter widget map (module)

ComponentFlutter widgets
AppAuthHeaderWrap + Text in Padding
AppPasswordFieldTextFormField + IconButton
AppTotpInputcustom FormField<int?> over a single TextField 6-digit mask
AppSessionCardCard + ListTile + MenuAnchor
AppApiKeyCardCard + Row + ActionChips
AppSecretRevealCard + SelectableText + Clipboard
AppTotpChip`Badge``
AppSecurityBannerMaterialBanner

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)

Every form field-by-field for the Auth module. Validation mirror class-validator decorators exactly from src/modules/auth/dto/*.ts; messages follow the VALIDATION_ERROR (400) envelope with per-field details. Passwords havehed server side (Argon2id). Client validates inline then submits; server 400 shadows client.


1. Login Form — POST /auth/login (login.dto.ts)

#FieldLabelHint / PlaceholderKeyboardAutofillValidation (server)Client UX
1emailEmailjohn.doe@springfield.eduemailAddressusername@IsEmail() requiredtrim+lower; error: "Enter a valid email"; TextInputAction.next
2passwordPasswordtype your passwordtext(default)current-password@IsString() + @MinLength(6) requiredobscure+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).

#FieldTypeAutofillDecoratorsNotes/UX
3firstNametextgiven-name@IsString() reqfirst on form
4lastNametextfamily-name@IsString() req
5emailemailusername@IsEmail() reqnormalized lowercase by server (user.schema.ts:28)
6phonetel (optional)tel@IsOptional() @IsString()country-code prefix; no server format check
7passwordpasswordnew-password@IsString() @MinLength(8)min 8; show strength meter (client-only, not server policy)
8passwordConfirmpassword (client-only)must equal password; no server field — local only
9tenantIdtext@IsString() reqschool 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 in register(); navigating to /home uses 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)

#FieldTypeKeyboardAutofillValidation
10emailemailusername@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)

#FieldTypeAutofillValidation
11tokenhidden (from deep-link ?token=)@IsString() required
12passwordpasswordnew-password@IsString() @MinLength(8)
13passwordConfirmpasswordnew-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)

FieldTypeRequiredValidationUX
nametext@IsString()max length 48 (client), autofocus
scopeschipsno@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: .next sequence, last .done; Enter submits.
  • Password managers: autofillHints set on every auth field (09 §10).
  • Error copy: from message of envelope only for business 4xx; codes for the rest.

Client-side error priority

  1. 400 VALIDATION → field.
  2. 401 UNAUTHENTICATED → form message.
  3. 409 DUPLICATE_RESOURCE → inline.
  4. 422 BUSINESS_RULE_VIOLATION → banner.
  5. 429 RATE_LIMITED → countdown.
  6. 5xx → AppErrorState.

09 — User Behaviour (Auth Module)

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

MistakeRelatedUX countermeasure
Typing wrong/` casecaps-lock for passwordLoginshow 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 idRegisterplaceholder + example; hint autofilled from invite
Losing / reusing the verify-email token (email link fired twice / link lost)verifyserver returns "already verified" → benign; resend link (rate-limited)
Resend obnoxious spamresend-verificationrate 5/120s, cap UX "Wait 1 min"
Tapping "log out all" then leaving the devicelogout-allconfirm dialog; after action show "logged out everywhere" result
Losing 2FA device & can't get in2farecovery codes (planned) + clear warning at setup: "save these recovery codes"
Revoking a device you're currently on → locked out of that sessionsessionsidentify current row (badge "This device"); warn on revoke of current

3. Power & new users

  • Power user: global shortcuts Ctrl+L focus 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 autofillHints everywhere; 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>; Flutter TextInputAction.

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:
    • register mid-way → email capture for invite context; UX: one-tap "resume".
    • reset token not clicked within 1 h → expired banner, one tap re-send.
    • 2fa enable after obtaining secret → incomplete → show "Finish 2FA setup" reminder row on Security hub (client memoized).

8. Frustration points → UX treatments (summary table)

FrustrationTreatment
"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 logincountdown chip + "no auto-retry"
Device access after revokeconfirm; state icon change
Unknown key prefixno "secret" leaks in UI; only prefix shown

9. Cross-device & conflict

  • Concurrent login on two devices → two sessions (user_sessions per 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)

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

GestureBehaviourWhere
Tapsubmit, reveal password, copy, open/close sheetsall inputs
Tap on CTAsubmitting-aware disabled re-taplogin/register/reset/enable
Long-pressAppMenu on session/api-key rowssessions, api keys
Double-tapnone (no media in auth)
Swipe leftno destructive swipe (revoke is destructive) — keep confirm
Pull downrefresh sessions/api-keyslists only
Pinch/dragnone (no media)
Hardware backpre-auth: pop to previous auth step; never back into shell; on root → exitall
Edge swipedrawer (authed shell) — n/a pre-auth

2. Keyboard / desktop / mouse

InputBehaviour
Tabemail → password → CTA → secondary links; focus ring primary
Entersubmits the visible form (login/register/reset/forgot/2fa)
Escclears inline error / closes sheet / cancels dialog → returns focus to trigger
Ctrl/Cmd+Kn/a pre-auth (focus first field instead) recommended
Ctrl/Cmd+Entersubmit from form (same as Enter)
Arrowsnavigate chips in API-key scopes (input)
Right-clickcontext menu (same as long-press: session revoke, key revoke)
Hovertooltip after 500 ms; CTA elevation hinted
Focusvisible ring on every control; logical order

3. Haptics (mobile)

EventHapticRef
Wrong TOTPmediumImpact
Login successlightImpact
Copy keyselectionClick
Enable 2FAlightImpact
Destroy confirm (revoke)heavyImpact
Ruledisabled when system haptics off; none on desktop08 §3

4. Motion language (module extension of 08 §4)

TransitionDuration/curveUsed by
Route push (pre-auth)m-base slidelogin→forgot→register
Success→homem-base fade-upany unauthenticated success
Sheet (key reveal)m-base bottom slideapi-key
Totp box errorshake AnimatedSwitcher (reduced-motion = none)2fa
Badge change (2FA on)scale pop m-fastsecurity hub

Reduced-motion: only fades + instant; no shake.

5. Micro-interactions catalogue (auth fl)

InteractionDetail
Submit pressedbutton loading-state swap in m-base, double-tap locked
Key copiedsnackbar "Copied" + toast, tooltip
Password revealicon toggles, focus stays, no screen shift
TOTPauto-advance AnimatedContainer; paste-tolerant; on 401: red + shake + focus box
Device revokedrow fade-out m-base, list re-animates m-entrance stagger
Refresh sessionsRefreshIndicator completes with lightImpact

6. Frustration controls (shared 08 §6 + module)

  • All presses respond < 100 ms with m-fast ripple.
  • 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).
  • studylyon://verify-email?token= / reset-password?token= → fills hidden field; auto-submit.
  • Manual code-paste → AppTotpInput accepts paste.
  • Copy key → share sheet on tablet/desktop (AppMenu?) — desktop: Ctrl+C on SelectableText.

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)

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)

ScreensThemeException
Pre-auth (login/register/verify/reset)surface, primaryemphasis: brand primaryContainer; input fill surfaceVariant
Security screens (authed)standard shelldestructive = error
2FA QR cardsurfaceContainerHighdark: QR contrast ensured (inverseSurface)

2. Widget → token

ComponentM3 / FlutterTokensElevRadius
Login cardCardsurfaceContainerLow, outlineVariant bordere-1r-lg
Populated text fieldTextFormFieldfill surfaceVariant, border outline, focus primaryr-md
CTA Log inFilledButtonprimary/onPrimaryr-md
Secondary CTATextButtonprimary, underline on hover
Register CTAFilledButtonprimaryr-md
Forgot/resetOutlinedButtonoutline/onSurfacer-md
TOTP boxesFilledButton-like boxesbox surfaceVariant, focus primaryContainerr-md
Error chipAppBanner/inlineerrorContainer/onErrorContainerr-sm
Offline bannerAppOfflineBannertertiaryContainere-1
Sessions cardAppCardsurfaceContainerLowe-1r-lg
Current-device highlightAppCard tonalprimaryContainere-1r-lg
API key ringAppCardsurfaceContainerLow, secret monor-lg
Secret revealAppCardtertiaryContainer bg (warning-kind)e-2r-lg
Dialogs/sheetsAppDialog/AppBottomSheetsurfaceContainerHighe-4/e-5r-lg/r-xl
FAB (api-keys)FloatingActionButtonprimaryContainere-3r-full
SnackbarSnackBarinverseSurfacee-5r-md

3. Typography mapping

TokenScreen element
headlineMediumscreen titles (Welcome/Security hub headings)
titleLargecreate-key card, dialog titles
titleMediumsession rows, key rows, section headers
bodyMediumlabels, helper lines
bodySmall"created/last used", disclaimer, hints
labelLargebuttons, tabs (Security tabs)
monotoken/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)

PurposeTokenApplied to
Form submit→resultm-baseswap to success/error
Route transitionsm-basestack push/pop
List exit on revokem-baseremoval
Totp errorm-fast+shake
Badge popm-fast2FA on/API-key count
Hero QRm-slow2FA 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

ClassPre-authSecurity
phone <600single columnlists full width, detail pushed
600–839centered cardmaster-detail
≥1200centered + max width 480master-detail breaks, hover rings

12 — API Mapping (Auth Module)

Exact wire contract for every screen → endpoint. Base /api/v1; envelope per 00-shared/07. All endpoints from src/modules/auth/auth.controller.ts; business rules from auth.service.ts. Bearer JWT; global guards: RateLimitGuardJwtAuthGuardRbacGuard (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

AspectContract
Basehttps://api.<domain>/api/v1
HeadersAuthorization: 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}
TenancytenantId from JWT claim; never in body (except register.tenantId bootstrap)
Cachingnone (auth state) — client caches nothing server-sided
Offlinepre-auth writes blocked; reads (sessions) cached last-good
Retrybackoff on 5xx/network; no auto-retry on 429

Screen: Login / 2FA-challenge

EndpointPOST /auth/login
Request{email, password} (login.dto.ts)
Success200 data:{accessToken,refreshToken}
Client flowvalidate → header set → persist token pair (secure storage) → navigate home
Auth@Public, rate auth 10/min (auth.controller.ts:38-44)
Errors400 validation; 401 UNAUTHENTICATED (“Invalid email or password.”); 429 RATE_LIMITED; 5xx
2FA(planned) client challenge — no server support today (OQ-1)
Offlineblocked, 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

ScreenLoadingStreamingRealtime
loginbutton
registerbutton
sessionsAppSkeleton rows(planned) WS session-change event
api-keysAppSkeleton
2FAspinner

Client-side error mapping table (module)

ScreencodeUI
login401「 credentials incorrect」
login429countdown
verify-email400“Verify link expired/invalid” + resend
reset400expired-token banner
sessions404 (revoke id)treat as removed
api-key create400/409inline; 401→refresh; 5xx generic+requestId
any401 (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)

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 in flutter_secure_storage (00-shared/11 §11); on boot reads → refresh → if refresh succeeds ⇒ authenticated; else unauthenticated.
  • sessionExpired reason: tokenReuse, sessionRevoked, passwordChanged, expired (mapped from error path).
  • Single-flight refresh in dio interceptor (00-shared/11 §5): concurrent 401s coalesce into one POST /auth/refresh; swap pair; replay queue.

2. Per-screen Cubits

ScreenCubitEvents → State
LoginLoginCubitLogin(email,pw) → {initial, loading, success(model), error(code)}
2FA (planned)TotpCubitSubmit(code) → {idle, verifying, success, error}
RegisterRegisterCubitRegister(form) → {idle, submitting, success(tokens→AuthCubit), duplicate, error}
VerifyVerifyEmailCubitVerify(token) → {verifying, success, resendable(error/cooldown)}
ForgotForgotCubitSubmit(email) → {idle, sent, rateLimited(countdown)}
ResetResetCubitSubmit(token,pw) → {idle, submitting, success, expired)}
SessionsSessionsCubitLoad, Refresh, Revoke(id) → {initial, loading, loaded([session]), empty, error, revoking}
ApiKeysApiKeysCubitLoad, LoadMore(n/a), Create(form), Revoke(id) → parallel {list, createFlow{sheetState, revealing(secret), done}, error}
2faDetailTfaDetailCubitLoadStatus, Enable, Verify(code), Disable(code) → {off, setupReady,on, verifying, disabled}
LockLockCubitAuthorize(biometric) — device-level, (forward-looking)
  • All prefixed AuthHub* not needed — AuthCubit + per-screen cubits.
  • Loading & caching: LoadState from 00-shared/06 §3.1; lists refresh via RefreshIndicator bypassing 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 eventCubit methodRepository call
LoginScreen submitlogin()authRepo.login(email,pw)
VerifyEmailScreenverify(token)authRepo.verifyEmail
Resend pressresend()authRepo.resendVerification
Forgot Screensend()authRepo.forgot
Reset Screensubmit()authRepo.reset
Sessions loadload()authRepo.sessions
Sessions row menurevoke(id)authRepo.revokeSession
LogoutAll dialog confirmlogoutAll()authRepo.logoutAll
ApiKeys listload()authRepo.apiKeys
FAB createcreate(name,scopes)authRepo.createApiKey() → secret snapshots to sheet revealing
key menurevoke(id)authRepo.revokeApiKey
2fa cardenable()authRepo.enable2fa
2fa codeverify(code)authRepo.verify2fa
Turn offrequestDisable(code)authRepo.disable2fa

5. Caching & refresh

  • AuthCubit cache = token pair + user meta (needed everywhere; local_cache).
  • Sessions/api-keys: no persistence cache → always fetch on screen open; RefreshIndicator re-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 any Session-related push press navigate or re-fetch with snackbar 「 signed in elsewhere 」 on next app use.

7. Error states per action

ActionErrorState →
login401error(reason AuthError.invalidCredentials) → inline form
login429rateLimited(remaining) → countdown, disable CTA
refresh401SessionExpired(reason) → router /login
revoke404treat-as-removed; error snackbar
create-key429/4xxcreateError(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

  • ConnectivityCubit gates auth submission (offline) → banner + disable CTA.
  • FeatureFlagsCubit not used (auth module runs pre-feature-gate).
  • On logoutAuthCubit clears secure storage then navigates /login (sessionExpired iff server originally revoked).

14 — QA Checklist (Auth Module)

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_RESOURCE inline.
  • 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/refresh succeed; 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 surfaceContainerHigh on 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 tenantId still 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)

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_storage for accessToken/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 GoRoute for /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 in Scaffold(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: keys auth.access, auth.refresh, auth.user, auth.expiry.
  • No direct SharedPreferences (that would be a leak vector).

10. Testing

  • Unit: AuthCubit state transition matrix; LoginCubit form validation; mapper DTO→model.
  • Widget: login states (idle/loading/error/rate/offline); TotpInput paste/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; const constructors; QR image lazy-build off-screen; clipboard micro-delay.

12. Proposals flagged to the team

  1. When server adds first-login-verify gate + 2FA challenge, enable LoginCubit challenge state (OQ-1).
  2. When lockout/family-revoke/recovery-codes land (IMPLEMENTATION_PLAN.md), add the associated screens.
  3. Analytics wiring waits share 99 (AnalyticsService interface).

01 — Product Overview (Biometric 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.

ResponsibilityStatusSource
Ingest raw punch logs (immutable)✅ realbiometric.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🚧 plannedblueprint 04-Modules/Biometric.md:13
Poll / sync device punches (biometric-sync queue)🚧 queue + scheduler exist; worker stubqueue.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 eventblueprint EVENTS.md:18
Template enrollment / verify / match🚧 planned — no schema, no endpointblueprint Biometric.md:13-19
Verify device health🚧 plannedblueprint Biometric.md:19

2. Business goals

GoalMeasure
Attendance marking latency < 1 s (PRD)PRODUCT_REQUIREMENTS_DOCUMENT.md:136
Zero human double-entry at the gateraw punch → derived attendance, no clerk retyping
Audit-grade attendance evidencelogs are insert-only, never updated (biometric-log.schema.ts:7, blueprint COLLECTIONS.md:1866)
Immutable forensic recordraw payload preserved for replay (rawData, biometric-log.schema.ts:21-22)
Operational resiliencesync failures retried via BullMQ; exhausted → DLQ (blueprint Biometric.md:58)
1-year retention boundbiometric_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

DependencyRoleSource
Attendance moduleconsumes BiometricImported → derived records; AttendanceSource.BIOMETRICblueprint Biometric.md:47; attendance.schema.ts:18
Students modulestudentId ref on every log (ref: 'Student')biometric-log.schema.ts:9-10
BullMQ biometric-syncperiodic device polling (*/15 * * * *)queue.constants.ts:7; scheduler.service.ts:70-76
BullMQ attendance-processattendance derivation workerevent-queue-map.ts:14-17; attendance.worker.ts:16
Integrations module (planned)encrypted device credentials/configblueprint Biometric.md:48,59
Audit module (planned)immutable log-write trailblueprint Biometric.md:49
RBACbiometric.log.create, biometric.log.read, biometric.device.managepermissions.constants.ts:41-43
Mongo collectionsbiometric_logs, biometric_devicesbiometric.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; processed flag planned, COLLECTIONS.md:1888).
  • Unknown deviceId: ingest accepts any string — no device registry check today (create-biometric-log.dto.ts:9-11).
  • Unknown studentId: @IsMongoId only checks shape, not existence (create-biometric-log.dto.ts:5-7); orphan log persists.
  • Tenancy: tenantId is taken from the JWT claim by BaseRepository — but ingest never runs tenant logic of its own (see OQ-1).
  • rawData gap: schema stores rawData (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 pushes studentId).
  • as any: biometric.service.ts:15 casts the DTO with as any — strict-TS no-explicit-any is currently off in 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-sync worker 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.BIOMETRIC exists but nothing sets it (attendance.schema.ts:18); no code path links a biometric log to a mark() 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 declare biometric.log.read and no sync perm (permissions.constants.ts:41-43).

10. Open questions (module-level; global ledger in 00-shared/12)

#ItemImpact
OQ-1ingest 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-2rawData is in the schema but not the DTO — ingest the forensic payload now or drop the field?Forensic/replay promise (Biometric.md:56)
OQ-3Template storage format (hash? encrypted blob? vendor raw?) — no schema exists.Entire enrollment/verify UX + privacy posture
OQ-4Who writes the biometric-sync worker? Jobs currently stall.Sync status UX is speculative until it lands
OQ-5POST /biometric/ingest has no permission metadata — device webhooks may need a machine-token path, not user JWTs.Auth model for devices
OQ-6Duplicate-punch / dedupe policy: derive at ingest or in attendance?Attendance correctness

11. Glossary (this module)

TermMeaning
Punchone raw device event (biometric_logs row)
Logimmutable raw event: {studentId, deviceId, timestamp, mode?, rawData?}
Devicebiometric_devices doc: {name, deviceId, model?, status, location?, config?}; status ∈ active/inactive/offline (biometric-device.schema.ts:7-11)
Syncperiodic device poll via biometric-sync queue (*/15 * * * *)
Templatethe enrolled biometric sample — planned, format undecided (OQ-3)
Attendance derivationraw logs → attendance docs via BiometricImported event (planned)

02 — User Personas (Biometric Module)

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"

AspectDetail
RoleSenior administrator; owns daily operations
ContextDesktop + phone; busy between 7:00-9:00 AM (gate rush)
GoalsGate works with zero babysitting; disputes resolved before parent asks
PainsDevice offline for 3 days unnoticed; manual re-entry when sync breaks
Permissionsbiometric.device.manage, biometric.log.read, biometric.log.create (all three)
SurfacesDevice list, sync status, log audit (planned screens)
Key metric"Did every student who entered get counted?"
BehaviourChecks device fleet once daily; flags offline devices immediately

2. Office Clerk / Device Operator — "Arjun"

AspectDetail
RoleFront-office staff who physically operates the gate device
ContextStands next to the hardware; runs the admin console on a tablet/kiosk
GoalsEnroll new students fast; re-enroll on scanner rejection; confirm a student "actually scanned"
PainsFingerprint rejected on dry hands; duplicate enrollment mistakes; unclear enrollment status
Permissionsbiometric.device.manage, biometric.log.read
SurfacesEnrollment wizard (planned), device detail, punch timeline, log search
BehaviourHigh-frequency short tasks; one-handed; hates multi-step dialogs

3. IT / Technical Admin — "Dev"

AspectDetail
RoleManages device fleet, network, vendor SDK integration
ContextDesktop; SSH-level comfort; reads logs
GoalsDiagnose sync failures; push config to devices; watch the biometric-sync queue
PainsNo worker behind biometric-sync today (jobs stall — queue.constants.ts:7, no @Processor); vendor SDK availability gates everything (FEATURE_ROADMAP.md:56)
Permissionsbiometric.device.manage, biometric.log.read
SurfacesDevice config, sync job history, Bull-Board queue view
BehaviourBatch operations; export-friendly; tolerates dense technical UI

4. Teacher — "Meera"

AspectDetail
RoleTakes class attendance; checks "who's in" at start
ContextPhone, 2 minutes before period
GoalsSee biometric check-ins merged into the class attendance view without re-counting
PainsIf derivation isn't wired, she still marks manually and trusts nothing
Permissionsattendance.mark (+ read via attendance endpoints); no biometric permission today
SurfacesClass attendance list (attendance/class/:classId); biometric contribution is passive
BehaviourOnly cares about the derived result, not the device

5. Platform Admin (cross-tenant) — "Sam"

AspectDetail
RoleStudyLyon operator; sees fleet across tenants
ContextDesktop; support tickets
GoalsFleet health; spot tenants with dead devices; vendor-SDK risk tracking
PainsNo cross-tenant biometric view exists; roadmap gates the whole feature
Surfaces(planned) platform dashboard tile
BehaviourWeekly health review

6. Parent — passive consumer

AspectDetail
RoleWants "reached school" confidence
ContextPush notification (forward-looking)
GoalsCheck-in reflected in daily attendance; late-punch alerts (proposed)
PainsDevice misread → child marked absent → angry call to office
SurfacesAttendance record only (attendance/student/:studentId)
BehaviourReads, never administers

Permission → persona matrix (real today)

PermissionSourceRiyaArjunDevMeeraSam
biometric.log.createpermissions.constants.ts:41
biometric.log.readpermissions.constants.ts:42
biometric.device.managepermissions.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)

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)

  1. Admin opens Biometric → Devices (GET /biometric/devices, blueprint Biometric.md:25).
  2. Taps Register device → form (POST /biometric/devices, blueprint :26): name, deviceId, model, location, status.
  3. Server enforces unique (tenantId, deviceId) (biometric-device.schema.ts:36).
  4. Device appears active (DeviceStatus.ACTIVE default, biometric-device.schema.ts:24-25).
  5. 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)

  1. Clerk opens Devices → [device] → Enroll.
  2. Selects student (search by name/roll).
  3. Device captures template → clerk confirms (≤ 3 taps — persona Arjun).
  4. Server stores template + employeeCode mapping (blueprint COLLECTIONS.md:1878-1879).
  5. 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)

  1. Parent questions "absent" → clerk opens Biometric → Logs.
  2. Filters by student + date (GET /biometric/logs, blueprint Biometric.md:30).
  3. Opens punch → sees timestamp, deviceId, mode, raw payload (biometric-log.schema.ts:12-22).
  4. Confirms the punch exists → edits attendance via PATCH /attendance/:id (attendance.controller.ts:54-59) with source preserved; 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)

  1. Device status flips offline (enum biometric-device.schema.ts:7-11; health-check planned, blueprint Biometric.md:19).
  2. Sync job retry: 4, exponential 2000 ms (blueprint RETRIES.md:38); exhausted → DLQ (blueprint Biometric.md:58).
  3. Admin sees failed job in Bull-Board; resolves network; taps Sync now (POST /biometric/devices/:id/sync, blueprint Biometric.md:28).
  4. 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

JourneyScreen (05)Status
1 Device setupDevice List / Device Register(planned)
2 EnrollmentEnrollment(planned)
3 Daily gateSync Status + Attendance-Verification Check-iningest real, rest (planned)
4 DisputeBiometric Logsread (planned)
5 Offline deviceDevice Detail / Sync Status(planned)

04 — Information Architecture (Biometric Module)

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):

RouteScreen
/biometricHub (sync status)
/biometric/devicesDevice List
/biometric/devices/newDevice Register
/biometric/devices/:idDevice Detail
/biometric/devices/:id/enrollEnrollment (per-device)
/biometric/logsLog List
/biometric/logs?student=:idLog List pre-filtered by student
/biometric/verifyAttendance-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=:id shared by the clerk for dispute resolution (journey 4 in 03).
  • Master-detail (tablet/desktop): Devices and Logs use two-pane list/detail (00-shared/05).

3. Content model (per screen)

ScreenPrimary entityFields shownSource
Device ListBiometricDevicename, deviceId, model, status, locationbiometric-device.schema.ts:15-31
Device RegisterBiometricDevice (create)name*, deviceId*, model?, location?, config?schema + blueprint Biometric.md:26
Sync Statusqueue/job statelast sync, pending, failed (→ DLQ)queue.constants.ts:7, blueprint RETRIES.md:38
Log ListBiometricLogstudent, device, timestamp, modebiometric-log.schema.ts:9-19
Log DetailBiometricLog + rawrawData (forensic)biometric-log.schema.ts:21-22
Enrollmentstudent + device + templatestudent ref, deviceId, employeeCode, template statusblueprint COLLECTIONS.md:1878-1879
Verify Check-instudent + punchespunch list, matched attendance statusattendance.schema.ts:16-21

4. IA decisions

  1. 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.
  2. Enrollment lives under Device, not under Student — enrollment is a device-scoped operation (template lives on hardware), even though the subject is a student.
  3. 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.
  4. 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, gated report.read/platform role.

05 — Screen Inventory (Biometric Module)

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)

FieldDetail
PurposeFleet health at a glance; the ops manager's landing (persona Riya)
EntrySettings → Biometric
Exit→ device list, → logs, → verify check-in
Widgetsstatus summary card (devices: active/offline counts), last-sync row, failed-sync banner, queue depth chip (Bull-Board link for Dev)
Statesloading skeleton; empty ("No devices — register one"); error (queue down); offline
Sourceplanned: GET /biometric/devices + queue health; no real API today
Analyticsbiometric.hub.open, biometric.hub.sync_status (proposed)
Permissionany biometric.* (hub visibility)

2. Device List (/biometric/devices) (planned)

FieldDetail
PurposeManage all devices: register, open, disable
Entryhub → Devices
Exit/biometric/devices/new; → /biometric/devices/:id
Sourceplanned GET /biometric/devices; unique key (tenantId, deviceId) (biometric-device.schema.ts:36)
RowAppListTile: name, subtitle model · location, trailing status badge (DeviceStatus: active/inactive/offline, biometric-device.schema.ts:7-11)
Pull-to-refreshRefreshIndicator
EmptyAppEmptyState "No devices registered"
CTAAppFAB "Register device"
Row menuAppMenu → Detail / Disable (confirm)
Permissionbiometric.device.manage (permissions.constants.ts:43)

3. Device Register (/biometric/devices/new) (planned)

FieldDetail
PurposeAdd a gate device to the tenant fleet
Formname*, deviceId*, model?, location?, config? (schema fields biometric-device.schema.ts:15-31)
Sourceplanned POST /biometric/devices (blueprint Biometric.md:26)
Primary CTA"Register device"
Statesloading; 409 duplicate deviceId inline; success → detail
Permissionbiometric.device.manage

4. Device Detail (/biometric/devices/:id) (planned — sync action part of it)`

FieldDetail
PurposeOne device: config, status, sync, enrollment entry
Headername + status badge + model/location; config collapsible (raw, biometric-device.schema.ts:30-31)
ActionsSync now (POST /biometric/devices/:id/sync, blueprint Biometric.md:28), Enroll/enroll, Disable (confirm), Edit (PATCH /biometric/devices/:id, blueprint :27)
Sync statesidle → running (spinner) → success (punch count) / failed (retry; 4× exp backoff, blueprint RETRIES.md:38 → DLQ)
Punch previewlast N logs for this device (biometric-log.schema.ts:26 index tenantId+deviceId+timestamp)
Permissionbiometric.device.manage; punch preview needs biometric.log.read

5. Enrollment (/biometric/devices/:id/enroll) (planned)

FieldDetail
PurposeCapture a student's template on a device (clerk, persona Arjun)
Stepsselect student → device capture → confirm → success chip
Sourceblueprint: employeeCode mapping (COLLECTIONS.md:1878-1879); no endpoint/schema/template format today (OQ-3)
Statesper-step loading; capture-failed (re-try, dry-hands hint); duplicate-enrollment warning
Exitsuccess → device detail ("Enrolled: {name}")
Permissionbiometric.device.manage
Blockersvendor 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)

FieldDetail
PurposeEvidence trail: search punches by student/device/date; dispute resolution (journey 4)
Sourceplanned GET /biometric/logs (blueprint Biometric.md:30); log fields biometric-log.schema.ts:9-22
Filtersstudent (deep-link ?student=:id), device, date range, mode
RowAppListTile: student name, deviceId · mode, timestamp
EmptyAppEmptyState "No punches found"
Row tap→ Log Detail sheet
Permissionbiometric.log.read (permissions.constants.ts:42)

7. Biometric Log Detail (bottom sheet) (planned)

FieldDetail
PurposeFull immutable record incl. forensic payload
ContentstudentId, 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)
Permissionbiometric.log.read

8. Attendance-Verification Check-in (/biometric/verify) (planned)

FieldDetail
PurposeProve a student checked in (dispute) or preview today's biometric-fed marks
Inputstudent search → date
Contentpunch timeline (logs) + derived attendance status per day; flags AttendanceSource.BIOMETRIC rows (attendance.schema.ts:18)
Statesno punches (evidence absent), matched (punch → attendance present), mismatched (punch without derived record — derivation gap)
Exit→ attendance record edit (PATCH /attendance/:id)
Permissionbiometric.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)

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 → AppEmptyState icon sensors, "No devices registered", CTA inline.
  • error → AppErrorState codes UNAUTHENTICATED (401 → session), INTERNAL (5xx + requestId).
  • row offline → trailing badge offline (error color) + subtle red-tinted tile.
  • disable → AppDialog confirm (destructive) → row removed on server 200 (no optimistic — irreversible operation, per 00-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)" (blueprint RETRIES.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

ScreenBackend todayClient 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)

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

ConcernSpec
PurposeShow DeviceStatus (biometric-device.schema.ts:7-11)
Statesactive → primaryContainer "Active"; inactive → surfaceVariant "Inactive"; offline → errorContainer "Offline"
Baseshared AppBadge + AppChips-style; fixed width 72 dp to prevent row jitter
Semanticstext "Device active/inactive/offline"; offline additionally announced "device offline"
Dark/lighttoken-only; no custom colors
Motion200 ms color transition on state swap

2. SyncStatusTile

ConcernSpec
PurposeOne device/job sync summary row (hub card + device detail)
Contenticon (check/error/hourglass), "Last sync {relative}" (from lastActivityAt-style field — planned), punch count, optional "Sync now" TextButton
Statesidle / 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)
Slotcompact for list rows; expanded for hub card
Semanticssingle node; state text in label

3. PunchRow

ConcernSpec
PurposeOne biometric_logs row in list/preview (biometric-log.schema.ts:9-19)
Contentstudent avatar+name, deviceId · mode, timestamp (relative + absolute), processed dot (planned field processed, COLLECTIONS.md:1888)
Statesunprocessed (grey dot) / processed (green dot) / selected (hover/pressed)
BaseAppListTile; tap → detail sheet
Semantics"punch, {student}, {time}, processed"

4. PunchTimeline

ConcernSpec
PurposeChronological evidence view (verify check-in screen)
Contentvertical timeline: dot + line, per punch PunchRow-lite (time, device, mode); day header grouping
Empty"No punches" compact state
SemanticsSemantics grouping per day; each punch a node

5. EnrollStepBar

ConcernSpec
Purpose3-step wizard indicator (Enrollment screen)
Steps1 Student → 2 Capture → 3 Confirm
Statescurrent (filled), done (check), future (disabled); step bar announced; only current/next tappable
Motionprogress fill 200 ms; step swap Mo.m-fast

6. StatusSummaryCard (hub)

ConcernSpec
Purpose"3 of 4 devices active" fleet summary
Contentheadline count + chip row (Active/Inactive/Offline counts — from planned GET /biometric/devices + DeviceStatus)
Stateshealthy (check icon), degraded (any offline/inactive → warning), empty ("no devices")
BaseAppCard; icon color by state

7. QueueHealthChip (hub, Dev persona)

ConcernSpec
Purposebiometric-sync queue depth + failures
Content"waiting · failed" counts; failed > 0 → error banner link to Bull-Board
BaseAppChips; live-region on refresh
Notemeaningless until worker exists (OQ-4) — render "queue unavailable" gracefully

8. ConfigJsonViewer (device detail, Dev persona)

ConcernSpec
PurposeRead/edit config object (biometric-device.schema.ts:30-31)
Contentcollapsible monospace JSON; edit mode with validate+save; never shows device secrets
BaseAppCard + disclosure; destructive save confirm
Noteblueprint 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)

StatusToken
activeprimaryContainer / onPrimaryContainer
inactivesurfaceVariant / onSurfaceVariant
offlineerrorContainer / onErrorContainer
processed (log)tertiaryContainer
unprocessed (log)surfaceVariant

08 — Form Specifications (Biometric Module)

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.

#FieldTypeRequiredValidation (server)Notes/UX
1studentIdtext (MongoId)@IsMongoId() (create-biometric-log.dto.ts:5-7)shape-only check; no existence check (edge, 01 §8)
2deviceIdtext@IsString() (:9-11)no registry check — any string accepted today
3timestampdatetime@IsDateString() (:13-15)device clock time; keep original punch time for derivation correctness (blueprint COLLECTIONS.md:1883)
4modetext@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.

#FieldTypeRequiredValidation (mirror)Notes/UX
1nametext@IsString()autofocus; max 48 chars (client)
2deviceIdtext@IsString()monospace hint "printed on device"; unique per tenant (:36)
3modeltext@IsOptional() @IsString()vendor model string
4locationtext@IsOptional() @IsString()"Main gate", "Library"
5statusselect– (default active)enum DeviceStatus (:7-11,24-25)segmented Active/Inactive/Offline
6configJSON@IsOptional() objectadvanced 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 fieldTypeMeaning
employeeCodestringvendor/user code — maps punch → studentId/userId via config
templateblob/hash (format TBD)storage format undecided — hash? encrypted blob? vendor raw? (OQ-3)
studentId / userIdMongoIdresolved 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)

FieldTypeNotes
studentsearch chipdeep-link ?student=:id
devicedropdownfrom GET /biometric/devices
date rangedate picker presetsToday / This week / Custom
modechipsmulti-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: .next sequence, .done submits; register form 6 fields single column.
  • Error copy: from message of envelope for business 4xx (00-shared/07); codes for the rest.

Client-side error priority

  1. 400 VALIDATION → field.
  2. 401 UNAUTHENTICATED → session-expiry overlay (global).
  3. 403 FORBIDDEN → permission screen ("You don't have device access").
  4. 409 DUPLICATE_RESOURCE → inline (deviceId duplicate).
  5. 429 RATE_LIMITED → countdown (ingest is high-volume — device path should use a machine token, not user rate tiers — OQ-5).
  6. 5xx → AppErrorState.

09 — User Behaviour (Biometric 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, blueprint COLLECTIONS.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 existing attendance endpoints (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 (blueprint DATA_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.OFFLINE exists (biometric-device.schema.ts:10) but nothing sets it today (no health-check implementation, blueprint Biometric.md:19) — flag in QA (14 §7).

7. Behaviour invariants (module)

  1. Every verdict is backed by at least one visible raw punch — no "trust us" UI.
  2. Every destructive action (disable device, overwrite enrollment) has a confirm dialog.
  3. No screen pretends the pipeline is real-time; sync copy always references the 15-min cadence.
  4. Biometric data never appears on student/parent-facing surfaces (privacy, 14 §8).

10 — Interaction Specification (Biometric Module)

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: AppRefreshIndicator re-fetches; Mo.m-fast settle; refresh bypasses cache (00-shared/06 §3.1).
  • During refresh the previous content stays visible (no skeleton flash).

2. Sync trigger (device detail + hub)

TriggerBehavior
"Sync now"button → spinner, disabled; row pulse; result snackbar "Sync complete · N punches" / "Sync failed"
Retrysame button after failure; copy "will retry (4×, exponential 2000 ms)" (blueprint RETRIES.md:38)
Dead-letterfailure 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-base horizontal slide between steps; progress fill 200 ms.

5. Row actions (device list / log list)

ActionInteraction
Opentap → detail (master-detail pane on ≥ 840 px)
MenuAppMenu: Detail / Disable (devices); Open / (logs)
DisableAppDialog confirm → 200 → row removed + AppSnackbar; no undo (irreversible)
Filter chipstoggle → list refetch; active chip announced

6. Verify check-in verdicts

  • Verdict card transitions: AnimatedSwitcher fade 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

CaseUI
Empty devicesAppEmptyState + inline CTA "Register device"
Empty logs (filter)"No punches for this filter" + "Clear filters" button
403permission 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 expiryglobal overlay (00-shared/07)

8. Keyboard & focus (forms)

  • .next sequence; .done submits 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 rawData reader: Semantics.label "raw device payload, read-only".

10. Interaction invariants

  1. No interaction implies real-time; sync copy references 15-min cadence.
  2. No destructive action without confirm; no undo promises.
  3. Every status change is server-confirmed before UI updates.
  4. Reduced-motion honored app-wide (00-shared/08 §2).

11 — Design System Mapping (Biometric Module)

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 groupWhere
Color: primary, primaryContaineractive states, CTA, "matched" verdict
Color: error, errorContaineroffline, failed sync, mismatch, destructive confirms
Color: tertiaryContainerprocessed-punch indicator
Color: surfaceVariantinactive devices, unprocessed punch
Type: headlineMedium / bodyMedium / bodySmallscreen titles, rows, metadata (06 layouts)
Spacing: Sp-16 phone / Sp-24 tabletscreen padding
Radius R-mdcards, fields, badges
Elevation El-1cards; El-2 bottom sheet
Motion Mo.m-fast / Mo.m-basemicro-interactions, screen transitions

2. Component mapping (all shared, no forks — 07 for module widgets)

Shared componentModule use
AppCardstatus summary, device header, config viewer
AppListTiledevice rows, punch rows
AppButton / AppFABCTAs, "Register device" FAB
AppMenurow actions
AppDialogdisable/overwrite-enrollment confirms
AppSnackbarsync result, register success
AppBannerfailed sync, mismatch verdict
AppSkeleton / AppEmptyState / AppErrorStateloading / empty / error
AppOfflineBannerconnectivity
AppRefreshIndicatorlist refresh
AppBadge / AppChipsstatus badge, filter chips, device status
AppBottomSheetlog detail
AppAvatarstudent/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)

StateColorIcon
active deviceprimaryContainersensors
inactive devicesurfaceVariantsensors_off
offline deviceerrorContainercloud_off
sync runningprimaryspinner
sync failederrorContainererror_outline
punch processedtertiaryContainercheck_circle
punch unprocessedsurfaceVariantcircle
verdict matchedprimaryContainerverified
verdict mismatcherrorContainerwarning_amber

5. Dark/light parity

  • All mappings token-based → automatic dark parity; QA pass required on badge contrast (errorContainer on dark) — 14 §9.
  • Monospace rawData uses bodySmall + monospace family on both themes.

6. Typography rules

  • Timestamps always bodySmall relative + absolute (locale-aware, 00-shared/02 §2).
  • Never uppercase device names or punch data.

12 — API Mapping (Biometric Module)

Exact wire contract for every screen → endpoint. Base /api/v1; envelope per 00-shared/07. Real endpoints from src/modules/biometric/biometric.controller.ts; planned endpoints from studylyon-blueprint/04-Modules/Biometric.md:24-31. Global guards: RateLimitGuardJwtAuthGuardRbacGuard (app.module.ts:129-131). RbacGuard needs @Permissions metadata — the ingest endpoint has none (OQ-5).


0. Module-wide request envelope & client policy

AspectContract
Basehttps://api.<domain>/api/v1
HeadersAuthorization: Bearer <accessToken>; x-request-id; JSON
response{success,message,data,meta?,timestamp,requestId}
error{success,message,error:{code,details?},timestamp,requestId}
TenancytenantId from JWT claim (BaseRepository scoping); never in body
Offlineall writes blocked; reads cached last-good
Retrybackoff on 5xx/network; no auto-retry on 429

1. Ingest punch — REAL today

EndpointPOST /biometric/ingest
Sourcebiometric.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
Success201 (created BiometricLogDocument) — biometric.service.ts:14-16 persists via logRepo.create
AuthJWT (controller-local guard + global chain); no @Permissions metadata — any authenticated user (OQ-5)
Errors400 validation; 401 unauthenticated; 429 rate; 5xx
Client roledevice webhook path; clerk manual entry (proposed) mirrors it (08 §5)
NoterawData 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

EndpointMethodSourceScreen
/biometric/devicesGET (list)blueprint Biometric.md:25Device List
/biometric/devicesPOST (create)blueprint :26Device Register
/biometric/devices/:idPATCH (update)blueprint :27Device Detail / Edit
/biometric/devices/:id/syncPOST (trigger sync)blueprint :28Device Detail — Sync now
/biometric/logsGET (list, filters planned)blueprint :30Log 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)

HopMechanismSource
Punch polledbiometric-sync queue, */15 * * * *queue.constants.ts:7; scheduler.service.ts:70-76
Workermissing — no @Processor('biometric-sync') anywhere (grep src/**)OQ-4
Event emittedBiometricImported → queue attendance-processblueprint EVENTS.md:18
Workerattendance.worker.ts:16 @Processor('attendance-process') (real)derives/processes attendance
Target modelAttendanceSource.BIOMETRIC = 'biometric'attendance.schema.ts:18
Existing triggerAttendanceMarked event → attendance-processevent-queue-map.ts:14-17

4. Attendance endpoints the biometric screens reuse (real)

EndpointSourceUsed by
GET /attendance/student/:studentId?startDate&endDateattendance.controller.ts:37-43Verify Check-in, Log Detail hop
PATCH /attendance/:idattendance.controller.ts:54-59dispute correction
GET /attendance/class/:classId?dateattendance.controller.ts:31-35teacher view (passive)

5. Permissions matrix

PermissionConstantEndpoint(s)permissions.constants.ts
biometric.log.createrealPOST /biometric/ingest (metadata missing — OQ-5):41
biometric.log.readrealplanned GET /biometric/logs:42
biometric.device.managerealplanned device CRUD + sync:43
biometric.sync / biometric.readblueprint names — not in constantsblueprint Biometric.md:66-68

6. Loading / streaming / realtime

ScreenLoadingStreamingRealtime
hubskeleton cards(planned) WS sync-event push
devicesskeleton list
device detailskeleton
logsskeleton list(planned) WS log push
verifyspinner

7. Client-side error mapping

ScreencodeUI
ingest400per-field (device path: log payload, no UI)
ingest429countdown; machine path → OQ-5
devices409"A device with this ID already exists"
devices403permission screen
sync5xx/networkretry banner "will retry (4× exp) — DLQ after" (RETRIES.md:38)
any401silent 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)

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

ScreenCubitEvents → State
Hub/Sync StatusBiometricHubCubitLoad, Refresh, SyncNow → {initial, loading, loaded(devices, queue), degraded, error}
Device ListDeviceListCubitLoad, Refresh, Disable(id) → {initial, loading, loaded([device]), empty, error, disabling}
Device RegisterDeviceRegisterCubitRegister(form) → {idle, submitting, success(device), duplicate, error}
Device DetailDeviceDetailCubitLoad(id), Sync(id), Update(id, patch) → {initial, loading, loaded(device, lastPunches), syncing, syncResult, error}
EnrollmentEnrollCubitSelectStudent, Capture, Confirm → {selecting, capturing, captureFailed, confirming, enrolled, duplicate}
Log ListLogListCubitLoad, Refresh, ApplyFilters(f), ClearFilters → {initial, loading, loaded([punch]), empty, error}
Verify Check-inVerifyCheckinCubitLoad(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 eventCubit methodRepository call
Hub refreshload()biometricRepo.devices() + queueHealth() (Bull-Board)
Hub Sync nowsyncNow(id)biometricRepo.syncDevice(id)
Devices refreshload()biometricRepo.devices()
Row disabledisable(id)biometricRepo.updateDevice(id, {status:'inactive'})
Register submitregister(form)biometricRepo.createDevice(form)
Detail syncsync(id)biometricRepo.syncDevice(id)
Detail punchesloadPunches(id)biometricRepo.logs(device: id, page: 1)
Enroll confirmconfirm()biometricRepo.enroll(deviceId, studentId) (planned)
Logs filterapplyFilters(f)biometricRepo.logs(filters)
Verifyverify(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), RefreshIndicator re-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

ActionErrorState →
sync5xx/networksyncResult.failed(retries) → banner "will retry (4× exp)"
syncDLQsyncResult.dlq → Bull-Board link
disable409/403snackbar; row unchanged
register409duplicate → inline deviceId banner
verify404 studentnoPunch verdict is data, not error; 404 student → inline
any401global 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

  • ConnectivityCubit gates sync/enroll (offline → banner, no silent failure).
  • AuthCubit session 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)

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/ingest with valid body → 201, returned doc has studentId/deviceId/timestamp/mode + tenantId from JWT (biometric.controller.ts:14-18, biometric.service.ts:14-16).
  • 400 on bad studentId (non-MongoId) / missing deviceId / non-ISO timestamp (create-biometric-log.dto.ts:4-20).
  • mode optional; omitted OK; extra body field rawData rejected 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 (BaseRepository scoping).
  • 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-sync repeatable 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 (blueprint Biometric.md:58).
  • POST /biometric/devices/:id/sync triggers immediate run (planned).

3. Functional — device CRUD (planned)

  • Create: unique (tenantId, deviceId) enforced (biometric-device.schema.ts:36); duplicate → 409.
  • status restricted to active|inactive|offline (:7-11); default active (:24-25).
  • PATCH partial update; config object round-trips.

4. Functional — verify check-in (planned)

  • Punch timeline matches biometric_logs index order (tenantId, studentId, timestamp -1) (biometric-log.schema.ts:26).
  • AttendanceSource.BIOMETRIC rows 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 (blueprint Biometric.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 OFFLINE today (blueprint health-check Biometric.md:19 unbuilt): verify who flips status once implemented.
  • Sync failure banner copy matches retry policy (4× exp → DLQ).
  • Catch-up punches preserve original timestamp (blueprint COLLECTIONS.md:1883).
  • Retention: biometric_logs > 1 year TTL+archive (blueprint DATA_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 needs biometric.log.read (permissions.constants.ts:41-43).
  • biometric.sync permission missing from constants (blueprint Biometric.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)

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.manage for devices subtree; biometric.log.read for logs/verify (permissions.constants.ts:41-43).
  • Deep link /biometric/logs?student=:id for 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 — SyncStatusTile scoped rebuilds.

12. Proposals flagged to the team

  1. Build order blocked by backend: device CRUD + logs endpoints, then the biometric-sync worker (OQ-4), then sync-status UI. The hub screen must not ship with a permanently-empty queue card.
  2. Template storage decision (OQ-3) gates enrollment UI; until then keep EnrollCubit designed but unshipped.
  3. biometric.sync permission name vs biometric.device.manage — settle before RBAC metadata lands on sync endpoints.
  4. Analytics wiring waits 00-shared AnalyticsService (proposed).
  5. QR/push check-in stays dormant (forward-looking) — route exists, no UI.

01 — Product Overview (Bulk Module)

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 ledger design-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, field file (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-17 lists 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 emits UserCreated (users.service.ts:64-76) → in-app queue user-created-notification (event-queue-map.ts:10) — and one student via StudentService.create (students-import.adapter.ts:76-84) — which emits StudentCreated (student.service.ts:79-91) → in-app queue student-enrolled (event-queue-map.ts:28). Import rows therefore DO fan out notifications, unlike the users module's inline import which bypasses events (see 01_Product_Overview.md §4 in 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; only JwtAuthGuard. Permission enforcement is (planned).
  • Module wiring: bulk.module.ts:11-17 imports UsersModule, 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 exportAsync import workers with BullMQ progress + rollback — (planned) (IMPLEMENTATION_PLAN.md:172)
Per-row validation + per-row error reportCSV 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:

PathEndpointParserReport shapeErrors 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 inlinePOST /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)

  1. Request → multipart file required, else 400 VALIDATION_ERROR "CSV file is required (multipart field "file")." (bulk.controller.ts:43-46).
  2. Buffer decoded as UTF-8 (bulk.controller.ts:47).
  3. Parse with csv-parse/sync {columns:true, skip_empty_lines:true, trim:true} (bulk-import.service.ts:26-30); unparsable CSV → 400 VALIDATION_ERROR "Malformed CSV: could not parse file." (:31-33); header-only/empty → 400 "CSV must include a header row and data." (:34-35).
  4. Unknown entity → 404 RESOURCE_NOT_FOUND No import adapter for entity "X". (bulk-import.service.ts:17-20).
  5. Per row (rowNumber = index + 2, header is row 1 — :45-46): adapter validate(row); any errors → failed+1, errors.push({rowNumber, errors}), row skipped (:47-52); else adapter create(row)imported+1, or on throw → failed+1 with the thrown message as the row error (:53-62). One bad row never aborts the batch (bulk-import.service.spec.ts:53-65).
  6. 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, via StudentRepository.findByAdmissionNumber).
  • email registered → Email "X" already registered. (students-import.adapter.ts:55-56, via UsersService.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).

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):

ColumnRequiredNotes
firstNameyes→ user firstName
lastNameyes→ user lastName
emailyesformat-checked; must not already be registered
admissionNumberyesmust not already exist
gradeyesmatched by name OR code (:118-121)
sectionyesmatched by name (:122-125)
academicYearyesmatched by name (:113-117)
rollNumbernooptional; 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)

  1. 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.
  2. 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).
  3. Round-trip — export CSV as a template/reference for future imports (GET /bulk/export/students, students-import.adapter.ts:87-98).
  4. 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.import exists but is unbound, permissions.constants.ts:10) — (planned) RBAC guard.
  • No analytics instrumentation in this module (proposed).

02 — User Personas (Bulk Module)

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

PersonaRole / permissionBulk behaviourScreens (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 rowsSS1-SS4
Registrar / Front-office staffstudent.read + limited create (permissions.constants.ts:25-26)Exports current rosters; occasionally imports small batchesSS1, SS4
Org Adminall org perms (rbac.member.*, organization.*)Owns entity coverage decisions, audits import outcomes, unlocks future entities (planned)SS1-SS4
IT/Platform Supportplatform-levelDiagnoses malformed files, encoding issues, and timeoutsSS4 (debug copy)
Class Teacher / Parent / Studentstudent.read-adjacent or noneNo 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.csv with admissionNumber, 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-type text/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 students too 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/StudentCreated events (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 on user.import / student.create is (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.import permission exists (permissions.constants.ts:25-28 has student.read/create/update/delete only) — the intended import permission is user.import (:10) (planned) binding.

03 — User Journey (Bulk Module)

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

#JourneyPersonaScreensOutcome
J1First-time bulk importAdmissions AdminSS1 → SS2 → SS3 → SS4All rows imported; report shown
J2Import with row errorsAdmissions AdminSS1 → SS2 → SS3 → SS4Partial success; error list reviewed
J3Fix and re-uploadAdmissions AdminSS4 → SS2 (corrected) → SS4Previously failed rows now import; successes not duplicated
J4Export roster / templateRegistrarSS5students.csv downloaded
J5Malformed / wrong fileany adminSS2 → SS1 (error)Clear failure, file not partially imported

J1 — First-time bulk import (happy path)

  1. Entry — Bulk/Import menu (client gate: admin-capable user, 02 §7).
  2. SS1 Upload — picks students.csv (file picker, .csv only); client parses in an isolate to render a preview (15 §4); "Next" enabled when headers match the contract (08 §1).
  3. SS2 Preview — header map (contract vs file), row count, sample rows; mismatches and missing required columns flagged before upload; "Import N rows" CTA.
  4. SS3 Confirm — reads the risk summary: "N rows will create N users and N student records. Duplicates and reference mismatches will be skipped."
  5. SS4 ResultPOST /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).
  6. Exit — back to Students list; imported users/students are live immediately (synchronous create; no queue).

J2 — Import with row errors (partial success)

  1. Same entry through SS3.
  2. 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).
  3. Error rows are grouped by cause (duplicates / missing refs / bad format) for fast triage; each row links to its line in the file.
  4. 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)

  1. From SS4, "Fix errors" → re-upload the corrected file (SS2).
  2. Already-imported rows now fail as duplicates (admission number / email, students-import.adapter.ts:49-56) — expected and explained: report shows imported ≈ previously-failed rows, duplicates listed as duplicates.
  3. 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).
  4. Design copy must teach this: "duplicate" ≠ "problem" on re-upload (14_QA_Checklist.md §5).

J4 — Export roster / template

  1. SS5 Export — entity selector (students only today), "Download CSV".
  2. GET /bulk/export/studentstext/csv attachment students.csv (bulk.controller.ts:50-60).
  3. Content: admissionNumber, rollNumber, status, admissionDate (students-import.adapter.ts:92-97), sorted by admissionNumber (:90-91).
  4. 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)

  1. Wrong entity offered (e.g. "teachers") → client blocks it (entity list is hard-coded students); if it slips through, server 404 No import adapter for entity "teachers". (bulk-import.service.ts:17-20) → SS4 error state.
  2. 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).
  3. Empty CSV (header only) → 400 CSV must include a header row and data. (:34-35).
  4. Missing file field → 400 CSV file is required (multipart field "file"). (bulk.controller.ts:43-46).
  5. 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) and StudentCreated (in-app student-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)

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 students today, students-import.adapter.ts:15).
  • Exit: SS4 → Students list, SS5 → file download.

2. Sitemap (screens)

#ScreenRoute (forward-looking)Purpose
SS1Bulk Home / Upload/admin/bulkentity picker + file picker + template hint
SS2CSV Preview/admin/bulk/previewheader map, sample rows, contract warnings
SS3Confirm/admin/bulk/confirmrisk summary + go/no-go
SS4Result & Error Review/admin/bulk/resultreport rendering + error table
SS5Export/admin/bulk/exportentity selector + download

3. Entity taxonomy (backend-constrained)

  • entity path param is a free string; only students resolves (bulk-import.service.ts:17-20). Everything else → 404.
  • (planned) entities per IMPLEMENTATION_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:

FieldTypeMeaning
entitystringechoed entity slug
totalRowsnumberdata rows parsed (header excluded; bulk-import.service.ts:45-46)
importednumberrows whose create succeeded
failednumberrows 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, rollNumberadmissionNumber, rollNumber, status, admissionDate
  • Export = roster snapshot (status, admissionDate are system-owned; admissionDate is 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)

GroupStrings (exact, 08 §2)Where thrown
Missing / invalid fieldMissing required column "X"., Invalid email format.students-import.adapter.ts:43,47
DuplicateAdmission number "X" already exists., Email "X" already registered.:54,56
Reference not foundAcademic 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-timearbitrary 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)

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)

FieldDetail
PurposeChoose entity + pick a CSV file; the entry point of the import wizard
Entryadmin nav "Import / Export"; post-error return from SS4
Exitvalid file + headers → /admin/bulk/preview; help → expandable
Inputsentity selector (hard-coded studentsstudents-import.adapter.ts:15; others (planned)), file picker (.csv)
Primary CTANext (disabled until file parses + headers recognized)
Secondary"Download template" (client-generated (forward-looking)); "Export instead" → SS5
Statesidle; picking; parsing (isolate — 15 §4); error (unreadable file, wrong extension, encoding/BOM failure); permission (client gate, 02 §7)
Contentcolumn contract summary (08 §1); max size hint 1000 rows / ~2 MB (10 §6)
Analyticsbulk.upload.pick, bulk.upload.parse_fail(reason) (proposed)
Keyboardfile picker is the only interaction; entity selector keyboardable
Adaptivedesktop/web primary; phone: single-column with compact file card
a11yfile drop-zone as a real button with label; parse errors live-region
Motionm-fast file card confirmation; errors shake

2. CSV Preview (/admin/bulk/preview)

FieldDetail
PurposeShow header map, row count, sample rows, contract warnings — before anything hits the server
EntrySS1 valid file
ExitNext/admin/bulk/confirm; Back → SS1; blocked warnings → stays
Dataclient-parsed CSV (columns:true, trim mirror of bulk-import.service.ts:26-30)
WidgetsCSVPreviewTable (module-specific), AppChips (required/optional/unknown header tags), AppAlert
Primary CTAImport N rows (disabled when blocking warnings exist)
Statespreview; warnings (unknown header, missing required — 08 §1); empty (no data rows → block, mirrors 400 bulk-import.service.ts:34-35)
Warningsnon-blocking: unknown columns ignored; blocking: required column missing
Analyticsbulk.preview.view, bulk.preview.warnings(n) (proposed)
Adaptivewide table on desktop; on phone horizontal scroll + sticky header column
a11ytable semantics, header associations, warning announcements
Motionrows fade-in m-fast; warnings expand m-base

3. Confirm (/admin/bulk/confirm)

FieldDetail
PurposeFinal go/no-go with the risk summary
EntrySS2
ExitStart 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 CTAStart import (filled)
Statesidle; (in-flight happens on SS4)
Analyticsbulk.confirm.start(n) (proposed)
a11ysummary as a list, not an image

4. Result & Error Review (/admin/bulk/result)

FieldDetail
PurposeRender the ImportReport and drive fix/re-upload
Entryafter confirm in-flight; deep-link from report URL (forward-looking)
ExitFix errors → SS1 (pre-highlight failed row numbers); View students → students list; Download report (client-synthesized CSV)
Data200 envelope data = ImportReport {entity,totalRows,imported,failed,errors[{rowNumber,errors[]}]} (import-adapter.interface.ts:14-25)
WidgetsImportSummaryCard, ErrorReviewTable (module-specific), AppSnackbar, AppEmptyState
Primary CTAsuccess: View students; partial: Fix errors; full fail: Fix errors
Statesin-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 groupingduplicates / reference-not-found / format (04 §7)
Analyticsbulk.result.view(imported,failed), bulk.result.download_errors (proposed)
Adaptiveerror table dense on phone; paginated > 50 errors
a11yreport summary live-region; table rows announced with row number
Motionsummary count-up m-base; errors expand/collapse

5. Export (/admin/bulk/export)

FieldDetail
PurposeDownload entity CSV
EntrySS1 secondary CTA; admin nav
Exitfile download; back to SS1
DataGET /api/v1/bulk/export/:entitytext/csv attachment <entity>.csv (bulk.controller.ts:50-60)
Widgetsentity selector, AppButton, AppSnackbar
Primary CTADownload CSV
Statesidle; downloading; empty (no rows → still a header-only CSV — stringify(rows,{header:true}) with [], bulk-import.service.ts:67-71); 404 entity
Noteexport ≠ import template (04 §6) — copy must say "roster snapshot"
Analyticsbulk.export.download(entity) (proposed)
Keyboardsingle 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; resizeToAvoidBottomInset where inputs exist (SS1 search field (forward-looking)).

06 — Screen Specifications (Bulk Module)

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

  1. Entity selector: hard-coded ['students'] — the only adapter is students (students-import.adapter.ts:15); others (planned) (IMPLEMENTATION_PLAN.md:172). Selector present anyway to future-proof; disabled-looking when single option.
  2. File pick: .csv extension 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 options columns:true, skip_empty_lines:true, trim:true (bulk-import.service.ts:26-30).
  3. Parse outcomes:
    • Success → file card shows name, row count (data rows = lines − header), detected headers; Next enabled.
    • 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); Next stays 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).
  4. Next → SS2. Back disabled on SS1 (it is the flow root).

1.3 States

StateVisualNotes
idlecard, Next disabled
parsingfile card skeleton, spinnerisolate parse is ms-level for ≤ 1000 rows
errorAppAlert error under file zoneper 14 §2 encodings
permissionempty state "You don't have import permission"client gate only; server RBAC (planned) (permissions.constants.ts:10)
offlineAppOfflineBanner; pick still allowed, Next blockedno 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:

StateClassificationBlocking?
header ∈ required set[required] chip
header ∈ optional set (rollNumber)[optional] chip
header not in contract[unknown] chip + warning "will be ignored"no
required column absentAppAlert 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 rowNumber in 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 import triggers the synchronous POST /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 in 08 §2.
  • Error CSV is client-synthesized: original row + error column (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 triggerCode/messageSS4 rendering
no file field400 CSV file is required (multipart field "file"). (bulk.controller.ts:43-46)client prevents this (always sends file); show generic + retry
unknown entity404 RESOURCE_NOT_FOUND No import adapter for entity "X". (bulk-import.service.ts:17-20)"Entity not available" + back to SS1
malformed CSV400 VALIDATION_ERROR Malformed CSV: could not parse file. (:31-33)"File could not be parsed" + back to SS1 (preview normally catches this)
header-only CSV400 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 copy Retry 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); response text/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

ConcernRule
StepperSS1→SS2→SS3→SS4; SS5 outside wizard (ImportStepper, 07)
Backpreserves wizard state (file + preview) so users can fix without re-picking when possible
Unsaved stateno forms with server writes until SS3; leaving SS3 before firing has no effect
Large fileSS1 cap + warning; SS3 caveat; SS4 in-flight honesty (10 §6)
a11ylive-region on parse errors and result summary; tables with real semantics (00-shared/09)
Motionm-fast micro-feedback, m-base screen transitions (00-shared/08)

07 — Component Library (Bulk Module)

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

ComponentUsed byPurpose
FileDropZoneSS1drag-drop + browse CSV picker
ImportStepperSS1-SS4upload → preview → confirm → result
CSVPreviewTableSS2header map + sample rows
ColumnChipSS2required / optional / unknown header tag
ContractAlertSS2blocking vs non-blocking header warnings
ImportSummaryCardSS4counts + status hero
ErrorReviewTableSS4per-row error list, grouped, paged
ErrorRowTileSS4single failed row (mobile)
ErrorGroupChipSS4duplicates / references / format grouping
ImportProgressPanelSS4in-flight honesty panel (indeterminate)
ExportCardSS5entity picker + download

2. FileDropZone

PropertyValue
InputsonFile(File), accept=".csv", maxRowsHint (1000), busy
Statesidle / hover (highlight) / picking / parsing (spinner) / error (message) / disabled
Behaviournative 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)
a11yexposed as a button with label "Choose CSV file"; drop target semantics; parse failure via live region
Motionm-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

PropertyValue
Dataheaders (with per-header classification), first 10 rows, total rows
Row numberingphysical line numbers (header = 1) — must equal server rowNumber (bulk-import.service.ts:46)
Header cellswrapped in ColumnChip; unknown columns muted
Whitespace flagsheader with leading/trailing space → ContractAlert blocking (server does not normalize headers, 08 §1)
Phonehorizontal scroll, sticky first column (row #)
a11y<table> semantics with th per column; row headers for #

5. ColumnChip

VariantColour (M3, 00-shared/02)Meaning
requiredprimary containermust be present; missing → blocking alert
optionalsecondaryContainerrollNumber only (students-import.adapter.ts:25)
unknownsurfaceVariantignored 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 → Next disabled; lists exact column names matching error strings Missing required column "X". (students-import.adapter.ts:43).
    • warning — unknown columns ignored; > 1000 rows; > 2 MB.
  • 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 a progress value 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 CSV button → 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)

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

#ColumnRequiredFormat / matchingAdapter source
1firstNameyesany non-empty string; → CreateUserDto.firstName (students-import.adapter.ts:66-70; create-user.dto.ts:5-8):18
2lastNameyesany non-empty string; → CreateUserDto.lastName (create-user.dto.ts:15-17):19
3emailyesmust match /^[^\s@]+@[^\s@]+\.[^\s@]+$/ (students-import.adapter.ts:11,46-47); must not already be registered (:55-56 via UsersService.findByEmail):20
4admissionNumberyesany non-empty string; must not already exist (:49-54 via StudentRepository.findByAdmissionNumber):21
5gradeyesmatched against existing Grade by name OR code (:118-121, $or):22
6sectionyesmatched against existing Section by name (:122-125):23
7academicYearyesmatched against existing AcademicYear by name (:113-117):24
8rollNumbernoblank → 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 !value after 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 columns keys).

1.4 Template copy (client-generated (forward-looking))

RowContent
headerfirstName,lastName,email,admissionNumber,grade,section,academicYear,rollNumber
exampleAmara,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)

TriggerCodeExact stringSource
multipart field file missing400 VALIDATION_ERRORCSV file is required (multipart field "file").bulk.controller.ts:44-45
CSV unparsable400 VALIDATION_ERRORMalformed CSV: could not parse file.bulk-import.service.ts:32
zero data rows400 VALIDATION_ERRORCSV must include a header row and data.bulk-import.service.ts:35
unknown entity404 RESOURCE_NOT_FOUNDNo import adapter for entity "X". (X = path param)bulk-import.service.ts:19

2.2 Row-level (per ImportRowError.errors[])

GroupExact stringSource
required missingMissing required column "X". (X = column name)students-import.adapter.ts:43
formatInvalid email format.:47
duplicateAdmission number "X" already exists.:54
duplicateEmail "X" already registered.:56
referenceAcademic year "X" not found.:117
referenceGrade "X" not found.:121
referenceSection "X" not found.:125
referenceNo class found for grade "X" section "Y".:140
create-timearbitrary message from the thrown Error (err.message)bulk-import.service.ts:57-62

2.3 Client-side (pre-upload, mirroring server strings)

ConditionCopyMirrors
unparsable fileThis file could not be read as CSV.bulk-import.service.ts:32
header only / emptyCSV must include a header row and data.:35
missing required headerMissing required column "X".students-import.adapter.ts:43
whitespace-damaged headerHeader " firstName" has leading/trailing space — the server will not match it.:26-30 (headers unnormalized)
non-UTF-8 encodingFile 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)

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

PatternWhoImpact on design
Batch-onboardingAdmissions Admin at admission seasonwizard must be repeatable in < 2 min; report must be actionable (03 J1-J3)
Spreadsheet-firstRegistrar hands over XLSXclient only accepts .csv; convert-in-app (forward-looking); clear guidance
Fix-and-reuploadAdmin after partial failureerror CSV download + row-highlight re-upload (06 §4.1c)
Copy-paste adminAdmin who pastes from Excel into a text editorBOM/CRLF/quote quirks — client sanitizes + warns (14 §2)

2. Behavioural rules (derived from constraints)

  1. 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).
  2. 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.
  3. No one reads a 400-row error list linearly. Group errors (duplicates / references / format — 04 §7), page at 50, and offer error CSV download.
  4. 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.
  5. Template-first behaviour. Users want a template, not a spec. Provide client-generated template download (08 §1.4) and treat GET /bulk/export as a roster snapshot, never as the template (04 §6).
  6. Trust in counts. Users sum the report; the invariant imported + failed === totalRows (04 §5) keeps the summary trustworthy — surface it verbatim.
  7. 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 actionSystem response (server)Expected user reactionDesigned response
Upload malformed file400 Malformed CSV: could not parse file. (bulk-import.service.ts:32)frustration; checks fileclient parse catches pre-upload; exact copy on SS4
Upload header-only400 CSV must include a header row and data. (:35)confusionSS2 blocks with same string
Wrong entity404 No import adapter for entity "X". (:19)surpriseclient hard-codes entity list; entity picker (planned) discovery
Partial failurereport with row errorsfix-and-reuploaderror CSV + row-highlight flow (03 J3)
Timeout/networkdropped request; rows may existre-upload fearidempotency copy + duplicate labelling (06 §4.1a)
Rate limited429 (api tier 100/min)waitcountdown 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-parse handles 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)

Precise interaction rules per screen and the long-running import interaction. Motion tokens per 00-shared/08; components in 07.


1. SS1 — Upload interactions

#InteractionRule
1.1File pickFileDropZone click/drop; .csv filter; immediately isolate-parse (15 §4)
1.2Parse failinline AppAlert; file card shows error; Next disabled; live-region announce
1.3Parse successfile card confirms (name, rows, headers); Next enabled; m-fast check-in
1.4Non-UTF-8 / BOMBOM stripped silently; non-UTF-8 → warning card with re-encode guidance (14 §2)
1.5Re-pickreplaces previous file; wizard state (preview/confirm) reset to preview

2. SS2 — Preview interactions

#InteractionRule
2.1Header classificationColumnChip per header (required/optional/unknown)
2.2Blocking warningContractAlert blocking; Import N rows disabled; lists exact missing column names (08 §1)
2.3Sample rowsfirst 10; row # = physical line (header=1)
2.4Backreturns to SS1 with file retained
2.5Unknown columnswarning only; never blocks (server ignores, bulk-import.service.ts:26-30)

3. SS3 — Confirm interactions

#InteractionRule
3.1Start importfires 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.2Backallowed until fired; no server side effects before firing

4. SS4 — Result interactions

#InteractionRule
4.1In-flightImportProgressPanel indeterminate; no progress events exist (bulk-import.service.ts:22-65); panel explains and warns
4.2Successsummary card; View students; Download report
4.3Partialgrouped error table; pagination 50; row tap → expand detail
4.4Row actioncopy error string; per-row "open file at line N" hint (rowNumber = line, :46)
4.5Error CSVclient-synthesized: original row + error column; no server endpoint
4.6Fix errors→ SS1 with failed row numbers queued; preview highlights them when the corrected file parses (03 J3)
4.7Pre-row server rejection400/404 rendered as failure card with exact copy (08 §2.1)
4.8429countdown Retry in Ns, no auto-retry (00-shared/07 §4)
4.9Network droppartial-upload guidance; re-upload is duplicate-safe (06 §4.1a)

5. SS5 — Export interactions

#InteractionRule
5.1DownloadGET /bulk/export/students (bulk.controller.ts:50-60); browser attachment students.csv; loading state on button
5.2Emptyheader-only CSV still downloads; toast "No rows exported"
5.3Export ≠ templatedisclaimer 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:

AspectDesign
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)
Feedbackindeterminate progress + copy: "validated and created server-side in one request; keep this tab open"
Duration guardclient request timeout ≥ 120 s (configurable); on timeout → partial-upload guidance (4.9)
File cap1000 rows / ~2 MB at pick time; warning above, block above hard cap (configurable (forward-looking))
Rate budgetone import can be a large chunk of the 100/min api tier (00-shared/07 §4) — warn on very large files
Cancelno server cancel exists; cancel only abandons the response — copy states rows may still be created
Future seamwhen 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

MomentMotion (00-shared/08)
file acceptedm-fast (check icon pop)
parse error / blockm-fast shake + live-region
wizard step advancem-base slide, stepper check pop
result countsm-base count-up on imported/failed
error row expandm-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)

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)

TokenWhereRationale
color.primary (filled)SS3 Start import, SS1 Next, SS5 Downloadsingle primary action per screen
color.errorblocking alerts, error rows, failure cardsseverity semantics only
color.warning (amber)unknown-header chips, size warningsnon-blocking attention
color.secondaryContaineroptional ColumnChip (rollNumber)neutral secondary
color.surfaceVariantunknown ColumnChipmuted, ignorable
color.success (M3 extended, 00-shared/02 §4)all-success summary, stepper checksstatus, not CTA
spacing grid 4/8/12/16/24card padding 24, table cell 12, chip gap 8consistency
radius.md (12)cards, drop zone, tablesforms & data density
type.titleMediumscreen titles, summary numbershierarchy
type.bodyMediumtable cells; type.labelLarge for row # columndata 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.mono if 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 outlineVariant row dividers (a11y contrast, 00-shared/09 §3).

3. Status colour mapping (module-specific)

StatusColourSurface
imported countcolor.successImportSummaryCard
failed countcolor.errorImportSummaryCard
in-flightcolor.primary indeterminate barImportProgressPanel
duplicate error groupcolor.warning chipErrorGroupChip
reference-not-found groupcolor.primary chipErrorGroupChip
format / create-time groupcolor.error chipErrorGroupChip

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)

TokenUse
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 emphasizedtransitions; standard for data rows

6. Accessibility mapping (00-shared/09)

RequirementBulk implementation
Live regionsparse failures (SS1), result summary (SS4), contract blocking (SS2)
Focus orderdrop zone → entity → Next; table cells focusable; CTA Enter
Contrasterror/warning text on surface ≥ 4.5:1; chips use containers not outlines
Screen-reader labelstables: real th; stepper: aria-current-equivalent semantics; summary read as one sentence
Touch targets≥ 48 dp on phone (row actions as tiles, ErrorRowTile)
Reduced motionrespect motion.reduced — replace count-up/shake with static states (00-shared/08 §6)
Keyboard onlyfull wizard usable without pointer (10 §8)

7. Error & empty states (00-shared/03)

StatePattern
PermissionAppEmptyState + "contact admin" (client gate; server RBAC (planned))
OfflineAppOfflineBanner; import/export blocked (no offline writes, 00-shared/06 §3.5)
Rate limitedcountdown copy, no auto-retry (00-shared/07 §4)
Empty roster exportheader-only CSV + toast (06 §5.2)
All-failed importfailure card + "fix errors" primary

8. Mapping gaps (flagged for the shared system)

  • No table component in 00-shared/03CSVPreviewTable / ErrorReviewTable (07) become the reference implementation; propose them for the shared library.
  • No file drop-zone in 00-shared/03FileDropZone (07) is the reference; same for ImportStepper.
  • 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)

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)

EndpointPOST /api/v1/bulk/import/:entity (bulk.controller.ts:35-48)
GuardJwtAuthGuard (bulk.controller.ts:30); RBAC permission guard (planned)user.import exists (permissions.constants.ts:10) but is not enforced on this route
Contentmultipart/form-data, field file (FileInterceptor('file') :38); required — missing → 400 VALIDATION_ERROR CSV file is required (multipart field "file"). (:43-46)
Entity paramfree 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)
Encodingfile buffer decoded UTF-8 server-side (bulk.controller.ts:47)
Parsecsv-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)
Executionsynchronous 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)
Response200 envelope; data = ImportReport {entity, totalRows, imported, failed, errors: [{rowNumber, errors[]}]} (import-adapter.interface.ts:14-25); invariant imported + failed === totalRows
Errors400 (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 effectsUserCreated 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 fallbackif usersService.create throws (duplicate), the adapter reuses the existing user id (students-import.adapter.ts:72-75) — row still imports as a student
Async/pollingnone 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)
ClientSS1-SS4 wizard; in-flight = indeterminate (10 §6); timeout → partial-upload guidance (06 §4.1a)

E2 — Export entity CSV

EndpointGET /api/v1/bulk/export/:entity (bulk.controller.ts:50-60)
GuardJwtAuthGuard (:30); RBAC (planned)
Responsetext/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 rowsadmissionNumber, rollNumber, status, admissionDate (ISO string, '' when unset), sorted by admissionNumber asc (students-import.adapter.ts:87-98)
Emptyheader-only CSV (stringify([],{header:true})) — still 200
Errors404 unknown entity (bulk-import.service.ts:19); 429; 5xx
ClientSS5; roster snapshot ≠ import template (04 §6)
EndpointPurposeSource
POST /api/v1/users/importseparate inline import engine (naive split(','); {imported, errors[]}; no events) — not part of this moduleusers.controller.ts:104-110; users.service.ts:233-282
POST /api/v1/students/bulk-import(planned) async students importIMPLEMENTATION_PLAN.md:195
POST /api/v1/files/upload-csv → worker(planned) queue-based CSV → per-row UserCreatedIMPLEMENTATION_PLAN.md 2.7 (users package 01 §4)
GET /api/v1/studentspost-import verification for SS4 "View students"students module

E4 — Permission & rate-limit posture

ConcernFact
Route guardJwtAuthGuard only (bulk.controller.ts:30)
user.importdefined (permissions.constants.ts:10); not bound to the bulk routes — RBAC enforcement (planned)
student.create / user.createexist (:7,26); same unbound status on these routes
student.importdoes not exist — intended import permission is user.import
Rate limitapi tier 100/min (00-shared/07 §4); one large import consumes a large share — client warns (10 §6)

E5 — Event fan-out (exact)

EventEmitterRoute (event-queue-map.ts)
UserCreatedUsersService.create per imported row (users.service.ts:64-76)in-app / user-created-notification (:10)
StudentCreatedStudentService.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

ConcernRule
AuthBearer JWT; 401 → single-flight refresh → replay; fail → session expiry (00-shared/06 §3.6)
Uploadmultipart field file; content-type multipart/form-data; never optimistic
Idempotencyre-upload safe via per-row dedup (students-import.adapter.ts:49-56); no Idempotency-Key needed
Offlinereads from last-good cache + banner; import/export blocked (00-shared/06 §3.5)
Error mapping00-shared/06 §5: 400 file-level, 404 entity, 429 backoff, 5xx generic + requestId; row-level errors live in the 200 body, not the envelope
Realtimenone for bulk; (forward-looking) completion topic when async lands (06 §4.3)

13 — State Management (Bulk Module)

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 of students-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:truebulk-import.service.ts:26-30), RetryParse.
  • Rules: Next enabled ⇔ parseStatus == ok && rows > 0 (mirrors 400 CSV 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 | unknown per 08 §1), blocking: string[] (missing required columns — names match Missing 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

FieldMeaning
phaseidle | confirming | inFlight | done
outcomeallSuccess | partial | failedPreRow
report?ImportReport {entity,totalRows,imported,failed,errors[{rowNumber,errors[]}]} (import-adapter.interface.ts:14-25) — verbatim from data
requestStatesent | timedOut — timeout ≠ failure: rows may exist (06 §4.1a)
jobId?, progress?(planned) async seam — unused today (no queue, queue.constants.ts:1-17)
failedRowsQueuerow numbers carried to SS1 for re-upload highlighting (03 J3)

4.4 Transitions (exact)

FromEventToCondition / source
ConfirmingstartImportInFlightmultipart file field (bulk.controller.ts:38); CTA double-fire blocked
InFlightimportSucceededAllSuccess200, imported === totalRows
InFlightimportPartialPartial200, failed > 0
InFlightimportRejectedFailedPreRow400 Malformed CSV: could not parse file. (:32) / CSV must include a header row and data. (:35); 404 No import adapter for entity "X". (:19)
InFlightimportRejectedFailedPreRow429 (countdown copy) / 5xx / network drop (partial-upload guidance)
Partial / FailedPreRowfixErrorsFixAndReupload→ SS1 with failedRowsQueue

4.5 Client-side report assertions

  • imported + failed === totalRows — violated → dataMismatch flag 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-coded students), 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)

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/students with a valid file returns 200 and a report where imported + failed === totalRows (import-adapter.interface.ts:14-25).
  • rowNumber of 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-app user-created-notification and StudentCreated → in-app student-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-parse handles RFC-4180 quoting (bulk-import.service.ts:26-30); verify a field containing a comma inside quotes imports intact; unclosed quote → 400 Malformed 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 with Missing 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 → 400 CSV 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 → 404 No import adapter for entity "fees". (bulk-import.service.ts:19); client never offers it (only students, 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/studentsContent-Type: text/csv, Content-Disposition: attachment; filename="students.csv" (bulk.controller.ts:50-60).
  • Columns admissionNumber, rollNumber, status, admissionDate in 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)

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)MultipartRequest POST /api/v1/bulk/import/:entity, field file (filename students.csv, contentType: text/csv) — matches FileInterceptor('file') (bulk.controller.ts:38-48); body decoded UTF-8 server-side (:47). Returns ImportReport from envelope data.
  • 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 the Content-Disposition filename.
  • 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

PlatformPackage / APINotes
web/desktopfile_picker (FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['csv']))also universal_html drag-drop for the drop zone
Android/iOSfile_selector/file_picker with mime text/csv, text/comma-separated-values, application/csvsome providers return .txt for CSV — accept by content sniff, not extension alone
Allread 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 — csv package with shouldParseNumbers: false (never let the parser coerce types; server rows are Record<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: truefirst row = header; rows → Map<String,String>
trim: truetrim each cell after split
skip_empty_lines: truedrop blank lines before mapping
quoting (RFC-4180)default csv behaviour — must match; test quoted commas (14 §2)
header normalizationnone 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): when IMPLEMENTATION_PLAN.md:172 workers land (queue + progress + polling), ImportProgressPanel accepts progress: 0..1 and the cubit adds jobId/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

  • ImportSummaryCard asserts imported + 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 in 08 §2.2.
  • Error CSV download is client-synthesized: original file rows + appended error column joined from the report by rowNumber. No server endpoint exists.
  • "Fix errors" passes failedRowsQueue (row numbers) back to SS1 for highlighting after re-pick (03 J3).

7. Export

  • exportCsv via repository; on mobile hand bytes to a share sheet (share_plus); on web trigger an anchor download from the response bytes with filename="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)

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 in 00-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

GoalMeasure
Institutional broadcast in secondsDraft → publish ≤ 3 taps from composer
Audience accuracyNo announcement delivered outside its audience
Delivery accountabilityRead receipts per announcement, per recipient
Low operational noiseRecipients 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

CapabilityStatusSource
Create announcement (draft)Implementedannouncements.controller.ts:23-27
List (filter by audience)Implementedannouncements.controller.ts:29-33
Publish + broadcastImplementedannouncements.controller.ts:35-39
Mark readImplementedannouncements.controller.ts:41-45
Read receiptsImplementedannouncements.controller.ts:47-51
Detail GET /announcements/:idGapno route
Update / delete / archiveGapno route
Priority / pinned / expiry(planned)not in announcement.schema.ts
Templates(planned)IMPLEMENTATION_PLAN.md:231
RBAC communication.* permsGapabsent from permissions.constants.ts
Event→queue routingGapabsent 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 announcements collection, tenant-scoped via BaseRepository (announcement.schema.ts:31,57)
  • Cross-module reads: GradeRepository, SectionRepository, StudentRepository, OrganizationMember model (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.ts has 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)

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/reads after 24 h.
  • Audience needs: broadcast-all (AudienceType.ALL), role targeting (AudienceType.ROLEorgMemberModel.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.GRADE and AudienceType.SECTION — resolved via gradeRepo.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 (findOne returns [], 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 (or AudienceType.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 against targetUserIds locally ((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 via AudienceType.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 consumersAnnouncementCreated / AnnouncementPublished payloads (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

CapabilityAishaDanielZainabMrs. OkoroSamuel
Compose announcement
Publish / broadcast
Read receipts view
Feed + mark read
Threads / messages

03 — User Journeys (Communication Module)

End-to-end journeys mapped to backend flow. Backend behavior cited with file:line.


1. Author journey: Draft → Publish → Measure (Aisha, Daniel)

StepActionBackend callBackend behavior
1Open composerlocal form state (draft is client-side only)
2Title, body, audiencevalidated per CreateAnnouncementDto
(create-announcement.dto.ts:29-49)
3Save draftPOST /announcementscreates doc with published: false and
createdBy from tenant context (announcement.service.ts:31-37); emits
AnnouncementCreated (:39-51)
4PublishPOST /announcements/:id/publishidempotent — 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)
5MonitorGET /announcements/:id/readsreturns readBy[] receipts
(announcement.service.ts:104-107)
6Follow upmanual (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)

StepActionBackend callBackend behavior
1Open feedGET /announcementslist, sort: { createdAt: -1 }
(announcement.service.ts:56-60); optional ?audience= filter
2Client-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)
3Open detailGET /announcements/:idgap: no route; reuse list item payload
(forward-looking)
4Read + acknowledgePOST /announcements/:id/read$addToSet receipt
{userId, readAt} — idempotent (announcement.repository.ts:20-33)
5Check attachmentattachments: 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 from GET /announcements/:id/reads
    • targetUserIds from list payload.
  • Note: reads returns 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

SituationBackend behaviorClient handling
Publish nonexistent idNotFoundException announcement.service.ts:64error snackbar
Mark read nonexistent idNotFoundException announcement.service.ts:100toast, 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)

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 shapeResolution (announcement.service.ts:109-148)
allnone[] — broadcast marker, targets empty :144-146
rolestring (e.g. teacher)org members roles: value, status: 'active' → userIds :114-119
gradestring (grade name)gradeRepo.findOne({name}) → students of grade :120-125
sectionstring (section name)sectionRepo.findOne({name}) → students of section :126-135
customstring[] userIdsdirect 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.ts has no Announcement routes today.
  • Notifications engine owns delivery channels (studylyon-blueprint/04-Modules/Notifications.md) — out of scope here.

05 — Screen Inventory (Communication Module)

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

FieldDetail
PurposeConsume school announcements; primary recipient surface
Entrydashboard Communication hub, app open, push tap (forward-looking)
Exitdetail sheet; compose (authors); /announcements/mine
SourceGET /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 widgetsAppCard announcement tile: title, body excerpt, audience chip, status badge (draft/published), unread dot, date
Pull-to-refreshRefreshIndicator re-fetches list
Empty"No announcements" + CTA "Create one" (authors)
Statesloading skeleton; error offline banner + retry; permission (401 → login)
Analyticscomm.announcements.{open,refresh,tab_switch} (proposed)
a11yunread dot exposed via semantics label "Unread"; chips readable by TalkBack
Motionlist 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)

FieldDetail
PurposeFull body, audience line, attachments, mark-as-read action
Entryfeed row tap, search result, push deep link (forward-looking)
Exitback; attachments via external browser
SourceGap: no GET /announcements/:id route (announcements.controller.ts has only create/list/publish/read/reads). Use the list payload + receipt action (forward-looking)
ActionsPOST /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 badgedraft / published + publishedAt local-formatted
Emptynot applicable (not-found → snackbar + pop)
a11ybody as single readable block; read-state announced via live region
Analyticscomm.announcement.detail.{open,mark_read,attachment_open} (proposed)

3. Compose Announcement (/announcements/compose) — authors

FieldDetail
PurposeCreate draft; audience targeting; publish in one flow
Entryfeed FAB, /announcements/mine FAB, empty-state CTA
Exitsave draft → success → feed/mine; publish → success → feed
SourcePOST /announcements (draft, published: false, createdBy from context — announcement.service.ts:31-37) then optional POST /announcements/:id/publish
Formtitle, 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)
Statesvalidation inline; draft saved snackbar; publish spinner
Analyticscomm.announcement.compose.{save_draft,publish,audience_change} (proposed)
Keyboardtitle .text, body multiline; toolbar above keyboard
a11ylabels linked; audience picker as radio group

4. My Announcements (/announcements/mine) — authors

FieldDetail
PurposeAuthor's own drafts + published list
Sourcesame GET /announcements; client filters createdBy == me — backend has no "mine" query (forward-looking)
Rowannouncement tile + status badge (draft / published) + reads count
Empty"No announcements yet" + FAB compose
Analyticscomm.announcements.mine.{open,status_filter} (proposed)

5. Read Receipts (/announcements/:id/reads — sheet) — authors

FieldDetail
PurposeWho read / who hasn't
SourceGET /announcements/:id/readsreadBy[] (announcement.service.ts:104-107); read count + targetUserIds.length from list payload
RowsAppListTile: name (user lookup), readAt; segmented "Read / Unread" (unread = targetUserIds − readBy)
Statesloading; empty (nobody read yet)
Analyticscomm.announcement.reads.{open,segmented_switch} (proposed)
Noteexport / laggard reminders (planned)

6. Threads List (/threads) — module secondary surface

FieldDetail
SourceGET /threads (threads.controller.ts)
RowAppListTile conversation: partner avatar, last message, unread badge
Empty"No conversations" + FAB new thread
Analyticscomm.threads.{open,search} (proposed)

7. Thread Detail / Chat (/threads/:id)

FieldDetail
SourceGET /threads/:id, send POST /messages, mark read PATCH /threads/:id/read (IMPLEMENTATION_PLAN.md:107-112)
RealtimeWebSocket via existing WsModule (planned)IMPLEMENTATION_PLAN.md:119
Statesoffline → outbox queue (forward-looking)
Analyticscomm.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)

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 /announcementsAnnouncementDocument[] (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: readBy lacks 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

StateRender
loadingAppSkeleton rows ×6
successlist
emptyAppEmptyState "No announcements yet" + compose CTA (authors)
error offlineAppOfflineBanner + cached list (forward-looking); retry
auth expired401 → 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$addToSet receipt, 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.value for role/grade/section/custom; "Everyone" for all; count = targetUserIds.length (0 for allannouncement.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 inputResolves to
allnone (value omitted)[] — broadcast marker only (announcement.service.ts:144-146)
roleenum string — e.g. teacheractive org members with role
(announcement.service.ts:114-119)
gradegrade name stringstudents of grade (:120-125)
sectionsection name stringstudents of section (:126-135)
customuserId chipsthe 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 /announcementspublished: false + createdBy from token context (announcement.service.ts:31-37); emits AnnouncementCreated (:39-51).
  • Publish: same POST then POST /announcements/:id/publish → resolves audience to targetUserIds, sets published: true, publishedAt (:68-93); emits AnnouncementPublished (: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

CheckRuleSource
titlerequired, min 1 char, trimmedcreate-announcement.dto.ts:30-33; announcement.schema.ts:33-34
bodyrequired, min 1 char, trimmedcreate-announcement.dto.ts:35-38; announcement.schema.ts:36-37
audience.typerequired enumcreate-announcement.dto.ts:14-16; announcement.schema.ts:16-17
audience.valueoptional string | string[], strings onlycreate-announcement.dto.ts:24-26
attachmentsoptional 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/readsAnnouncementDocument['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 announcements targetUserIds is [] → 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

  1. published: false ⇒ never in recipient "For me"; authors see "Draft" badge.
  2. Unread state = myUserId ∉ readBy[].userId — always derived, never cached server-side.
  3. AudienceType.ALLtargetUserIds.length === 0 — treat as "everyone", never as "0".
  4. 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.
  5. 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 /announcements paginate 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)

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

PropertyTypeNotes
announcementAnnouncement modelfrom GET /announcements payload (announcement.schema.ts:31-53)
isUnreadboolderived: myUserId ∉ readBy[].userId
onTapVoidCallback→ detail
trailingwidget?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

PropertyTypeNotes
audienceAnnouncementAudience{ type, value } (announcement.schema.ts:15-21)

Label mapping (verbatim enum, announcement.schema.ts:7-13):

typelabel
all"Everyone"
rolevalue (e.g. "Teacher")
gradevalue (e.g. "Grade 10")
sectionvalue (e.g. "10-A")
custom"Selected people" (+count if known)

AppChip with module palette (see 11_Design_System_Mapping.md).

3. AnnouncementStatusBadge

PropertyTypeNotes
publishedboolannouncement.schema.ts:45-46
  • true → "Published" + publishedAt on detail
  • false → "Draft" (publishedAt absent — :48-49)

AppBadge; published = success color, draft = neutral.

4. ReadReceiptBar

PropertyTypeNotes
readCountintreadBy.length (announcement.service.ts:104-107)
targetCountinttargetUserIds.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

PropertyTypeNotes
userIdstringreceipt.userId (announcement.schema.ts:24-25)
readAtDateTime:27-28
displayNamestringuser lookup (forward-looking) — API returns ids only

AppListTile: avatar, name, "Read 3 Aug 9:04". Unread variant shows "Not read yet".

6. AudiencePicker (composer)

PropertyTypeNotes
valueAnnouncementAudience{ type, value? }
onChangedcallback
optionsenum listall|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
FeedAnnouncementCard, AudienceChip, AnnouncementStatusBadge, App*
DetailAnnouncementStatusBadge, AudienceChip, AppSheet/Page
ComposeAudiencePicker, ComposerField, App*
MineAnnouncementCard, AnnouncementStatusBadge
ReceiptsReadReceiptBar, ReadReceiptRow
ThreadsThreadTile, MessageBubble

08 — Form Specifications (Communication Module)

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

FieldRequiredControlSource rule
TitleAppTextField single-lineIsString, MinLength(1) (create-announcement.dto.ts:30-33); trimmed on save (announcement.schema.ts:33-34)
BodyAppTextField multilineIsString, MinLength(1) (create-announcement.dto.ts:35-38); trimmed (announcement.schema.ts:36-37)
Audience typeAudiencePicker choice chipsIsEnum(AudienceType) (create-announcement.dto.ts:14-16); enum all|role|grade|section|custom (announcement.schema.ts:7-13)
Audience valueconditionalpicker / chips / noneIsOptional, IsString({ each: true }), string | string[] (create-announcement.dto.ts:24-26)
Attachmentschip list + pickerIsOptional, IsArray, IsString({each}) (create-announcement.dto.ts:45-49)
Prioritynot a backend field(planned) — absent from announcement.schema.ts
Expirynot a backend field(planned) — absent from announcement.schema.ts

2. Audience picker — value semantics per type

TypeValue UIPayload valueResolution note
allnone (hint "Everyone in your school")omittedresolves [] (announcement.service.ts:144-146) — broadcast marker
roledropdown of roles (RBAC constants)"teacher"org members roles: value, status: 'active' → userIds (announcement.service.ts:114-119)
gradepicker fed by academics (grade names)"Grade 10"gradeRepo.findOne({name}) → students (:120-125) — name, not id
sectionpicker fed by academics (section names)"10-A"sectionRepo.findOne({name}) → students (:126-135) — name, not id
customuser 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

ActionRequestsResult
Save draftPOST /announcementspublished: false, createdBy from token (announcement.service.ts:31-37); emits AnnouncementCreated (:39-51)
PublishPOST /announcementsPOST /announcements/:id/publishtargetUserIds 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 /announcements includes 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).
  • AnnouncementCreated fires 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)

FieldClient ruleFailure UX
Titlenon-empty after trim, ≤ 120 chars (product limit (proposed))inline error, focus
Bodynon-empty after triminline error, focus
Audience typeone of 5 enum valuesradio/chips always select one
Audience valuerequired unless all; strings onlyinline error "Choose who sees this"
AttachmentsURL 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)

Observed/targeted behavior patterns per persona; informs layout, frequency of actions, and failure modes. Backend constraints cited where they shape behavior.


1. Consumption behavior

PersonaCadenceSession typeCore action
Student (Zainab)2×/day (morning/evening)20-40 s skimopen feed → open unread → auto/explicit read
Parent (Mrs. Okoro)1×/day + event-triggered15 sdeep link → detail → read
Teacher (Daniel)3-5×/day around classes30-60 sfeed + compose class notices
Admin (Aisha)as events occur1-2 mincompose, 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 AudiencePicker must 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 :id after 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[] from GET /announcements/:id/reads and targetUserIds from the list (announcement.service.ts:104-107 + :42-43).
  • Known trap: AudienceType.ALL has targetUserIds = [] (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

FailureUser reactionMitigation in design
Feed emptyassumes nothing was announcedempty-state CTA for authors; no skeleton ghosting
Audience silently empty (typo name)believes everyone saw itrecipient preview (planned) + warning (06 §3.3)
Read tap errorsretries once, then ignoresidempotent $addToSet (announcement.repository.ts:20-33) makes retries safe; silent fire-and-forget + toast
Offline feedexpects cached last stateoffline banner + last-fetch cache (forward-looking)
Duplicate announcements (no edit route)deletes? can't — no delete routetolerate duplicates in feed; dedupe by _id client-side

6. Trust & privacy

  • Users believe announcements are private to the tenant — true at API level (BaseRepository tenant 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.ts has no communication.*), so the client hides it for non-authors by createdBy == me (announcement.schema.ts BaseSchema createdBy; 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)

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

FromGesture/CTAToBackend relevance
Feed rowtapDetail (sheet mobile / pane tablet)no fetch — carry item (06 §2.1 gap)
Feed FABtapComposeauthors only (planned)
Feed tabtap For me | Allsame screen, refilterclient-side filter (announcement.service.ts:56-60)
AnnouncementCard menutapRead receipts (authors)GET /announcements/:id/reads
Compose Publishtap (loading)success pop → feedPOST create + POST publish (08 §3)
Thread tiletapThread detailGET /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; Back restores 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.next chain; body multiline with toolbar.
  • Audience change: selecting a type re-renders value control; picking all clears 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/read on open (IMPLEMENTATION_PLAN.md:112).
  • Realtime via WS (planned) (IMPLEMENTATION_PLAN.md:119).

7. Motion & feedback map

InteractionMotionFeedback
Feed loadlist fade m-baseskeletons
New item appearsslide-in m-base
Detail opensheet slide-up m-base / page push m-base
Mark readinstant checkmark (no delay)snackbar on error only
Publishbutton spinner m-fast; success pop m-basesnackbar "Published"
OfflineAppOfflineBanner + native retry

8. Offline & error policy

StatePolicy
offline, feedserve last-fetched cache + banner (forward-looking)
offline, mark readqueue receipt POST; flush on reconnect (forward-looking) — safe: $addToSet idempotent (announcement.repository.ts:27-31)
offline, composeblock publish; keep form state in memory; warn
401global → login redirect (JWT guard, announcements.controller.ts:11)
404 mark read / readssnackbar + pop to list
500generic 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)

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

SemanticTokenComponent use
Publishedsuccess/primaryAnnouncementStatusBadge
Draftneutral/greyAnnouncementStatusBadge
Unreadaccent amber dotAnnouncementCard leading dot
Readdefault text/iconcheckmark on detail CTA
Audience roleinfo chipAudienceChip
Audience grade/sectioninfo chip variantAudienceChip
Audience customsecondary chipAudienceChip
Audience allprimary "Everyone" chipAudienceChip
Priority high (planned)error/alert accentfuture PriorityBadge — no schema field yet (announcement.schema.ts:31-53)
Expired (planned)muted + strikethrough titlefuture filter state

2. Component → token mapping (key ones)

Component (07)Tokens
AnnouncementCardsurface card, outline border, radius-m, space-3/4, shadow-1
AudienceChipchip tokens + per-type fill (map above)
AnnouncementStatusBadgeAppBadge token sets (success/neutral)
ReadReceiptBarprogress primary, track surface-variant
Compose formstandard 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

  1. Color never carries meaning alone — always paired with text/label (00-shared/09): "Draft", "Published", "Unread" semantics.
  2. Unread amber is reserved for "has unread content" only (feed dot, thread badge).
  3. Error red = validation and failures only, not for "draft" or "expired".
  4. High-priority accents (planned) must contrast ≥ 4.5:1 and pass 00-shared/09 checks.

4. Iconography

IconMeaning
campaign / megaphoneannouncements hub, compose FAB
mark_email_read / check_circlemark-read CTA, read state
visibility / bar_chartread receipts (author)
attach_fileattachments
draft (document)draft badge
scheduleexpiry (planned)

Icons from the shared icon set (00-shared/02); no custom glyphs.

5. Density & layout

  • Feed rows: comfortable density (space-4 vertical); 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)

Exact endpoints from source. Announcement API is verbatim (announcements.controller.ts); threads/messages listed per docs/IMPLEMENTATION_PLAN.md:104-119. No communication.* permissions exist in permissions.constants.ts — flagged as gap. Response envelope per 00-shared/07.


1. Announcements API (implemented — announcements.controller.ts)

#MethodPathSummary (from @ApiOperation)Source
1POST/api/v1/announcementsCreate an announcement (draft)announcements.controller.ts:23-27
2GET/api/v1/announcementsList announcements (filter by audience type):29-33
3POST/api/v1/announcements/:id/publishPublish and broadcast an announcement:35-39
4POST/api/v1/announcements/:id/readMark announcement as read:41-45
5GET/api/v1/announcements/:id/readsRead 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)

EventEmittedPayloadSource
AnnouncementCreatedcreate (draft!){announcementId, title, body, audienceType}announcement.service.ts:39-51
AnnouncementPublishedpublish{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)

MethodPathSource
POST/api/v1/messagessend (1:1 or group) — IMPLEMENTATION_PLAN.md:107
GET/api/v1/messageslist my messages — :108
GET/api/v1/threadslist conversations — :109
GET/api/v1/threads/:idthread + messages — :110
POST/api/v1/threadscreate thread — :111
PATCH/api/v1/threads/:id/readmark read — :112
WebSocketrealtime 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)

GapImplication
No GET /announcements/:iddetail relies on carried list payload
No update / delete / archive routesdrafts cannot be edited; duplicates tolerated
No "mine" filterclient filters createdBy == me
No user-filter on listclient filters targetUserIds for "For me"
No paginationunbounded list; (planned) when tenant grows
No priority/expiry fieldscomposer must not render them (08 §8)
all audience ⇒ targetUserIds = []receipts counts must degrade (09 §3)
No communication.* RBACauthorship gating is client-side by createdBy for now
No event→queue map entriesdownstream delivery (planned)

13 — State Management (Communication Module)

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

CubitScreenBackend sourceMutation
AnnouncementFeedCubitFeedGET /announcementsnone (list)
AnnouncementDetailCubitDetailcarried item + POST :id/readread receipt
AnnouncementComposerCubitComposePOST /announcements, POST :id/publishcreate, publish
AnnouncementReadsCubitReceiptsGET /announcements/:id/readsnone (stats)
ThreadsCubit / ThreadCubitThreads (secondary)threads/messages APIssend, 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).
  • FeedItemRead is optimistic; the authoritative readBy arrives on next fetch — rollback on fetch when server disagrees (idempotent server-side anyway, announcement.repository.ts:20-33).
  • Caching: last FeedLoaded kept 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.ts rules (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; requires targetUserIds from 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

  • FeedItemRead and DetailMarkRead do not share state directly; the feed reconciles on next fetch. Single source of truth = server readBy.
  • 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) — no GET :id API (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

  1. Never store readBy as screen truth for other screens — only FeedLoaded cache.
  2. Composer keeps its own form state; no redux-style global form.
  3. All API failures map to typed ComposerError/FeedError messages, never raw exceptions.
  4. Tenant/user ids (meId) injected once at app bootstrap (auth session) — reused by ForMe filter and read checks.

14 — QA Checklist (Communication Module)

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

#SeverityCheckExpected / source
1.1🔴ROLE teacher resolves to active members onlyorg members roles: 'teacher', status: 'active' → userIds (announcement.service.ts:114-119); inactive excluded
1.2🔴GRADE by name resolves students of that gradegradeRepo.findOne({name})studentRepo.find({gradeId}) (:120-125); unknown grade ⇒ []
1.3🔴SECTION by name resolves students of that sectionsectionRepo.findOne({name}) → students (:126-135)
1.4🟡CUSTOM with single string vs arrayboth handled (:136-143); non-array string wrapped to [value]
1.5🟡CUSTOM with invalid ObjectIdnew 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 resolutiongrade/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 truthall ⇒ always; else meId ∈ targetUserIds (announcement.schema.ts:42-43)

2. Draft / publish lifecycle

#SeverityCheckExpected / source
2.1🔴Create ⇒ published: false, createdBy = token userannouncement.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-opearly return when published (:68-70)
2.4🔴Publish sets publishedAt + resolved targetUserIds:73-78
2.5🟡Publish 404 for unknown idNotFoundException (: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

#SeverityCheckExpected / 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 404announcement.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.userIdderived client-side; test with partial reads
3.7🟢ALL-audience counts hiddentargetUserIds.length === 0 (:144-146)

4. Delivery via events / queues

#SeverityCheckExpected / 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 routesevent-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 deliverypayload audienceType only; consumers must key on AnnouncementPublished

5. Expiry & priority (planned)

#SeverityCheckExpected / source
5.1🟡Expiry field absent today — UI must not render itschema has none (announcement.schema.ts:31-53)
5.2🟢When shipped: expired items hidden from feed, flagged on detaildesign intent only
5.3🟢Priority/pinned ordering affects feed sorttoday sort is createdAt: -1 only (announcement.service.ts:59)

6. List / query behavior

#SeverityCheckExpected / source
6.1🟡?audience= filter equals audience.typefilter['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+ docsgap, (planned)
6.4🟢Index supports feed query{tenantId, published, createdAt: -1} (announcement.schema.ts:57)

7. Client states (per 06)

#SeverityCheck
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

#SeverityCheck
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

#SeverityCheck
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

#SeverityCheck
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)

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

PackageUse
flutter_bloc (+ equatable)Cubits per 13_State_Management.md
dio + retrofit (or hand-rolled client)API client, envelope parsing (00-shared/07)
intlrelative dates ("2d ago"), publishedAt formatting
cached_network_imageattachment icons/thumbnails (planned)
connectivity_plusoffline banner (10 §8)
No new state lib, no new DI libreuse 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 FeedLoaded in memory (or shared_preferences cache) 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 showModalBottomSheet with isScrollControlled for receipts.
  • Tablet two-pane via LayoutBuilder / NavigationRail pattern (00-shared/05).
  • Dates via intl with locale from app settings; relative time for feed rows.

11. Build order

  1. Models + repository (+ unit tests, mocked Dio).
  2. Feed Cubit + screen (loading/empty/error/offline states).
  3. Detail sheet + read action.
  4. Composer Cubit + form + audience picker (+ validation mirroring DTO).
  5. Receipts sheet (+ unread derivation).
  6. Threads list/detail (secondary, thin).
  7. WebSocket feed-update subscription (planned)IMPLEMENTATION_PLAN.md:119.

12. Test matrix (unit/widget)

CubitKey tests
FeedForMe filter (all/role/grade/custom), error path, refresh reconcile
Detailoptimistic read, 404 snackbar, idempotent double-tap
ComposerDTO-field validation, publish pipeline failure keeps draft, dirty guard
Readsunread derivation, ALL-audience count hide

01 — Product Overview (CRM 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

GoalMeasure
No inquiry lostEvery walk-in/phone/website lead lands in the pipeline with a follow-up date
Fast conversionLead → Student conversion one action after qualification (grade/year/class set)
Pipeline visibilityFunnel stats per status via admissions analytics; leads filterable by status
Accountable follow-upsEvery follow-up has scheduledAt, optional completedAt, and createdBy
Controlled admissionsDecisions 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, AdmissionConverted via EventBus (crm.service.ts:50-63,166-173; admission.service.ts:54-57,159-168,219-223).
  • Queues (planned): admission-reminder, admission-expiry workers (IMPLEMENTATION_PLAN.md §1.3 — reminder for incomplete docs/interview scheduling, auto-reject stale; findStale scan already exists at admission.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 → closedAt auto-set when closedReason is 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:144 puts 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 behind JwtAuthGuard only (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)

TermMeaning
LeadAn inquiry record (new/contacted/qualified/converted/closed)
ConvertPromote lead/admission → User + Student; sets convertedAt
Follow-up{note, scheduledAt, completedAt?, createdBy, createdAt} subdocument
CampaignMarketing source with type/status/dates/metrics
AdmissionFormal application with document + interview + decision stages
Workflow stage{from, to, approver?, comment?, at} — full admission state history
Decidablesubmitted / documents_pending / interview_scheduled / under_review

02 — User Personas (CRM Module)

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, emailcreate-lead.dto.ts:12-27), everything else optional; source defaulted to walk_in/phone per 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.
  • 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.manage if 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: campaign and per-campaign metrics (metrics.leadsGenerated/converted/sent/opened/clicked, campaign.schema.ts:45-52).
  • 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).
  • 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)

PermissionReceptionistCounselorCampaign MgrAdmin
crm.read
crm.lead.manage
crm.campaign.manageoptional
  • Gap: no crm.admission.* / crm.lead.delete / crm.followup.* permission strings exist; admission endpoints share the generic crm.* 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)

End-to-end journeys through the CRM. Steps marked (planned) are in docs/IMPLEMENTATION_PLAN.md but 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)

  1. Parent walks in asking about Grade 6. Priya opens New Lead, picks entry walk_in (source default).
  2. Fills firstName, lastName, email (required), phone, grade interest → submit.
    • Server: duplicate email → 409 inline (crm.service.ts:42-47); else lead created status: new, emits LeadCreated (crm.service.ts:50-63).
  3. Priya hands off: Aditi opens lead detail, sets assignedTo to herself, logs a follow-up "Called; parent interested in June intake" scheduledAt: +2d.
  4. Next day: leads list filtered by her assignment (status filter exists; assignment filter (planned)) → she sees the lead, calls, sets status: contacted.
  5. School visit happens; Aditi sets status: qualified, adds grade/academicYear/class (lead.schema.ts:64-74).
  6. Aditi taps Convert → confirm dialog → server creates User (+ Student, ADM{timestamp} admission number) and marks lead converted with convertedToStudentId (crm.service.ts:151-164), emits LeadConverted (crm.service.ts:166-173).
  7. Lead list: lead now shows converted badge; detail links to student.

2. Journey: Duplicate / already-in-system

  1. Receptionist types an email that already exists → 409 on submit (crm.service.ts:42-47).
  2. 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

  1. Counselor sets status: closed (+ optional closedReason).
  2. Server auto-stamps closedAt when no reason given (crm.service.ts:94-96).
  3. Closed leads remain visible under the status filter (no hard delete anywhere; soft-delete API (planned), base.repository.ts:68-74).
  4. 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

  1. Office submits application (POST /crm/admissions) → status submitted, workflow records draft → submitted (admission.service.ts:39-59), emits AdmissionSubmitted.
  2. Aditi adds required documents (TC/marksheet/certificate/photo — admission.schema.ts:27-33) → status auto-advances to documents_pending (admission.service.ts:112-114).
  3. Aditi schedules interview (datetime, panel staff, mode online/offline, feedback later) → status interview_scheduled (admission.service.ts:118-134).
  4. Panel records feedback; Aditi moves status under_review.
  5. Decision: Approve with comment → status approved, decidedAt set, emits AdmissionApproved (admission.service.ts:136-170). Workflow history now shows approver + comment per hop (admission.schema.ts:52-67).
  6. Admin/Aditi converts → User + Student created, status converted + conversion{studentId, convertedAt} (admission.service.ts:172-225), emits AdmissionConverted.
  7. 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

  1. Decision reject or waitlist from a decidable status (ADMISSION_DECIDABLE_STATUSES, admission.schema.ts:20-25).
  2. Status becomes terminal-immutable: further edits blocked (admission.service.ts:257-269); convert blocked unless approved (admission.service.ts:174-178).
  3. Emits AdmissionRejected (reject only — waitlist emits no event today).

6. Journey: Campaign-driven leads

  1. Kabir creates campaign (name, type, status, dates) — POST /crm/campaigns (crm.controller.ts:83-87).
  2. Website/print leads arrive with source: campaign (lead.schema.ts:58-59).
  3. Kabir views campaign list (crm.controller.ts:77-81) — no per-campaign lead drill-down today; source-based funnel analytics (planned).
  4. 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)

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 need crm.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

RelationEdgeSource
Lead → StudentconvertedToStudentId after convertlead.schema.ts:88-89
Lead → StaffassignedTolead.schema.ts:79-80
Lead → AcademicgradeId/sectionId/academicYearId/classId refslead.schema.ts:64-74
Lead → Campaignimplicit via source: campaign (no FK)lead.schema.ts:58-59
Admission → Studentconversion.studentId after convertadmission.schema.ts:83-89
Admission → Staffinterview.panel[], workflow[].approveradmission.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

ListServer filtersServer sort (fixed)Source
Leadsstatus only (page/limit)createdAt: -1crm.controller.ts:37-45; lead.repository.ts:31
Admissionsstatus only (page/limit)submittedAt: -1crm.controller.ts:103-111; admission.repository.ts:29
Campaignsnone (page/limit)createdAt: -1crm.controller.ts:77-81; campaign.repository.ts:25
  • Gaps: no assignedTo, source, q (search), or sort client 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)

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)

FieldDetail
PurposeWork the lead pipeline by status; open details; create leads
EntryCRM tab; back from detail
SourceGET /crm/leads?page&limit&status (crm.controller.ts:37-45)
Filtersstatus dropdown (server); source + "my leads" chips (client-side, per 04 §6)
Primary CTAFAB New lead (crm.lead.manage)
Rowavatar initials, name, email/phone, source icon, status chip, next follow-up due
Statesloading skeleton; empty ("No leads — create your first"); error(offline/5xx) + retry; 403 permission-gated tab
Pull-to-refreshRefreshIndicator bypasses cache
Sortserver-fixed createdAt desc; client secondary sort (name) (proposed)
Analyticscrm.leads.list, crm.leads.filter(status), crm.leads.open(id)

2. Lead Detail (/crm/leads/:id)

FieldDetail
PurposeFull lead record: identity, status, placement, notes, follow-up timeline, convert
SourceGET /crm/leads/:id (crm.controller.ts:53-57)
Sectionsidentity card · pipeline card (status chip + timestamps) · placement card · follow-up timeline (chronological, lead.schema.ts:24-39) · notes
ActionsEdit (sheet S3), Add follow-up (sheet), Convert to student (S4, gated by rules crm.service.ts:120-125,145-149)
TimelinefollowUps with completedAt checkmark; conversion entry when converted
Statesloading; 404 ("Lead not found"); permission mode read-only
Analyticscrm.lead.open, crm.lead.followup.added, crm.lead.converted

3. Lead Editor — Create / Edit (/crm/leads/new, sheet on detail)

FieldDetail
PurposeCapture (min fields) or update a lead
Sourcescreate: POST /crm/leads (crm.controller.ts:47-51); edit: PATCH /crm/leads/:id (crm.controller.ts:59-63)
Form08_Form_Specifications.md §2 (13 fields, 3 required)
States409 duplicate-email inline (crm.service.ts:42-47); 400 field errors
Motionmodal sheet; error shake; success → detail refresh
Analyticscrm.lead.create.{submit,success,duplicate}, crm.lead.update.{submit,success}

4. Convert Lead Flow (dialog from detail) — S4

FieldDetail
PurposeOne-action promotion of lead → User + Student
APIPOST /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)
Confirmdialog: "Create student account from this lead?" (irreversible)
Resultsuccess → converted badge + link to student; error 400 → inline rule list
Analyticscrm.lead.convert.{submit,success,failed(rule)}

5. Admissions List (/crm/admissions)

FieldDetail
PurposeManage the application funnel by status; funnel stats header
SourceGET /crm/admissions?page&limit&status (crm.controller.ts:103-111)
Header tilescounts + conversion rate from GET /crm/admissions/stats (crm.controller.ts:97-101; admission.service.ts:227-237)
Rowname, email, status badge, submittedAt, pending-documents hint
CTAFAB New application
Statesas S1; stats tile shows its own loading/skeleton
Analyticscrm.admissions.list, crm.admissions.filter(status), crm.admissions.stats.view

6. Admission Detail (/crm/admissions/:id)

FieldDetail
PurposeApplication record: identity, status, documents, interview, workflow history, decision actions
SourceGET /crm/admissions/:id (crm.controller.ts:113-117)
Sectionsidentity 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 modesclosed statuses block edit/decision UI (server also 400s, admission.service.ts:257-269)
Analyticscrm.admission.open, crm.admission.document.added, crm.admission.interview.scheduled, crm.admission.decided, crm.admission.converted

7. Admission Editor — Create (/crm/admissions/new) — S7

FieldDetail
PurposeSubmit an application on behalf of applicant
SourcePOST /crm/admissions (crm.controller.ts:91-95) → status always submitted (admission.service.ts:43)
Form08 §3 (name/email/phone/placement/notes)
Noteno edit route for draft: server sets SUBMITTED directly; updates via PATCH only while open (admission.service.ts:81-93)
Analyticscrm.admission.create.{submit,success}

8. Interview Scheduler (sheet on S6)

FieldDetail
PurposeSet interview slot, panel, mode; optional feedback
SourcePOST /crm/admissions/:id/schedule-interview (crm.controller.ts:134-141) → status interview_scheduled (admission.service.ts:130-132)
InputsscheduledAt*, panel[] (staff multi-pick), mode online/offline, feedback
Analyticscrm.admission.interview.scheduled

9. Decision Dialog (on S6)

FieldDetail
PurposeApprove / Reject / Waitlist with optional comment
SourcePOST /crm/admissions/:id/decision (crm.controller.ts:143-147; admission.service.ts:136-170)
Guardonly from decidable statuses; 400 otherwise (admission.service.ts:141-145)
Confirmreject/waitlist confirm copy ("reversible? no — closed once decided")
Analyticscrm.admission.decided{approve,reject,waitlist}

10. Campaigns List (/crm/campaigns)

FieldDetail
PurposeSee marketing campaigns; create new
SourceGET /crm/campaigns?page&limit (crm.controller.ts:77-81)
Rowname, type icon, status badge, dates, metrics snapshot (leadsGenerated etc.)
CTAFAB New campaign (crm.campaign.manage)
Gapno campaign detail/edit/delete endpoints — row tap is informational-only today
Analyticscrm.campaigns.list, crm.campaign.open

11. Campaign Editor — Create (/crm/campaigns/new) — S11

FieldDetail
PurposeRegister a campaign (name*, description, type, status, start/end date)
SourcePOST /crm/campaigns (crm.controller.ts:83-87; create-campaign.dto.ts:5-34)
Analyticscrm.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)

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

StateUI
initial/loading8 × 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 offlineAppOfflineBanner + cached list (SWR, 00-shared/06 §3.3)
error 5xxAppErrorState(code, message, retry)
permissiontab hidden without crm.read (route guard)

Interactions

  • Status chip tap → server request with status param; chips reflect loading per-tab (keep list, show linear progress).
  • Source / "My leads" chips → client-side filter of current page (no server support — 04 §6 gap; (planned) server filters).
  • Infinite scroll: loadMore() when hasNext (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)

  1. Identity header (sticky): AppAvatar 56 dp, name, email, phone, LeadSourceIcon
    • label, LeadStatusChip.
  2. 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)
  3. 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).
  4. Assignment card: assignedTo staff name or "Unassigned" + assign action (PATCH assignedTo, update-lead.dto.ts:72-75).
  5. Follow-up timeline (module AppTimeline):
    • each entry: note, scheduledAt (formatted), checkmark when completedAt set, "by {createdBy name}" (lead.schema.ts:24-39)
    • overdue entries in color.error; today's in color.primary
    • empty state: "No follow-ups yet"
  6. Notes card: notes or empty.
  7. 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), POST crm.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 per create-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); version increments (base.repository.ts:63).
  • Duplicate email → 409 DUPLICATE_RESOURCE (crm.service.ts:42-47).

Field table (exact, per DTO)

FieldRequiredType/validationDefaultSource
firstNamestringcreate-lead.dto.ts:12-14
middleNamestring:16-19
lastNamestring:21-23
email@IsEmail, lowercased server-side:25-27; lead.schema.ts:52-53
phonestring:29-32
sourceenum website/referral/walk_in/phone/campaign/otherwebsite:34-37; lead.schema.ts:58-59
statusenum new/contacted/qualified/converted/closednew:39-42; lead.schema.ts:61-62
gradeIdMongoId:44-47
sectionIdMongoId:49-52
academicYearIdMongoId:54-57
classIdMongoId:59-62
notesstring (multiline):64-67
assignedToMongoId (staff):69-72

Behaviors

  • Create: status hidden by default (server defaults new); exposed under "Advanced" — admission staff may pre-set contacted/qualified.
  • Status rules (mirror server): setting converted via form is blocked — must use convert flow (server guards, crm.service.ts:120-125); setting closed without closedReason → client shows reason field (server auto-stamps closedAt, 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); emits LeadConverted (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)

  1. Identity header: name, email, phone, StatusBadge.
  2. Pipeline card: status, submittedAt, decidedAt (admission.schema.ts:136-143).
  3. Placement card: grade/academicYear/class (resolved).
  4. Documents card: rows of DocumentCard (type chip — tc/marksheet/certificate/ photo/other, admission.schema.ts:27-33 — filename, uploadedBy, uploadedAt) + Add document action (crm.lead.manage).
  5. Interview card: scheduledAt, mode (online/offline), panel names, feedback (admission.schema.ts:69-81); Schedule action when none / editable.
  6. 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.
  7. Conversion card (when converted): conversion.studentId + convertedAt (admission.schema.ts:83-89) + student link.
  8. Notes card.
  9. 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."

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 via Edit action; 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)

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)

PropValues
statusnew|contacted|qualified|converted|closed (lead.schema.ts:7-13)
StatusColor (semantic)IconLabel key
newcolor.secondaryContainerinboxcrm.status.new
contactedcolor.primaryContainercallcrm.status.contacted
qualifiedcolor.tertiaryContainerverifiedcrm.status.qualified
convertedsuccess containercheck_circlecrm.status.converted
closedsurface-variantblockcrm.status.closed
  • Icon + text, never color-only (00-shared/09).
  • Disabled variant renders grayed + reduced opacity for non-actionable chips.

2. LeadSourceIcon

sourceiconlabel key
websitelanguagecrm.source.website
referralgroupcrm.source.referral
walk_instorefrontcrm.source.walk_in
phonephonecrm.source.phone
campaigncampaigncrm.source.campaign
othermore_horizcrm.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)

statuscolorlabel key
draft / submittedsecondaryContainercrm.adm.status.draft|submitted
documents_pendingtertiaryContainercrm.adm.status.documents_pending
interview_scheduledprimaryContainercrm.adm.status.interview_scheduled
under_reviewsecondaryContainercrm.adm.status.under_review
approvedsuccess containercrm.adm.status.approved
rejectederror containercrm.adm.status.rejected
waitlistedsurface-variantcrm.adm.status.waitlisted
convertedsuccess container + checkcrm.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)

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_ERROR details map field → message under 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

FieldReqControlValidation (server)DefaultSource
firstNametext@IsStringcreate-lead.dto.ts:12-14
middleNametextoptional stringcreate-lead.dto.ts:16-19
lastNametext@IsStringcreate-lead.dto.ts:21-23
emailtext (.emailAddress, autofill email)@IsEmail; stored lowercase (lead.schema.ts:52-53)create-lead.dto.ts:25-27
phonetext (.phone, autofill tel)optional stringcreate-lead.dto.ts:29-32
sourcedropdownenum 6 valueswebsite (lead.schema.ts:58)create-lead.dto.ts:34-37
statusdropdown (Advanced)enum 5 valuesnew (lead.schema.ts:61)create-lead.dto.ts:39-42
gradeIddropdown@IsMongoIdcreate-lead.dto.ts:44-47
sectionIddropdown@IsMongoIdcreate-lead.dto.ts:49-52
academicYearIddropdown@IsMongoIdcreate-lead.dto.ts:54-57
classIddropdown@IsMongoIdcreate-lead.dto.ts:59-62
notesmultilineoptional stringcreate-lead.dto.ts:64-67
assignedTostaff dropdown@IsMongoIdcreate-lead.dto.ts:69-72

Edit-only fields: closedReason (string, update-lead.dto.ts:77-80).

Conditional logic

  • status = closed → reveal closedReason (client); server auto-stamps closedAt when 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

FieldReqControlValidation (server)Source
firstNametext@IsStringcreate-admission.dto.ts:6-8
middleNametextoptionalcreate-admission.dto.ts:10-12
lastNametext@IsStringcreate-admission.dto.ts:14-16
emailtext .emailAddress@IsEmail; stored lowercase (admission.schema.ts:102-103)create-admission.dto.ts:18-20
phonetext .phoneoptional stringcreate-admission.dto.ts:22-25
gradeIddropdown@IsMongoIdcreate-admission.dto.ts:27-30
academicYearIddropdown@IsMongoIdcreate-admission.dto.ts:32-35
classIddropdown@IsMongoIdcreate-admission.dto.ts:37-40
notesmultilineoptional stringcreate-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 status field — server always creates submitted (admission.service.ts:43).

4. Follow-Up Form (S2 sheet) — POST /crm/leads/:id/follow-ups

FieldReqControlValidationSource
notemultiline@IsStringcreate-follow-up.dto.ts:5-7
scheduledAtdatetime picker@IsDateString (ISO)create-follow-up.dto.ts:9-11
completedAttoggle + datetime@IsDateString optionalcreate-follow-up.dto.ts:13-16
  • Display uses followUps[] (lead.schema.ts:24-39); createdBy set server-side from token (crm.service.ts:111).

5. Campaign Form (S11) — POST /crm/campaigns

FieldReqControlValidationDefaultSource
nametext@IsStringcreate-campaign.dto.ts:5-8
descriptionmultilineoptionalcreate-campaign.dto.ts:10-13
typedropdownenum 5email (campaign.schema.ts:30)create-campaign.dto.ts:15-18
statusdropdownenum 4draft (campaign.schema.ts:33)create-campaign.dto.ts:20-23
startDatedate picker@IsDateStringcreate-campaign.dto.ts:25-28
endDatedate picker@IsDateStringcreate-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

FieldReqControlValidationSource
typedropdownenum tc/marksheet/certificate/photo/othercreate-admission-document.dto.ts:5-8; admission.schema.ts:27-33
fileIdfile pick → storage upload (storage provider; upload endpoint (planned))@IsStringcreate-admission-document.dto.ts:10-12
filenameauto from fileoptional 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

FieldReqControlValidationDefaultSource
scheduledAtdatetime picker@IsDateStringschedule-interview.dto.ts:11-14
panelstaff multi-pick chipsarray of @IsMongoIdschedule-interview.dto.ts:16-20
modesegmented online/offlineenumoffline (admission.schema.ts:76-78)schedule-interview.dto.ts:22-25
feedbackmultilineoptional stringschedule-interview.dto.ts:27-30

8. Decision Form (S9 dialog) — POST /crm/admissions/:id/decision

FieldReqControlValidationSource
decisionradio/segmentedenum approve/reject/waitlistadmission-decision.dto.ts:4-8,10-13
commentmultilineoptional stringadmission-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, tel autofill hints; labels linked; group headers (Applicant / Contact / Placement / Assignment); first-invalid focus; 409 banner in live region.

09 — User Behaviour (CRM Module)

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)

FromToRuleSource
anyconvertedvia Convert action only; blocked if already converted or closedcrm.service.ts:120-125
anyconvertedgrade + academic year + class must be setcrm.service.ts:145-149
anyclosedallowed; closedAt auto-stamp when no reasoncrm.service.ts:94-96
closedanythingnot 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

SituationUser expectationContract
Duplicate lead emailClear "already exists" + path forward409 (crm.service.ts:42-47)
Convert preconditions missingKnow which field to fix400 exact message (crm.service.ts:145-149)
Convert already-converted/closedAction disabled with reason400 (crm.service.ts:120-125)
Edit closed admissionForm locked, banner explains400 (admission.service.ts:257-269)
Decision from wrong statusDialog not offered400 (admission.service.ts:141-145)
Cross-tenant id"Not found" — never existence leak404 (base.repository.ts:20-30)
OfflineBanner + cached list + no writes (CRM has no offline write queue; writes require connectivity)00-shared/07 §10
Rate limitCountdown, no auto-retry429 (00-shared/07 §4)

7. Sorting & scanning expectations

  • Default order: newest first (leads/campaigns by createdAt, admissions by submittedAtlead.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 (server sort param (planned)).

8. Confirmation thresholds

ActionConfirm?Rationale
Create lead/admission/campaignNolow cost, reversible via edit
Update lead fieldsNo (inline save)cheap
Status → closedSoft: reason field, no modalreversible-in-record, but terminal-ish
Convert lead/admissionYes (modal)creates User + Student records, irreversible (crm.service.ts:118-176)
Decision approve/reject/waitlistYes (modal)terminal + immutable (admission.service.ts:136-170)
Add follow-upNocheap, append-only

9. Habit loops

  1. Daily queue: open Leads → status filter → work overdue-first → log follow-up → next contact.
  2. Weekly funnel check: Admissions → stats header (conversion rate) → status counts → chase stale applications (reminder worker (planned) automates).
  3. 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)

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

RouteScreenGuard
/crmshell + leads tab (S1)authGuard + permissionGuard('crm.read')
/crm/leadsS1crm.read
/crm/leads/newS3 createcrm.lead.manage
/crm/leads/:idS2crm.read
/crm/leads/:id/editS3 edit (sheet)crm.lead.manage
/crm/admissionsS5crm.read
/crm/admissions/newS7crm.lead.manage
/crm/admissions/:idS6crm.read
/crm/campaignsS10crm.read
/crm/campaigns/newS11crm.campaign.manage
  • Guards evaluate client-side for UI; server enforcement (planned) (permissions exist at permissions.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: loadMore when hasNext (mixin 00-shared/06 §3.2); row tap → detail push (phone) / detail pane (tablet).
  • Row press feedback: m-fast ripple; 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 closedReason inline field; save without reason auto-stamps closedAt server-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 document sheet (08 §6); after success, status badge may flip submitted → documents_pending (admission.service.ts:112-114) — refresh detail; snackbar notes the transition.
  • Interview: Schedule sheet (08 §7); re-schedule allowed while open (overwrites, admission.service.ts:124-129); after save, status → interview_scheduled when 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

TransitionTimingNotes
Row tap → detailm-base push (phone) / fade pane (tablet)
Sheet/dialogm-base slide/fade
Status chip changem-fast color/icon swapno bounce
Timeline entry addslide-in m-base + 40 ms stagger
Error shakem-fast horizontal 2×6 pxforms only
Success check (S4)m-base scale-in check
Skeleton → contentfade m-baseno 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): subscribe crm.lead.updated / crm.admission.updated topics when gateway adds them.
  • Detail views: no client cache (always fetch, 00-shared/06 §3.3).

8. Optimistic vs pessimistic summary

MutationPolicyReason
PATCH lead fields/status/assignedTooptimistic + rollbackcheap, reversible (00-shared/06 §3.5)
Add follow-uppessimisticappend + server createdBy stamp
Create lead/admission/campaignpessimistic409/400 paths need server truth
Convert lead/admissionpessimisticside effects (User+Student)
Admission decisionpessimisticterminal side effects
Document/interviewpessimisticworkflow 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)

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):

StatusRole colorContainerToken intent
newonSecondaryContainersecondaryContainerneutral/awaiting
contactedonPrimaryContainerprimaryContainerin-progress
qualifiedonTertiaryContainertertiaryContainerwarm/positive
convertedonSuccessContainersuccessContainerterminal-success
closedonSurfaceVariantsurfaceVariantterminal-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 + labelSmall timestamps.

3. Shared components → module mapping

Shared (00-shared/03)Module usage
AppCardpipeline/placement/documents/interview/notes cards (S2/S6)
AppListTileleads/admissions rows (wrapped in module tiles 07 §3,7)
AppChipsfilter rows (07 §13), panel/mode chips, source chips
AppBadgestatus badges (07 §1,6)
AppAvataridentity (40/56 dp), initials from name
AppTimelinefollow-up timeline (S2), workflow timeline (S6, read-only)
AppDialogConvert (S4), Decision (S9)
AppBottomSheetforms (S3, follow-up, document, interview, campaign)
AppDatePickerscheduledAt, dates (datetime variant for follow-up/interview)
AppSegmentedButtondecision choice, interview mode
AppSkeleton / AppEmptyState / AppErrorState / AppOfflineBannerall list/detail states
AppFABcreate actions (gated by permission)
AppTextFieldall 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)

StateIllustrationCopy
No leadsinbox"No leads yet — create your first lead"
No leads in filterfilter_alt_off"No leads in this status" + Clear filter
No admissionsdescription"No applications yet"
No campaignscampaign"No campaigns yet"
Lead 404search_off"Lead not found"
Offlinecloud_offstandard AppOfflineBanner + cached list
Permissionlock"Ask your admin for CRM access" (when crm.read missing)

6. Accessibility tokens

  • Overdue text: color.error at ≥ 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)

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 at permissions.constants.ts:34-36; enforcement (planned), IMPLEMENTATION_PLAN.md §5.1).


1. Endpoint table

Leads

#MethodPathScreenParams/BodyPermSource
1GET/crm/leadsS1page (1), limit (20), status (enum)crm.readcrm.controller.ts:37-45
2POST/crm/leadsS3CreateLeadDtocrm.lead.managecrm.controller.ts:47-51
3GET/crm/leads/:idS2crm.readcrm.controller.ts:53-57
4PATCH/crm/leads/:idS3/S2UpdateLeadDto (partial)crm.lead.managecrm.controller.ts:59-63
5POST/crm/leads/:id/follow-upsS2CreateFollowUpDtocrm.lead.managecrm.controller.ts:65-69
6POST/crm/leads/:id/convertS4crm.lead.managecrm.controller.ts:71-75

Campaigns

#MethodPathScreenParams/BodyPermSource
7GET/crm/campaignsS10page, limitcrm.readcrm.controller.ts:77-81
8POST/crm/campaignsS11CreateCampaignDtocrm.campaign.managecrm.controller.ts:83-87

Admissions

#MethodPathScreenParams/BodyPermSource
9POST/crm/admissionsS7CreateAdmissionDtocrm.lead.managecrm.controller.ts:91-95
10GET/crm/admissions/statsS5crm.readcrm.controller.ts:97-101
11GET/crm/admissionsS5page, limit, statuscrm.readcrm.controller.ts:103-111
12GET/crm/admissions/:idS6crm.readcrm.controller.ts:113-117
13PATCH/crm/admissions/:idS6UpdateAdmissionDto (partial)crm.lead.managecrm.controller.ts:119-123
14POST/crm/admissions/:id/documentsS6CreateAdmissionDocumentDtocrm.lead.managecrm.controller.ts:125-132
15POST/crm/admissions/:id/schedule-interviewS6/S8ScheduleInterviewDtocrm.lead.managecrm.controller.ts:134-141
16POST/crm/admissions/:id/decisionS6/S9AdmissionDecisionDtocrm.lead.managecrm.controller.ts:143-147
17POST/crm/admissions/:id/convertS6crm.lead.managecrm.controller.ts:149-153

2. Response shapes

  • List endpoints (1, 7, 11): envelope data: Lead[]/Campaign[]/Admission[] + meta: {page, limit, totalItems, totalPages, hasNext, hasPrevious} via buildPaginationMeta (crm.service.ts:75,79,191,193; admission.service.ts:68-72).
  • Detail (3, 12) & mutations: data = document, no meta.
  • Stats (10): data: {new?: 0, submitted?: n, ..., total, conversionRate: x.x} (admission.service.ts:227-237); counts keyed by AdmissionStatus enum.

3. Sort & filter contract (exact)

ListServer sort (fixed)Filters supportedSource
LeadscreatedAt: -1status onlylead.repository.ts:24-36 (sort at :31)
AdmissionssubmittedAt: -1status onlyadmission.repository.ts:25-35 (sort at :29)
CampaignscreatedAt: -1nonecampaign.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

RuleCodeMessage (verbatim)Source
Duplicate lead email409Lead with email "{email}" already exists.crm.service.ts:42-47
Lead not found404Lead not found.crm.service.ts:82-86,98
Convert already-converted400Lead is already converted.crm.service.ts:120-122
Convert closed lead400Cannot convert a closed lead.crm.service.ts:123-125
Convert missing placement400Lead must have grade, academic year, and class assigned for conversion.crm.service.ts:145-149
Admission not found404Admission not found.admission.service.ts:75-79
Decide from wrong status400Admission in status "{status}" cannot be decided.admission.service.ts:141-145
Edit closed admission400Admission in status "{status}" is not editable.admission.service.ts:257-269
Convert non-approved400Only approved admissions can be converted.admission.service.ts:174-178
Convert missing placement400Admission 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))

EventEmitted atPayload keysSource
LeadCreatedPOST leadsleadId, firstName, lastName, email, sourcecrm.service.ts:50-63
LeadConvertedPOST leads/:id/convertleadId, studentId, userIdcrm.service.ts:166-173
AdmissionSubmittedPOST admissionsadmissionId, emailadmission.service.ts:54-57
AdmissionApproveddecision approveadmissionId, emailadmission.service.ts:158-162
AdmissionRejecteddecision rejectadmissionId, emailadmission.service.ts:163-167
AdmissionConvertedPOST admissions/:id/convertadmissionId, studentIdadmission.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)

GapImpactNotes
No lead search/qcan't find by nameglobal search incl. leads (planned) (IMPLEMENTATION_PLAN.md §3)
No assignedTo/source list filters"my leads" is client-sideserver filters (planned)
No nextContactDate fieldfollow-up due derived from followUps[] client-sideadd field or dedicated endpoint (proposed)
No lead/admission DELETEno hard/soft delete in UIBaseRepository.softDelete exists (base.repository.ts:68-74); endpoint (planned)
No campaign detail/PATCHcampaigns read-only after create(planned)
No campaign metrics writemetrics schema-only(planned)
No document download/upload endpointsfileId opaquestorage wiring (planned)
No admission assignedTono owner for applications(proposed)
Admission workers (reminder/expiry)stale apps not auto-handledqueues named in 00-shared/01 §6; findStale ready (admission.repository.ts:53-62)
RBAC decoratorsall routes = any authed userenforcement (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)

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

CubitScreen(s)State
LeadsCubitS1page + status filter + client-side source/mine filters + rows
LeadDetailCubitS2full lead + timeline + mutation flags
LeadFormCubitS3create/edit form, 409 handling
FollowUpCubitS2 sheetfollow-up form lifecycle
ConvertCubitS4pre-check + in-flight + result
AdmissionsCubitS5page + status filter + stats tiles
AdmissionDetailCubitS6admission + workflow + mutation flags
AdmissionFormCubitS7create form
InterviewCubitS8schedule form lifecycle
DecisionCubitS9decision form lifecycle
CampaignsCubitS10paged list
CampaignFormCubitS11create 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 fixed createdAt: -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 = min scheduledAt over followUps without completedAt (lead.schema.ts:28-31); overdue flag. Pure function, unit-tested; cache key crm.leads:{status} (SWR, 5 min TTL — volatile; RefreshIndicator bypasses).
  • Realtime: none — no WS topic; refetch on foreground resume ((forward-looking) topic crm.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 closed without reason → local closedReason prompt; server stamps closedAt (crm.service.ts:94-96) — reconcile from response.
  • FollowUpAdded → refresh detail (pessimistic; response authoritative, carries createdBy).
  • On 401 → sessionExpired flow (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): state conflict → inline banner; action "View existing" → lookup (planned) (no email-search endpoint; today: edit fields). Double-submit guarded.
  • 400 VALIDATION_ERROR → map details[].field → fieldErrors.

5. FollowUpCubit (S2 sheet)

  • POST crm.controller.ts:65-69; pessimistic; success → emit FollowUpAdded → detail refetch; validation per 08 §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); sort submittedAt: -1 (admission.repository.ts:29).
  • Stats: GET /crm/admissions/stats (crm.controller.ts:97-101) — independent statsState (failure → tiles "—", list still usable).
  • Refresh refetches 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.read gates tabs/routes; crm.lead.manage gates all write actions; crm.campaign.manage gates 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)

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.com vs a@x.com collide (lead.schema.ts:52-53 lowercase; lead.repository.ts:19 lowercases 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-100 only merges fields); flag + track.

2. Status validation & transitions

  • Status accepts only new|contacted|qualified|converted|closed (lead.schema.ts:7-13); invalid value → 400 VALIDATION_ERROR (@IsEnum, update-lead.dto.ts:42-45).
  • Setting status: closed without closedReasonclosedAt auto-stamped (crm.service.ts:94-96); with reason → closedAt still stamped, reason stored.
  • Convert on converted lead → 400 Lead is already converted. (crm.service.ts:120-122); on closed → 400 Cannot 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, convertedToStudentId set (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; decidedAt set; 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 assignedTo accepts MongoId (update-lead.dto.ts:72-75); invalid → 400.
  • List filter by assignedTo does not exist server-side (only statuscrm.controller.ts:37-45): "my leads" is client-side; verify no misleading server claims (flag as gap).
  • assignedTo is Staff ref (lead.schema.ts:79-80); display names resolve from staff module (cross-module read (planned)).
  • Follow-up createdBy is 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: false in 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); metadata survives 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).
  • completedAt optional; uncompleted follow-ups drive the derived "next follow-up due" (client logic — unit test followUpSummary).
  • 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.
  • filename defaults to fileId server-side when absent (admission.service.ts:107).
  • First document on submitted admission → status auto-advances to documents_pending (admission.service.ts:112-114); on other open statuses → status unchanged.
  • Scheduling interview sets interview{...} + status interview_scheduled when different (admission.service.ts:124-132); re-schedule overwrites cleanly.
  • Interview mode restricted online|offline (schedule-interview.dto.ts:22-25; default offline).
  • 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); admissions submittedAt: -1 (admission.repository.ts:29); campaigns createdAt: -1 (campaign.repository.ts:25) — verify row order client-side.
  • limit bounds (default 20; 1–100 per 00-shared/07 §5): limit=200 behavior verified (server default cap).
  • meta = {page, limit, totalItems, totalPages, hasNext, hasPrevious} (buildPaginationMeta); infinite scroll stops at hasNext=false.
  • Invalid status query value → 400 (typed LeadStatus/AdmissionStatus params, crm.controller.ts:42,108).
  • Stats endpoint sums match list counts per status (admission.service.ts:227-237); conversionRate = converted/total × 100, 1 decimal, 0 when empty.

8. Events & workers

  • Create lead emits LeadCreated with leadId/name/email/source (crm.service.ts:50-63); convert emits LeadConverted with leadId/studentId/userId (:166-173).
  • Admission emits AdmissionSubmitted|Approved|Rejected|Converted at 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, verify findStale global 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/stats correctness: no per-status leak beyond tenant scope.

10. Performance & offline

  • Leads list (20 rows) first frame from cache < 300 ms; ListView.builder mandatory (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 Intl tenant locale; wire format YYYY-MM-DD/ISO-8601 independent of display; RTL-safe rows.

13. Performance budgets (module)

CheckBudget
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)

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.
  • DateTime from ISO strings; refs stay String ObjectIds (display resolved via other modules' repositories).

3. Repositories (via AppDio)

RepoMethods → endpoint
LeadRepositorylist({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
AdmissionRepositorylist({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)
CampaignRepositorylist({page, limit}), create(dto)
  • All map envelope → data + meta (pagination mixin, 00-shared/06 §3.2); errors surface as ApiException(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 uncompleted scheduledAt (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

RoutePageGuard
/crm/crm/leadsLeadListPagecrm.read
/crm/leads/newLeadFormSheetcrm.lead.manage
/crm/leads/:idLeadDetailPagecrm.read
/crm/admissionsAdmissionsPagecrm.read
/crm/admissions/newAdmissionFormPagecrm.lead.manage
/crm/admissions/:idAdmissionDetailPagecrm.read
/crm/campaignsCampaignsPagecrm.read
/crm/campaigns/newCampaignFormSheetcrm.campaign.manage
  • Permission gate: permissionGuard('crm.read') etc. (strings from permissions.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

  1. Enums/models + DTO mappers + repositories (contract-locked to 12 §1).
  2. LeadsCubit + S1 (list/filter/pagination).
  3. S2 detail + S3 form + 409 handling.
  4. Convert flow (rules + dialog) — server-rule parity tests.
  5. Admissions slice (S5-S9) + stats tiles.
  6. Campaigns slice (S10/S11).
  7. Permissions gating + deep links + i18n pass + performance profile.

01 — Product Overview (Dashboard Module)

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/**, and docs/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

CapabilityCode todaySource
GET /api/v1/dashboard/overviewImplementeddashboard.controller.ts:13-17
KPI aggregation (students/teachers/staff/attendance/finance)Implementeddashboard.service.ts:18-71
dashboard.read, dashboard.widget.manage permissionsDefined, not enforced on the routepermissions.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 requestdashboard.service.ts:24-38; no cache calls
GET /attendance, GET /finance, GET /widgets, PATCH /widgets/:id(planned) — blueprint onlyDashboard.md:24-27
Cache rebuild worker + event invalidation(planned) — no worker, no event handlerDashboard.md:50, Dashboard.md:34
Widget config collection dashboard_widgets(planned) — no schema in codeDashboard.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 scopeOut of scope (owned elsewhere)
KPI aggregation: students, teachers, staff, attendance, financeRaw 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} — the RedisCacheService prefixes every key with the current tenant automatically (redis-cache.service.ts:16-19), falling back to platform (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:23permissions: 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 in design-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)

  1. 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.
  2. Numbers stay fresh without hammering MongoDB — 60 s TTL + event-driven invalidation (blueprint Dashboard.md:49-50).
  3. Role-appropriate visibility — each role sees only the widgets its permissions allow (blueprint Dashboard.md:52).
  4. 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)

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

FieldDetail
ContextRuns 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 todayFull 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"
BehaviourOpens 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 pointsNumbers 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)
Analyticsdashboard.overview.open, dashboard.kpi.tap (proposed)

P2 — Ravi, Accountant (accountant)

FieldDetail
ContextOwns fee collection and reconciliation. Default role permissions: fees.collect, student.read (role.schema.ts:47) — no dashboard.read
Access todayNo 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 needFinance widget: pendingInvoices count + totalDueAmount (dashboard.service.ts:66-69); drill to Fees module
Notedashboard.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)

FieldDetail
ContextTeaches classes; marks attendance. Default permissions: student.read, attendance.mark, attendance.edit (role.schema.ts:31) — no dashboard permission
Access todayNo dashboard. Teacher view (planned) (IMPLEMENTATION_PLAN.md:232)
Derived needPersonal 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
BehaviourWould 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)

FieldDetail
ContextTrack child's attendance/dues. Parent: student.read (role.schema.ts:55); student: no permissions (role.schema.ts:63)
Access todayNo dashboard
Derived needChild-scoped attendance %, fee balance, notices. (planned) per IMPLEMENTATION_PLAN.md:232; child-scoping has no server support today — flag in QA (unknown ownership)
NoteStudent-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)

FieldDetail
ContextCross-tenant super admin (role.schema.ts:10-16)
Access todayNot a per-tenant dashboard user; permissions list is empty :15 — platform-level rollups (planned), not defined in blueprint
NoteOut of client scope for Phase 1

Permission → widget matrix (derived)

WidgetData source (service)Needed permissionDefault roles with it
Students/Teachers/Staff countsdashboard.service.ts:31-33dashboard.read (permissions.constants.ts:37)org_admin only (role.schema.ts:23)
Attendance today:34,:54-65dashboard.readorg_admin only
Finance: pending invoices + dues:35-37,:45-48,:66-69dashboard.read + fees.collect-class read (planned)org_admin; accountant has fees.collect but not dashboard.read (role.schema.ts:47)
Widget customizationdashboard.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)

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

StepActor actionSystem / screenSource
1Opens app → lands on Dashboard HomeSession valid; OverviewCubit loads12_API_Mapping.md E1
2(implicit)GET /api/v1/dashboard/overview → skeleton → KPI cardsdashboard.controller.ts:13-17
3Reads KPI row: students 812, teachers 34, staff 21, attendance 93%, dues ₹4.2LNumbers + trend (planned) + stale badgedashboard.service.ts:50-70
4Sees attendance 93% (summary present: …)Taps attendance card → Attendance module (daily view)link only; attendance data owned by Attendance module
5Notices pending invoices countTaps finance card → Fees module invoices list (filtered issued/partial/overduedashboard.service.ts:36-37)link only
6Pulls-to-refresh mid-morningAppRefreshIndicator → cache-bypassing refetch10_Interaction_Specification.md §2
7Leaves tab open; worker marks attendance elsewhereWS event AttendanceMarked (Dashboard.md:34) arrives on tenant:{id} room (ws.gateway.ts:50) → cubit invalidates overview → silent refresh13_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))

StepActor actionSystem / screen
1Opens dashboard, reads finance.pendingInvoices + totalDueAmountKPI cards (dashboard.service.ts:66-69)
2Clicks finance cardFees module (list) — filter mirrors service statuses issued/partial/overdue
3Returns → dashboard refreshes on focus (didChangeDependencies re-fetch)cache TTL 60 s (Dashboard.md:41)
4(Accountant persona) (planned): dashboard shows finance-only layoutneeds dashboard.read on accountant role (02_User_Personas.md P2)

J3 — Attention-driven refresh (live events)

StepActor actionSystem / screen
1Dashboard open while cashier records a paymentPaymentRecorded/InvoiceGenerated broadcast (Dashboard.md:34; ws-bridge.service.ts:16-22)
2Cubit receives event → invalidates overview key → refetches13_State_Management.md §5
3Finance card updates without manual pullin-place animation Mo-m-base

J4 — Widget customization (planned)

StepActor actionSystem / screen
1Opens "Customize" (only if permission dashboard.widget.managepermissions.constants.ts:38)Widget palette sheet (planned)
2Toggles attendance chart off, reorders finance cardGET /dashboard/widgets + PATCH /dashboard/widgets/:id (planned) (Dashboard.md:26-27)
3Saves → overview refetches with new ordercache invalidated server-side (planned)
4(No permission)"Customize" hidden — never a 403 dead-end (00-shared/06 §5)

J5 — Empty / first day of tenancy

StepActor actionSystem / screen
1Fresh tenant, no datacounts 0, attendance rate 0 (dashboard.service.ts:64 returns 0 when no records), no invoices
2Screen shows zero-state cards + AppEmptyState hint "Invite your first student"06_Screen_Specifications.md §4

J6 — Offline / degraded

StepActor actionSystem / screen
1No network on openAppOfflineBanner + last-good cached KPI payload rendered (TTL-flagged)
2Tap retrybanner 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)

  1. Cache staleness beyond 60 s (no worker yet — live scans today; 01_Product_Overview.md §3 gap).
  2. WS event storms on tenant:{id} room (all events broadcast — ws-bridge.service.ts:16-22) → debounce refetch.
  3. Role without dashboard.read sees nothing → (planned) role views.

04 — Information Architecture (Dashboard Module)

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

RouteScreenSourceStatus
/dashboardDashboard Home (KPI row + chart grid + widget grid)dashboard.controller.ts:13-17Implemented (overview only)
/dashboard/attendanceAttendance trend detail (planned)Dashboard.md:24(planned)
/dashboard/financeFinance trend detail (planned)Dashboard.md:25(planned)
/dashboard/customizeWidget customization (planned)Dashboard.md:26-27; permissions.constants.ts:38(planned)
drill-through/students, /fees/invoices, /attendance (module screens)external linksImplemented

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 in dashboard_widgets (Dashboard.md:7) once GET/PATCH /widgets exist (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) (blueprint Dashboard.md:52; see 02_User_Personas.md matrix).
  • Today the effective matrix is "org_admin sees everything; everyone else sees nothing" (role.schema.ts:23 vs :31,:39,:47,:55,:63) — role-scoped IA (planned) per IMPLEMENTATION_PLAN.md:232.

6. Naming / taxonomy

TermMeaning
KPI cardNumber + label + trend (planned) + optional drill link
TrendΔ vs previous period — no server support (planned)
WidgetA dashboard tile (KPI card or chart)
Stale badgePayload older than TTL 60 s rendered from client cache (Dashboard.md:41)

05 — Screen Inventory (Dashboard Module)

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)

FieldDetail
PurposeRole-filtered operational KPIs at a glance, from one cached endpoint
EntryPost-login landing (roles with dashboard.read); any app navigation
Exitdrill-through to Students/Fees/Attendance; (planned) customize
SourceGET /api/v1/dashboard/overview (dashboard.controller.ts:13-17) → dashboard.service.ts:50-70 payload
CompositionKPI row (students/teachers/staff counts + attendance % + finance dues) (planned) chart blocks; (planned) widget grid
Permissiondashboard.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
Statesloading (skeletons), success, empty (zero counts — dashboard.service.ts:64 rate 0 path), error (offline/5xx with retry), stale (TTL badge)
RealtimeWS tenant:{id} room events (ws.gateway.ts:50; ws-bridge.service.ts:16-22) → invalidate + refetch for the 4 KPI events (Dashboard.md:34)
Analyticsdashboard.overview.open, dashboard.kpi.tap, dashboard.refresh (proposed)

2. Attendance Detail (/dashboard/attendance) (planned)

FieldDetail
PurposeAttendance rate trend over a period
SourceGET /api/v1/dashboard/attendance (Dashboard.md:24) — no controller today
CompositionPeriod selector (planned); line/bar chart (AppCharts, 00-shared/03); today's summary (dashboard.service.ts:40-43)
Permissiondashboard.read; teacher-scoped variant (planned) (IMPLEMENTATION_PLAN.md:232)
Statesas §1; empty = "no attendance yet" (rate 0 path :64)

3. Finance Detail (/dashboard/finance) (planned)

FieldDetail
PurposePending invoices + dues trend
SourceGET /api/v1/dashboard/finance (Dashboard.md:25)
Compositiondues number, pending-invoice count, collection trend chart; drill to /fees/invoices
Permissiondashboard.read; accountant role (planned) (has fees.collect role.schema.ts:47, lacks dashboard.read)
Statesas §1

4. Widget Customization (/dashboard/customize) (planned)

FieldDetail
PurposeAdmin reorders/toggles widgets per tenant
SourceGET /api/v1/dashboard/widgets, PATCH /api/v1/dashboard/widgets/:id (Dashboard.md:26-27); persistence in dashboard_widgets (Dashboard.md:7)
Permissiondashboard.widget.manage (permissions.constants.ts:38) — server API absent, entry hidden until it ships
Compositionwidget palette (reorder handles, visibility toggles, role chips (planned))
Statesas §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)

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 cite dashboard.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.managepermissions.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):

CardValueSource lineFormat
Studentsstudents.total:31 (count active)integer, NumberFormat
Teachersteachers.total:32integer
Staffstaff.total:33integer
Attendanceattendance.rate:57-64% (server pre-rounds)
Duesfinance.totalDueAmount:45-48,:68currency, locale INR
Pending invoicesfinance.pendingInvoices:36-37,:67integer 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, shimmer Mo.m-fast); charts as AppSkeleton(chart) blocks. No partial numbers.
  • success: cards fade/slide in Mo-m-base staggered (30 ms apart); charts animate draw Mo-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 single AppEmptyState hero ("Your dashboard is ready — add your first data") for the all-zero case; never hide a 0 behind an error.
  • error (offline): AppOfflineBanner top + last-good payload rendered with StaleBanner ("Showing data from X min ago"); retry button.
  • error (5xx): first-load → AppErrorState.code (INTERNAL) + Retry; with previous data → inline AppSnackbar + 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

MechanismBehaviour
Pull-to-refreshAppRefreshIndicator → cubit refresh()bypasses client cache (force network, 00-shared/06 §3.3)
Focus returnrefetch when app resumes or route returns to dashboard (staleness check first)
Pollingbackground poll every 60 s while dashboard is the active tab and app is online; cancelled on dispose (13_State_Management.md §4)
WS invalidationon 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 guardsingle 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 semanticsLabel describing the data ("Attendance 93% today, 812 of 874 present") + a bodySmall caption text row (screen-reader-safe alternative, 00-shared/09).
  • Empty chart: dashed-line placeholder + "No data for this period".
  • PeriodSelector (planned): SegmentedButton Today/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, value in 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 use primary/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-base slide+fade; skeleton shimmer Mo-m-fast; WS-updated values: AnimatedSwitcher 200 ms; stale banner slide-in.

2. Attendance Detail /dashboard/attendance (planned)

ConcernSpecification
ChromeAppBar "Attendance", back; body as §1
HeaderheadlineMedium today's rate + summary chips (present/absent/… counts — dashboard.service.ts:40-43)
ChartChartCard period trend from GET /dashboard/attendance (Dashboard.md:24); PeriodSelector enabled (server param (planned))
Statesas §1; empty → "No attendance recorded yet" (rate-0 path :64)
A11ychart 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)

ConcernSpecification
ChromeAppBar "Finance", back
Headerdues headlineMedium + pendingInvoices count + overdue chip (:36-37)
Chartcollection trend from GET /dashboard/finance (Dashboard.md:25)
Drill"View invoices" → /fees/invoices (filters mirror status in issued/partial/overduedashboard.service.ts:36-37)
Statesas §1; empty → "No pending invoices — all settled"
A11yas §1.5

4. Widget Customization /dashboard/customize (planned)

ConcernSpecification
EntryAppBar IconButton on Home, rendered only with dashboard.widget.manage (permissions.constants.ts:38)
Palettelist of widget cards with ReorderableListView handles + Switch visibility + role chips (planned)
SaveAppButtonPATCH /dashboard/widgets/:id per change (planned) (Dashboard.md:27); per-item saving state; conflict (another admin edited) → AppSnackbar + reload list
Persistenceserver dashboard_widgets (Dashboard.md:7) (planned); no local-only layout — always server-authoritative
Statesloading skeleton list; empty → "All widgets shown"; error → AppErrorState + retry
A11yreorder handles have Move semantics; switches labeled with widget name

Shared micro-interaction & motion notes (all screens)

  • Mo-m-base route transitions; AppBar elevation El-1; cards El-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)

Module-specific widgets for the dashboard. Shared primitives live in 00-shared/03 (AppCard, AppSkeleton, AppEmptyState, AppErrorState, AppRefreshIndicator, AppOfflineBanner, AppSnackbar, AppBadge, AppChips, AppCharts, SegmentedButton family). Everything here composes those primitives; token names per 00-shared/02.


1. KpiCard

PurposeOne aggregate number with label and optional trend
Propslabel: String, value: String (pre-formatted), trend: TrendData? (delta, direction (planned)), onTap: VoidCallback?, status: KpiStatus (normal/zero/stale)
Bindingvalue from payload leaf (dashboard.service.ts:50-70); formatting at call site (NumberFormat, INR for totalDueAmount :68)
LayoutAppCard; value headlineMedium; label bodySmall onSurfaceVariant; TrendIndicator under value; optional trailing drill icon
Statesnormal; 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)
A11ysemantics node: label, value value-first ("93 percent, Attendance"); button when onTap; 48 dp min
Motionpress Mo-m-fast ripple; value change AnimatedSwitcher 200 ms

2. TrendIndicator (planned)

PurposeΔ vs previous period
Propsdelta: double, `direction: up
RenderingbodySmall; ▲ +2 success / ▼ −1 error / neutral (00-shared/02 color roles); never rendered without server trend field (06_Screen_Specifications.md §1.2)
A11yglyph + hidden semantics "up 2 percent"; color never sole signal

3. ChartCard

PurposeTitle + chart + caption container
Propstitle, chart: Widget (via shared AppCharts), caption: String? (a11y text row), trailing: Widget? (e.g. PeriodSelector)
LayoutAppCard padded 16; title titleMedium; caption bodySmall onSurfaceVariant below chart
Emptydashed placeholder + "No data for this period" (AppEmptyState mini)
A11ycaption text row is the screen-reader alternative to the chart (00-shared/09)

4. PeriodSelector (planned)

PurposeToday / 7d / 30d segmented control
Propsvalue, onChanged, enabled
Behaviourdisabled + tooltip "Periods arrive with the server API" until `GET /dashboard/attendance
Stateselection lives in cubit (13_State_Management.md §3), triggers refetch

5. StaleBanner

PurposeSurface cache staleness honestly
Propsage: Duration, onRefresh: VoidCallback
RenderingAppBanner(info) "Showing data from X min ago" + Refresh text button; auto-hides after successful refetch
Rulesshown 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)

PurposeCustomize grid: reorder + visibility + role chips
ComposReorderableListView of WidgetPaletteTile (drag handle, Switch, AppChips roles (planned)), save via AppButton
DataGET/PATCH /dashboard/widgets (planned) (Dashboard.md:26-27), persisted in dashboard_widgets (Dashboard.md:7)
A11yhandles Move semantics; switch labels per widget
Entry gateparent renders only with dashboard.widget.manage (permissions.constants.ts:38)

7. KpiRow

PurposeHorizontal-scrolling row of KpiCards (phone) / wrapped grid (tablet+ )
Behaviourscrollable with Scrollbar; shows 5 cards in service order (students → teachers → staff → attendance → finance, dashboard.service.ts:31-33,34,35-37)
Loading5 square AppSkeletons
A11yrow semantics combined list; no auto-scroll

Dependency map (module → shared)

KpiCardAppCard; ChartCardAppCard,AppCharts,AppEmptyState; StaleBannerAppBanner; WidgetPaletteAppChips,AppButton,AppSnackbar; all loading→AppSkeleton; refresh→AppRefreshIndicator; offline→AppOfflineBanner.

Design decisions (why)

  • No custom chart widget — shared AppCharts owns 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 from dashboard_widgets, never local state only.

08 — Form Specifications (Dashboard Module)

The dashboard is a read-first surface: no forms exist today (the single endpoint is GET /dashboard/overviewdashboard.controller.ts:13-17; no DTO directory exists under src/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.managepermissions.constants.ts:38). Server API (planned) (Dashboard.md:26-27); persistence dashboard_widgets (Dashboard.md:7).

FieldControlRules
Widget listReorderableListView (drag handles)1:1 with server widget ids; reorder is local until save
VisibilitySwitch per widgetoptimistic toggle + rollback on failure
Role chipsAppChips multi-select (planned)"who may see this widget" — free-text chip input; server role-list param (planned)
SubmitAppButton "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

FieldControlRules
PeriodSegmentedButton Today/7d/30dsingle-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)

InputKindBehaviour
Pull-to-refreshgesturebypasses client cache, force network (06_Screen_Specifications.md §1.4)
KPI tap → drilltap target ≥ 48navigation only, no payload mutation
WS invalidationpassive listenernever 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_ERROR field-level, 409 conflict inline (00-shared/07 §3).

09 — User Behaviour (Dashboard Module)

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: StaleBanner with 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 / InvoiceGenerated just fired (Dashboard.md:34), they expect the dashboard to reflect it within seconds. The WS bridge already broadcasts every domain event to tenant:{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 AppEmptyState hero (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 KpiCard is tappable → students list, fees invoices (filtered to issued/partial/overduedashboard.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 per IMPLEMENTATION_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)

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)

  1. Route push Mo-m-base slide+fade; AppBar immediately visible.
  2. KPI row area renders 5 AppSkeleton cards (value 28 dp line + label 12 dp, shimmer Mo-m-fast), charts render AppSkeleton(chart).
  3. 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 draw Mo-m-base ((planned) blocks).
  4. Failure on first load → AppErrorState.code + Retry; retry re-runs the sequence from 2. No partial numbers.

2. Pull-to-refresh

  • AppRefreshIndicator wraps the scroll view; trigger calls cubit refresh() with force: 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 AnimatedSwitcher 200 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

  1. Client subscribed to tenant:{tenantId} room (auto-join on connect — ws.gateway.ts:50); listens for AttendanceMarked, ResultPublished, PaymentRecorded, InvoiceGenerated (Dashboard.md:34).
  2. Event arrives → debounce 2 s (coalesce bulk event bursts) → refresh(force: true).
  3. 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.
  4. Value deltas ≥ 1% animate (AnimatedSwitcher 200 ms) + live-region announcement (a11y). Sub-1% deltas update silently.
  5. Connection lost → rely on poll timer; banner AppOfflineBanner after 2 missed polls.

5. KPI card tap → drill

  • Whole card is the target (≥ 48 dp). Tap → Mo-m-base press ripple → route push to owning module: students list / teachers list / staff list / attendance day view / fees invoices filtered issued|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.managepermissions.constants.ts:38); route push.
  • Reorder: drag handle press Mo-m-fast; drop commits to list state; Save issues per-widget PATCH (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); Tab order: KPI cards → charts (focusable with caption) → AppBar actions.
  • Enter activates focused card. Escape returns from detail routes.

10. Motion budget

InteractionToken
Route enter/exitMo-m-base
Skeleton shimmerMo-m-fast
Value update (WS/poll)200 ms AnimatedSwitcher
Stale/offline bannerslide-in Mo-m-fast
Chart draw (planned)Mo-m-base
Press rippleMo-m-fast

11 — Design System Mapping (Dashboard Module)

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

RoleTokenUsage
Screen titleTy headlineSmall/titleLargeAppBar "Dashboard"
KPI valueTy headlineMedium (tabs) / numeric styleKpiCard.value
KPI labelTy bodySmall onSurfaceVariantKpiCard.label
TrendTy bodySmall (bold variant (planned))TrendIndicator
Chart titleTy titleMediumChartCard.title
Chart captionTy bodySmall onSurfaceVariantChartCard.caption (a11y text)
BannerTy bodyMediumStaleBanner, AppOfflineBanner
Empty stateTy titleMedium + bodyMediumAppEmptyState

2. Colour

RoleTokenUse
Card surfacessurfaceContainerLowKpiCard, ChartCard (both themes)
ValueonSurfaceKPI numbers
Trend up/downprimary/error (semantic success/error), neutral onSurfaceVariantTrendIndicator (planned) — glyphs accompany colour (09_User_Behaviour.md B-rule)
Chartsprimary, secondary, tertiaryAppCharts series
Overdue/attentionerror + errorContainer chipdues overdue chip
Zero hintonSurfaceVariantzero-state hints
Stale bannerinfo secondaryContainerStaleBanner
OfflineAppOfflineBanner shared tokensoffline

All pairs pass AA on light + dark (00-shared/09 §2); no module-specific colours added.

3. Spacing & layout

TokenUse
Sp 16 (phone) / 24 (tablet+)screen horizontal padding
Sp 12between KPI cards (row), chart-card padding
Sp 16chart-card inner padding
Sp 24section gaps
KPI rowhorizontal scroll phone; wrap grid tablet ≥840 dp; max 3 cols desktop ≤1200 width

4. Elevation & radius

TokenUse
El-e1resting cards (KpiCard, ChartCard)
El-e2desktop hover lift (06_Screen_Specifications.md §1.7)
El-1AppBar
R mdcards, banners, chips
R fulltrend 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

ComponentContainerTextAccent
KpiCardsurfaceContainerLow, El-e1, R-mdvalue headlineMedium onSurface; label bodySmall onSurfaceVarianttrend primary/error
ChartCardsame as cardtitle titleMedium; caption bodySmallseries primary/secondary/tertiary
StaleBannersecondaryContainerbodyMedium onSecondaryContainerinfo
WidgetPaletteTile (planned)surfaceContainerLowtitle bodyLargeswitch 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

RequirementMapping
Contrast AAtokens above; chart series differ by luminance + pattern (planned)
Chart summariesChartCard.caption text row + semanticsLabel (06_Screen_Specifications.md §1.5)
Touch targets ≥ 48all cards, handles, switches
Colour independencetrend glyphs + labels
Live regionsKPI value updates ≥ 1%

12 — API Mapping (Dashboard Module)

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)

EndpointGET /api/v1/dashboard/overview (dashboard.controller.ts:13-17)
GuardJwtAuthGuard 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)
Requestnone; tenant from JWT only (never from body — 00-shared/07 §1)
Response200 envelope, data (dashboard.service.ts:50-70): {students:{total}, teachers:{total}, staff:{total}, attendance:{total, summary:{status:count}, rate}, finance:{pendingInvoices, totalDueAmount}}
Aggregatesstudents = 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)
Errors401 UNAUTHENTICATED (missing/expired JWT); 429 RATE_LIMITED (api tier 100/min); 5xx INTERNAL (service has no custom errors)
Side effectsNone 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)
ClientDashboard Home first load; skeletons → values (06_Screen_Specifications.md §1.3)

Cached response contract (planned) (blueprint target)

Cache keysl:{tenantId}:dashboard:overview — tenant namespacing automatic (redis-cache.service.ts:16-19); fallback platform (:17)
TTL60 s (Dashboard.md:41)
Invalidationon AttendanceMarked, ResultPublished, PaymentRecorded, InvoiceGenerated (Dashboard.md:34) — handler (planned); plus scheduled rebuild worker (Dashboard.md:50)
Client mirrorclient cache sl:{tenant}:dashboard:overview TTL 60 s (volatile, 00-shared/06 §3.3); force-refresh bypasses it (10_Interaction_Specification.md §2)
Stalenessclient renders last-good + StaleBanner beyond 60 s; ≥10 min → offline banner (06_Screen_Specifications.md §1.3)

E2 — Attendance trend (planned)

EndpointGET /api/v1/dashboard/attendance (Dashboard.md:24) — no controller route in code
Request`?period=today
Responserate/summary series for period; today shape from dashboard.service.ts:54-65
Cachesl:{tenantId}:dashboard:attendance:{period} TTL 60 s (planned)
ClientSS2 attendance detail; PeriodSelector disabled until this ships (08_Form_Specifications.md §2)

E3 — Finance trend (planned)

EndpointGET /api/v1/dashboard/finance (Dashboard.md:25)
Request?period= (planned); accountant role access (planned) (role lacks dashboard.readrole.schema.ts:47)
Responsedues + pendingInvoices series; today shape dashboard.service.ts:66-69
Cachesl:{tenantId}:dashboard:finance:{period} TTL 60 s (planned)
ClientSS3 finance detail

E4 — List widgets (planned)

EndpointGET /api/v1/dashboard/widgets (Dashboard.md:26)
Guarddashboard.read (permissions.constants.ts:37); reads user's widget layout
Responseordered widget configs from dashboard_widgets (Dashboard.md:7)
Client/dashboard/customize list + Home layout resolution (planned)

E5 — Update widget (planned)

EndpointPATCH /api/v1/dashboard/widgets/:id (Dashboard.md:27)
Guarddashboard.widget.manage (permissions.constants.ts:38) — the only dashboard write permission; org_admin default (role.schema.ts:23)
Requestvisibility / order / role chips (planned)
Errors404 RESOURCE_NOT_FOUND; 403 (no permission); 409 (concurrent edit)
Clientper-widget save with per-item spinner + conflict snackbar (10_Interaction_Specification.md §7)

E6 — Realtime (not REST)

ChannelWS /ws, JWT auth (ws.gateway.ts:20-23,35-44); client auto-joined to tenant:{tenantId} (ws.gateway.ts:50)
EventsAttendanceMarked, 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 rulefilter 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)

TierLimitNotes
api100/minoverview/attendance/finance polling must stay within budget: 60 s poll = 1 req/min per screen, plus pull + WS-triggered refetches — acceptable
admin500/min(planned) if widgets PATCH ships under admin tier

Client contract summary

ConcernRule
AuthBearer JWT; 401 → single-flight refresh → replay; fail → session expiry (00-shared/06 §3.6)
Optimisticnone today (read-only); (planned) customize toggles optimistic with rollback (08_Form_Specifications.md §1)
IdempotencyGETs safe; PATCH widgets (planned) idempotent per widget
Offlinelast-good payload + banners; no writes (09_User_Behaviour.md B8)
Formattingclient formats, never recalculates (09_User_Behaviour.md B9): rate pre-rounded :57-64, dues pre-summed :45-48
Error mapping00-shared/06 §5: 401 reauth, 403 hide, 404 empty, 429 backoff poll, 5xx keep-last-good + snackbar
Role gatingdashboard.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)

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

CubitOwnsSource
OverviewCubitpayload, freshness, refresh triggersE1 (12_API_Mapping.md)
PeriodCubit (planned)Today/7d/30d selection08_Form_Specifications.md §2
WidgetsCubit (planned)widget order/visibility for customize screenE4/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 pending flag 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:overview TTL 60 s (00-shared/06 §3.3); volatile — never written on error responses.

4. Polling

  • Timer.periodic(60s) started on Loaded, cancelled in close().
  • Tick → refresh() non-forced (cache age check skips the request when fresh — 10_Interaction_Specification.md §3).
  • App-background: WidgetsBindingObserver pauses 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)

  • PeriodCubit state {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-widget PATCH (E5) with rollback; conflict (409) → reload from E4.
  • Layout is server-authoritative (07_Component_Library.md §7).

8. Cache & staleness summary

LayerKeyTTLWrites on
Server (target (planned))sl:{tenantId}:dashboard:overview60 s (Dashboard.md:41)rebuild worker + event invalidation (Dashboard.md:50,:34)
Client volatilesl:{tenant}:dashboard:overview60 ssuccessful 200 only
Client renderlast-good always kept; StaleBanner past 60 s, offline ≥10 min

9. Error mapping (from 00-shared/06 §5)

ErrorUI
401silent refresh → replay; fail → session expiry screen
403never reachable (module hidden) — dashboard.read gate
429backoff: skip next poll cycle; AppSnackbar once
404not expected on overview; treat as 5xx
5xxkeep lastGood; snackbar ≥ 4 s; no skeleton flash

14 — QA Checklist (Dashboard Module)

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-rounded present/total×100, 0 when no records (:57-64).
  • Finance card counts only issued|partial|overdue invoices (:36-37) and sums totalAmount − paidAmount (:45-48); currency formatted INR.
  • 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.md Phase 5).

3. Caching staleness (critical)

  • Server TTL 60 s (Dashboard.md:41) — verify the overview key sl:{tenantId}:dashboard:overview expires 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 StaleBanner with 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 ChartCard has 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"), button when 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; Tab order 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: AnimatedSwitcher 200 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: KpiRow children const-eligible where possible; Overview compares 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)

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 ChartCard with caption + semanticsLabel (06_Screen_Specifications.md §1.5). Disable PeriodSelector until server period params exist (E2/E3 (planned)).

8. Tests (map to 14_QA_Checklist.md)

TestWhat it guards
overview_cubit_test.dartsingle-flight coalescing; cache-hit skips network; force bypasses cache; 5xx keeps lastGood; error never writes cache
overview_repository_test.dartkey/TTL exact (sl:{tenant}:dashboard:overview, 60 s); 200-only writes
dashboard_widget_test.dartvalues rendered verbatim (no client math — 09 B9); zero-states show hints; stale banner age text
kpi_card_semantics_test.dartvalue-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 testcustomize entry hidden without dashboard.widget.manage; route hidden without dashboard.read

9. Rollout order

  1. OverviewCubit + repository + KPI row (E1 live today).
  2. Stale/offline banners + WS refresh + polling.
  3. Charts/periods (planned) when E2/E3 land.
  4. Widget customization (planned) when E4/E5 land.
  5. Role-scoped layouts (planned) per IMPLEMENTATION_PLAN.md:232.

01 — Product Overview (Exams 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, and docs/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).

ResponsibilitySource
Create an exam (always starts status: 'draft')examination.service.ts:43-59
List exams (paginated) / get one / update / soft-deleteexamination.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-slotsexamination.service.ts:133-137
Enter/upsert marks for one student on one subjectexamination.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 cardresult.controller.ts:16-37; result.service.ts:36-98
Grade letter computed from overall percentageresult.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-publishedevent-queue-map.ts:27

2. Business goals

GoalMeasure
Exam schedule creation per class/term is fastOne POST per exam (POST /examinations), one POST per subject slot (POST /examinations/:id/subjects)
Marks entry is per (exam, student, subject) and idempotentUpsert 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 maximumServer 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 atomicallypublishResults stamps publishedAt on all results of the exam, then flips exam status → 'published' (examination.service.ts:203-220)
Multi-tenant isolationEvery query tenant-scoped by BaseRepository.scopedFilter (base.repository.ts:20-30); cross-tenant IDs → 404
AuditabilityAll 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); exam type enum extension with mock_test | dpp | practice_test | all_india_test, isAllIndiaRank, testSeriesId, plus test_series / dpp collections and ExamsService.createMockTest/createDPP/createTestSeries sketched in docs/IMPLEMENTATION_PLAN.md:501-567, 643.
  • RBAC enforcement — no exam.* permission exists in code (permissions.constants.ts:1-97 lists no exam.*; blueprint studylyon-blueprint/04-Modules/Exams.md:64-72 specifies exam.create/update/ delete/mark/publish but they are not seeded) and ExaminationController declares 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 in examination.service.ts today (OQ-2).
  • Notifications for schedule/result alerts — (planned); only the in-app results-published job is routed (event-queue-map.ts:27); other exam events have no queue route (event-queue-map.ts contains no entry for ExaminationCreated etc.).
  • 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 publishedAt stamped.
  • 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 maximumNotFoundException('Marks cannot exceed maximum.')404 RESOURCE_NOT_FOUND, not 422 (examination.service.ts:145-146). Client must pre-validate marksObtained ≤ maximumMarks and 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 in addSubject (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.ts has no conflict logic) — client-side warning only (OQ-2).
  • Publish immutability: publishResults has no guard — it can be re-called, and PATCH /examinations/:id can still mutate a published exam (the UpdateExaminationDto.status is a free string, examination.dto.ts:49-52; schema enum is not enforced on the findOneAndUpdate path because updateById runs without runValidators, base.repository.ts:57-66) — a bad status could persist (OQ-5). Client must treat published as 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 → grade F (result.service.ts:83-95, 137).
  • Per-subject grade is 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-card overallGrade (A+/A/B+/B/C/D/F at 90/80/70/60/50/40, result.service.ts:130-138).
  • passingMarks vs maximumMarks: both @Min(1) only (examination-subject.dto.ts:36-44); passingMarks > maximumMarks is not rejected — client must validate (OQ-6).
  • Exam window inversion: startDate > endDate is not validated (both IsDateString, examination.dto.ts:19-25); startTime > endTime likewise not validated (examination-subject.dto.ts:28-34) — client-side validation only (OQ-6).
  • Type not enum-validated on the wire: CreateExaminationDto.type is IsString (examination.dto.ts:13-17) though the schema enum is ['midterm','final','unit_test','quarterly','other'] (examination.schema.ts:15-20) — invalid values fail on save() (create path runs validators) but a bad type may 500 as a validator error; client constrains the picker.
  • Soft delete: DELETE /examinations/:idsoftDelete (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 /examinations supports page/limit (1–100, default 20), but sort and q are accepted yet ignored by the service (examination.service.ts:67-79 uses find({}, {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 ExaminationResult per (student, subject-slot); the roster of students comes from the Students module (students of classId on 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)

#ItemImpact
OQ-1No 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-2Blueprint 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-3Duplicate subject slots are structurally possible (non-unique index). Server fix or client-side dedupe?Add-subject guard UX
OQ-4No 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-5PATCH /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-6passingMarks > maximumMarks, startTime ≥ endTime, startDate > endDate are not server-validated. Client-only constraints?Form validation strategy
OQ-7sort/q query params ignored on GET /examinations (examination.service.ts:67-79). Client-side sort/filter fallback?List UX
OQ-8MarksEntered/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-9Per-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)

TermMeaning
Exam (Examination)examinations doc: academicYearId, name, type, startDate, endDate, status, gradingSchemeId? (examination.schema.ts:9-36)
Exam typemidterm, final, unit_test, quarterly, other (examination.schema.ts:15-20)
Exam statusdraft, 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)
PublishPOST /examinations/:id/publish — stamp publishedAt on all results, set exam status published (examination.service.ts:203-220)
Report cardGET /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)

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 in permissions.constants.ts:1-97 — note no exam.* permission exists yet (OQ-4, see 01_Product_Overview.md §10); the RBAC column below is the target contract from studylyon-blueprint/04-Modules/Exams.md:64-72 and is (planned) until seeded.


1. Exam Coordinator — "the planner"

WhoSenior academic admin (principal, vice-principal, academic coordinator) at the tenant org
Roles / permissionsAdmin role (all permissions, role.schema.ts:23); target: exam.create, exam.update, exam.delete, exam.publish (planned)
FrequencyWeekly during planning season; daily during results week
DeviceTablet + desktop web; office Wi-Fi
GoalsCreate the term's exam calendar (name, type midterm/final/unit_test/quarterly/otherexamination.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 pointsNo 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)
BehaviorsReviews 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 criteriaExam visible with correct slots; all marks entered; publish succeeded → snackbar + list shows published badge

2. Class Teacher — "the marker"

WhoClass teacher / subject teacher marking a class's answer sheets
Roles / permissionsTeacher role (role.schema.ts:31); target: exam.mark (planned)
FrequencyDaily during exam week; per subject-slot
DevicePhone + tablet; classroom Wi-Fi may be flaky
GoalsOpen 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 pointsMarks 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)
BehaviorsEnters 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 criteriaEvery roster student has a mark ≤ maximumMarks; coverage 100%

3. Principal — "the approver"

WhoPrincipal or director; owns the publish decision
Roles / permissionsAdmin; target: exam.publish (planned)
Frequency2–6 times per term (per exam)
DevicePhone / tablet; on the move
GoalsVerify 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 pointsPublish 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)
BehaviorsOpens exam detail, glances at per-subject coverage, taps Publish, confirms in the dialog, watches for the published status flip
Exit criteriaExam shows published; report cards reachable (GET /results/report-card/:studentId/:examId)

4. Student — "the candidate"

WhoEnrolled student of the tenant
Roles / permissionsStudent role (role.schema.ts:63); reads via result.read (planned)/student.read today
FrequencyAfter each exam's publish; before each exam to see schedule
DevicePhone; shared data plan
GoalsSee 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 pointsResults 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)
BehaviorsChecks the results tab after "results published" notification (in-app/results-published job — event-queue-map.ts:27 (planned)); screenshots the report card
Exit criteriaReport card shows totals, percentage, grade; understands why a subject shows 0

5. Parent — "the verifier"

WhoParent/guardian of an enrolled student
Roles / permissionsParent role (role.schema.ts:55); reads via result.read (planned) / student.read today
FrequencyAfter each published exam
DevicePhone; home Wi-Fi
GoalsVerify the child's report card; compare marksObtained vs maximumMarks per subject; check remarks from teachers
Pain pointsSame 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
BehaviorsPassive reader; shares the card with family
Exit criteriaFull subject table + totals rendered from GET /results/report-card/:studentId/:examId

6. Support / QA Analyst — "the verifier"

WhoSchool IT support or QA reviewing tenant data
Roles / permissionsOrg admin; audit.read (permissions.constants.ts:54)
FrequencyOn incident
DeviceDesktop
GoalsTrace 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 pointsEvents 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)
BehaviorsReplays the flow read-only; compares server state against the client's optimistic UI claims
Exit criteriaRoot cause identified (data vs display)

7. Persona × permission matrix (target contract, (planned))

ActionCoordinatorTeacherPrincipalStudentParent
List/view examsexam.read*exam.read*✅ (read-only)
Create/update/delete examexam.create/update/delete
Add subject slotexam.update
Enter marksexam.mark
Publishexam.publishexam.publish
View own results/report cardresult.readresult.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)

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.

  1. Open Exams tab → list loads from GET /api/v1/examinations?page=1&limit=20 (examination.controller.ts:30-32).
  2. 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).
  3. Submit → POST /api/v1/examinations → 201 doc with status: 'draft' (examination.service.ts:48) → navigates to exam detail.
  4. Add slots: for each class+subject → POST /api/v1/examinations/:id/subjects with {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 > maximumMarks and startTime ≥ endTime (OQ-6).
  5. 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.

  1. Open exam detail → tap a slot → marks screen (GET /api/v1/examinations/:id/subjects for slot list, then GET /api/v1/results/exam-subject/:examSubjectId for existing marks, result.controller.ts:21-25).
  2. Roster = students of the slot's classId (Students module); each row prefilled from the marks response (absent result = unmarked).
  3. Type marksObtained (numeric keyboard, @Min(0)examination-subject.dto.ts:51-55), optional grade + remarks.
  4. Save row → POST /api/v1/results/exam-subject/:examSubjectId/marks with {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).
  5. 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.
  6. 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.

  1. Exam detail → coverage check (per-slot GET /api/v1/results/exam-subject/:id marked/total counts).
  2. Tap Publish → confirm dialog (explicit, never optimistic — publish has side effects: results become visible downstream).
  3. POST /api/v1/examinations/:id/publish (examination.controller.ts:54-56): server stamps publishedAt = now on every result of the exam's slots (examination-result.repository.ts:42-51) and sets exam status: 'published' (examination.service.ts:209-211); emits ExamResultsPublished (examination.service.ts:212-219) → in-app queue, job results-published (event-queue-map.ts:27).
  4. Detail updates: status badge published; slot marks show publishedAt.
  5. 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.

  1. Results tab → GET /api/v1/results/student/:studentId (result.controller.ts:16-20) → list of mark docs.
  2. Open report card → GET /api/v1/results/report-card/:studentId/:examId (result.controller.ts:32-37) → subject rows (name resolved client-side from subjectId — OQ-9), totals, percentage (2 dp), overallGrade (A+/A/B+/B/C/D/F, result.service.ts:130-138).
  3. 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.

  1. Marks screen → tap row → edit cell → save → same POST /results/exam-subject/:examSubjectId/marks endpoint (upsert updates marksObtained/grade/remarks, examination.service.ts:151-161); doc version increments (base.repository.ts:57-66).
  2. MarksEntered event re-emitted (examination.service.ts:163-174).
  3. 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)

  1. Exam detail → Edit → PATCH /api/v1/examinations/:id with partial {name?, type?, startDate?, endDate?, status?} (UpdateExaminationDto, examination.dto.ts:28-53). Client never sends status except draft→active (OQ-1); disables status editing once published (OQ-5).
  2. Delete → confirm dialog → DELETE /api/v1/examinations/:idsoft 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.
  3. 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)

  1. List (GET /examinations, paginated page/limit, meta from buildPaginationMeta, pagination-query.dto.ts:41-55); sort/q are ignored by the service (OQ-7) → client sorts by startDate locally.
  2. Statuses map to badges: draft (neutral), active (primary), completed (secondary), published (success) — schema enum examination.schema.ts:28-33.
  3. Row tap → detail (GET /examinations/:id) → slots (GET /examinations/:id/subjects).

8. Journey → endpoint cheat sheet

JourneyPrimary endpoints
J1 Plan examPOST /examinations, POST /examinations/:id/subjects, GET /examinations
J2 Enter marksGET /examinations/:id/subjects, GET /results/exam-subject/:id, POST /results/exam-subject/:id/marks
J3 PublishPOST /examinations/:id/publish, GET /examinations/:id
J4 Own resultsGET /results/student/:studentId, GET /results/report-card/:studentId/:examId
J5 Correct markPOST /results/exam-subject/:id/marks (upsert)
J6 Edit/deletePATCH /examinations/:id, DELETE /examinations/:id
J7 Lifecycle glanceGET /examinations, GET /examinations/:id, GET /examinations/:id/subjects

04 — Information Architecture (Exams Module)

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) and Academics → 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)

RouteScreenGuard (target (planned))
/examsS1 Exams Listexam.read
/exams/:examIdS2 Exam Detailexam.read
/exams/:examId/slots/:slotId/marksS3 Marks Entryexam.mark
/exams/:examId/slots/newS4 Add Subject Slotexam.update
/exams/new · /exams/:examId/editS8 / S8a Create/Edit Examexam.create / exam.update
/resultsS6 Student Results (mine)result.read
/results/:examId/report-cardS7 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)

EntitySourceKey fields exposed
Examexamination.schema.ts:9-36academicYearId, name, type (5-value enum), startDate, endDate, status (4-value enum), gradingSchemeId?
Subject slotexamination-subject.schema.ts:9-31examinationId, subjectId, classId, date, startTime/endTime (HH:mm strings), maximumMarks, passingMarks
Mark/Resultexamination-result.schema.ts:9-25studentId, examinationSubjectId, marksObtained?, grade?, remarks?, publishedAt?
Report cardresult.service.ts:8-26subjects[] (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.

IDScreenPurposeRoutePrimary widgetsStatesPermissions (target (planned))A11y focusAnimationsAnalytics (proposed)
S1Exams ListBrowse all exams of the tenant (paginated); entry point to planning & publish/examsAppBar, RefreshIndicator, ListView.builder of ExamCard, AppFAB.extended "New exam", AppFilterChip row (status/type), AppPaginationBarInitial/loading (skeleton), success (list), empty (no exams), error (5xx + requestId), offline (banner + cache), pagination (loading-more, last-page)exam.readScreen reader reads status badges (never color-only), focus order filter→list→FAB, touch target ≥48 dpFade-in rows Mo.fast; pull-to-refresh spinner; FAB hero transitionexam.list_view, exam.row_tap, exam.create_start
S2Exam DetailExam header + schedule (subject slots) + coverage + actions (edit/delete/add subject/publish)/exams/:examIdAppBar + AppMenu, header card (ExamHeaderCard), segmented control Schedule/Coverage, slot ListView (ExamSubjectCard), action bar FilledButton "Add subject", PublishButtonLoading, success, empty (no slots), error 404 (exam removed), offline (cached header + stale slots), publish-in-progressexam.read (actions: exam.update/exam.publish/exam.delete)Status changes announced via Semantics(liveRegion); publish button labeled with exam nameHeader fade/slide Mo.base; segmented control Mo.fast; publish progress spinner; slot list stagger Mo.fastexam.detail_view, exam.publish_tap
S3Marks EntryEnter/correct marks for one subject slot (class roster)/exams/:examId/slots/:slotId/marksSlotHeaderCard (subject, class, date/time, max/pass marks), roster ListView of MarksRow, MarksTextField per row, coverage bar (MarksSummaryBar), save-per-rowLoading (roster+marks), success, empty (roster empty), error 404 (slot removed → pop to detail), per-row saving, per-row failed, offline queue pendingexam.markNumeric keyboard friendly, per-row semantics label "{student}, {marks}/{max}", error announcementsRow save: check animation Mo.fast; failed row shake Mo.shake; coverage bar animates widthmarks_entry_open, marks_saved, marks_row_failed, marks_coverage
S4Add Subject SlotAdd one class+subject+date+time+marks slot to an exam/exams/:examId/slots/newForm 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.updateForm fields labeled, error summaries announced, date picker a11ySubmit button spinner; error inline slide Mo.fastexam_slot_add_start, exam_slot_add_submit, exam_slot_add_conflict_warned
S5Publish Confirm (dialog)Final gate before POST /examinations/:id/publish; shows coverage summaryoverlay on /exams/:examIdAppDialog (warning icon, coverage recap, FilledButton "Publish" / TextButton "Cancel")Initial (summary), submitting (spinner, non-dismissable), success → pop + badge update, error (404/5xx)exam.publishDialog focus trap, Escape cancels, confirm requires explicit tapDialog scale-in Mo.base; success check Mo.fastexam_publish_confirm, exam_publish_success, exam_publish_failure
S6Student Results (mine)Read-only marks list for a student/resultsAppBar, list of ResultRowCard grouped by exam, RefreshIndicatorLoading, success, empty ("No results yet"), error, offlineresult.readMarks read as {subject} {marks}/{max}Row stagger Mo.fastresults_view
S7Report CardPer-student, per-exam aggregate: subject table, totals, percentage, overall grade/results/:examId/report-cardReportCardHeader (exam, generatedAt), subject table (ReportCardTable), totals card, GradeBadge (overall), remarks columnLoading, success, empty (404 "No subjects scheduled"), errorresult.readTable header announced; grade badge label includes letter + thresholdTable rows fade Mo.fast; grade badge pop Mo.basereport_card_view, report_card_share
S8 / S8aCreate / Edit ExamForm to create (POST /examinations) or edit (PATCH /examinations/:id)/exams/new · /exams/:examId/editForm: AppDropdown (academic year, type), AppTextField name, two AppDatePicker; edit mode adds status read-only chipValidation, submitting, success → pop to detail, error 400 field / 404 (edit deleted exam)exam.create / exam.updateSame as S4Same as S4exam_form_submit, exam_form_error

Cross-screen notes

  • Shared empty/loading/error widgets: AppSkeleton, AppEmptyState, AppErrorState, AppOfflineBanner from 00-shared/03 — no module-specific rebuilds.
  • Status badges (S1/S2): draft, active, completed, published (examination.schema.ts:28-33) — one shared ExamStatusBadge component (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/:id etc. — (forward-looking).

06 — Screen Specifications (Exams Module)

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: AppEmptyState icon assignment, "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 shows requestId; 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 when hasNext: 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-35examination.service.ts:61-65); slots GET /examinations/:id/subjects (examination.controller.ts:51-53examination.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-screen AppErrorState "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); when completed → 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 of maximumMarks; @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 §3 for cadence).

Save flow: POST /results/exam-subject/:examSubjectId/marks (result.controller.ts:26-31examination.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" (slot classId has 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.fast check animation); failure → row shake Mo.shake, "Retry" affordance; error toast for 404 "Marks cannot exceed maximum." (examination.service.ts:145-146) → open marks dialog clamped to maximumMarks.
  • 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-50examination.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-20result.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-37result.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)

Module-specific components on top of 00-shared/03_Component_Library.md (primitives like AppButton, AppTextField, AppDialog, AppDropdown, AppDatePicker, AppEmptyState, AppSkeleton are 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

PurposeRender the exam lifecycle state: draft, active, completed, published (examination.schema.ts:28-33)
Variantsdraft → neutral (surfaceVariant/onSurfaceVariant); active → primary; completed → secondary; published → tertiary + Icons.verified
Statesicon + label always (never color-only, 00-shared/09); loading → skeleton chip
TokenslabelSmall (Ty), radius r-full, padding Sp.4/Sp.8, gap Sp.4
A11ySemantics(label: 'Status: published')

2. ExamTypeChip

PurposeShow exam type: midterm, final, unit_test, quarterly, other (examination.schema.ts:15-20); labels: "Midterm", "Final", "Unit test", "Quarterly", "Other"
Variantsselected (filter mode, primaryContainer) / static (informational, surfaceContainerHighest)
TokenslabelMedium, r-full, Sp.4/Sp.10

3. ExamCard (S1 list row)

Dataname, type, startDate, endDate, status, academicYearId (examination.schema.ts:9-36); derived: slots count + marked/total (from coverage queries)
Layout2-line header + meta row + footer row; inkWell whole-card tap → S2
Statesdefault / pressed (stateLayer primary 8%) / hover (desktop tint) / disabled (not used in list)
TokenssurfaceContainerLow, r-lg, e-1, padding Sp.16, gaps Sp.8/Sp.12
A11ySemantics(button) with full sentence label (§06 §1)

4. ExamSubjectCard (slot row, S2)

DatasubjectId, 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
Variantswith-coverage (progress bar from result counts) / without (slots without marks loaded); selected (tablet rail)
Statestap → S3; long-press → "Open marks" context menu (tablet)
TokenssurfaceContainer, r-md, padding Sp.12/Sp.16; progress 4 dp primary/tertiary when full
A11ySemantics(button, label: 'Subject, Class, date, time, max marks, N of M marked')

5. MarksRow (S3 roster row)

Datastudent (roster, resolved from classId via Students module) + result marksObtained, grade?, remarks?, publishedAt? (examination-result.schema.ts:15-25)
Layoutavatar initials · name/meta · MarksTextField (digits, max digits of maximumMarks) · optional grade chip · save-state icon
Statesunsaved (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)
Tokensrow surfaceContainerLow, gap Sp.12; field width 96 dp, labelLarge mono tabular
A11yrow semantics label with marks vs max; inline error announced

6. MarksSummaryBar (S3 coverage)

Datamarked/total + queued/failed counts (cubit-derived, 13_State_Management.md)
Layoutlabel "118 of 120 marked" + animated bar; below: "2 offline · 1 failed — Retry" line
Statesidle / saving / offline (dashed overlay) / complete (100%, tertiary)
Tokensheight 8 dp bar, r-full, primary; labelMedium; Semantics(liveRegion)

7. GradeChip

Datagrade? free text from EnterMarksDto (examination-subject.dto.ts:57-60) — client never computes per-subject grades (OQ-9)
Variantsfilled (grade present, secondaryContainer) / absent (tap → grade sheet)
TokenslabelMedium, r-full, padding Sp.4/Sp.8

8. CoverageTile (S2 coverage tab)

Dataslot + marked/total via GET /results/exam-subject/:id; max/pass marks
Layoutsubject · class · bar · "42/45" · "Max 100 · Pass 33"
Statesnone / partial / complete / no-marks (0 of N)
TokenssurfaceContainer, r-md, padding Sp.12

9. PublishButton + PublishDialog (S2/S5)

PublishButtonFilledButton.tonal tertiary, label "Publish results", icon campaign; states: enabled (status ≠ published) / disabled (published — hidden instead) / submitting (spinner)
PublishDialogAppDialog wrapper (§06 §5): warning icon, coverage recap list, Cancel / Publish actions, non-dismissable while submitting
Tokensdialog width ≤ 480 dp, r-lg, El-e3

10. SlotHeaderCard (S3 top)

Datasubject/class names, date, startTimeendTime (HH:mm strings, examination-subject.schema.ts:18-25), maximumMarks, passingMarks
Warning statepassingMarks > maximumMarks → tertiary banner "Pass marks exceed max marks" (invalid config, OQ-6)
TokenssurfaceContainerLow, r-lg, e-1, padding Sp.16

11. ResultRowCard (S6)

Dataresult doc (marksObtained, grade?, remarks?, publishedAt?, examination-result.schema.ts:15-25); subject/exam resolved client-side (OQ-9)
Layoutsubject · marks/max · grade chip · published date · remarks preview (2-line clamp)
TokenssurfaceContainerLow, r-md, padding Sp.12/Sp.16

12. ReportCardTable (S7)

Datasubjects[], totalMarksObtained, totalMaximumMarks, percentage, overallGrade, generatedAt (result.service.ts:8-26, 88-97)
Layoutphone: stacked rows; ≥840 dp: real table (Subject / Marks / Grade / Remarks) + totals row + overall card
Statesrow missing mark → "0" + muted "not marked" (payload can't distinguish, result.service.ts:70); empty grade → "—"
Tokensheader labelLarge onSurfaceVariant; numbers mono tabular; r-lg card wrap

13. GradeBadge (S7 overall)

DataoverallGrade letter (A+/A/B+/B/C/D/F — result.service.ts:130-138)
TokensdisplaySmall letter, r-lg padding Sp.8/Sp.16, primaryContainer; caption labelMedium "80–89%"
A11ySemantics(label: 'Grade A, 80 to 89 percent')

Reuse summary

NeedComponent source
Buttons, text fields, dialogs, dropdowns, date pickers, skeletons, empty/error/offline states00-shared/03 primitives
Motion, haptics00-shared/08_Interaction_&_Motion.md (Mo.fast/base/shake)
Accessibility baseline00-shared/09_Accessibility_Baseline.md
Forms & validation patterns08_Form_Specifications.md (module)

08 — Form Specifications (Exams Module)

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).

#FieldLabelWidgetKeyboardServer validationClient-only validationDefaultError
1academicYearIdAcademic yearAppDropdown@IsMongoId (examination.dto.ts:6-7)none (required)"Select the academic year" / 400 VALIDATION_ERROR
2nameExam nameAppTextFieldtext (autocorrect off)@IsString (examination.dto.ts:10-11)required, ≤100 chars, trim"Enter the exam name"
3typeExam typeAppDropdown@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"
4startDateStart dateAppDatePicker@IsDateString (examination.dto.ts:19-21)client-only:endDate (OQ-6)"Start date must be on or before end date"
5endDateEnd dateAppDatePicker@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).

#FieldServer validationClient rules
1name?@IsOptional @IsStringas §1
2type?@IsOptional @IsStringpicker only
3startDate? / endDate?@IsOptional @IsDateStringwindow rule as §1
4status?@IsOptional @IsStringfree 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).

#FieldLabelWidgetKeyboardServer validationClient-only validationDefaultError
1subjectIdSubjectAppDropdown@IsMongoId (examination-subject.dto.ts:16-18)client-only: warn "already scheduled" if duplicate (no server dedupe — OQ-3)"Select a subject"
2classIdClassAppDropdown@IsMongoId (examination-subject.dto.ts:20-22)"Select a class"
3dateExam dateAppDatePicker@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"
4startTimeStart timeAppTextFieldTextInputType.datetime (HH:mm)@IsString (examination-subject.dto.ts:28-30; example '09:00')required, HH:mm pattern09:00"Enter a valid time (HH:mm)"
5endTimeEnd timeAppTextFielddatetime@IsString (examination-subject.dto.ts:31-34)client-only: endTime > startTime (OQ-6)12:00"End time must be after start time"
6maximumMarksMaximum marksAppTextFieldnumber (digits)@IsNumber @Min(1) (examination-subject.dto.ts:36-39)client-only:passingMarks; sane cap 1000100"Maximum marks must be at least 1"
7passingMarksPassing marksAppTextFieldnumber@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).

#FieldLabelWidgetKeyboardServer validationClient-only validationDefaultError
1studentId(hidden)filled from roster row@IsMongoId (examination-subject.dto.ts:49-51)roster student
2marksObtainedMarksMarksTextFieldnumber, 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 422inline "Marks cannot exceed {max}"; on server 404 → refresh slot max, re-enter
3grade?GradeGradeChip → 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
4remarks?RemarksAppTextField 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 codeClient handling
400 VALIDATION_ERRORinline field errors (details[] per 00-shared/07 §3), focus first invalid
401 / 403session flow / permission screen (00-shared/06 §3.6)
404 RESOURCE_NOT_FOUNDresource-gone state (marks: "Marks cannot exceed maximum." is 404 — special-cased, examination.service.ts:146)
409 DUPLICATE_RESOURCEnot expected today (no unique constraints on exams/slots, OQ-3)
429 RATE_LIMITEDbackoff, no auto-retry (00-shared/07 §4)
5xxgeneric + requestId, retry

09 — User Behaviour (Exams Module)

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 sequential addSubject — each call is independent).
  • Window discipline: coordinators set startDate/endDate first; the slot date picker clamps to the window (mirrors CreateExaminationDto, 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; Enter saves 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 MarksSummaryBar is 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

TriggerBehaviour
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
5xxbanner + requestId, manual retry, last-good cache
429backoff, 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 published badge 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 is in-app/results-published (event-queue-map.ts:27); polling falls back to GET /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 0 with "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 Mo duration 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)

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 back Mo.fast.
  • Pull-to-refresh: re-fetch GET /examinations (bypass cache); on 304/no-change, no animation; new rows stagger-in Mo.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, haptic mediumImpact on success; badge flips to published with Mo.fast pop; haptic error on 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, haptic lightImpact) → failed (shake Mo.shake 300 ms, error icon, "Retry").
  • Max-marks clamp: typing past maximumMarks digits 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; MarksSummaryBar shows "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]; published exam → confirm sheet before any edit (immutability contract, OQ-5).
  • Coverage bar: width animates 200 ms ease; at 100% turns tertiary + haptic lightImpact (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); haptic selection on share.
  • Rows: fade Mo.fast stagger; unmarked rows muted with "not marked" hint (payload ambiguity, result.service.ts:70).
  • Grade badge: pop-in Mo.base on load; announcement via live region.

6. Keyboard & focus

ScreenBehaviour
S1/S2scroll navigation; N = new exam (desktop); P = publish (detail, desktop)
S3Tab cycles fields; Enter saves+advances; Esc clears current input; hardware-keyboard users get the full row cadence (§3)
S4/S8autofocus first field (name/subject); Enter submits when on last field; submit on keyboard done

7. Haptics summary

ActionHaptic
Row savedlightImpact
Publish successmediumImpact
Save failure / max violationerror (heavy)
Coverage 100%lightImpact
Overlap warning shownselection

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)

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)

SemanticColor role (light / dark)Used for
Exam draftsurfaceVariant / onSurfaceVariantExamStatusBadge.draft, chip, filter selected-off
Exam activeprimary / primaryContainerExamStatusBadge.active, Coverage bar active state
Exam completedsecondary / secondaryContainerExamStatusBadge.completed, GradeChip
Exam publishedtertiary / tertiaryContainer + Icons.verifiedExamStatusBadge.published, publish success state, publishedAt chips
Marks savedtertiary checkrow save-state icon
Marks failed / max violationerror / errorContainerfailed row, inline errors, MarksTextField error outline
Overlap / invalid config warningtertiary banner (passingMarks > maximumMarks, OQ-6)SlotHeaderCard warning, S4 conflict dialog
Grade absent / not markedonSurfaceVariant mutedreport-card unmarked rows
Coverage complete (100%)tertiaryMarksSummaryBar full state
Coverage partialprimaryMarksSummaryBar progress

Status enum source: examination.schema.ts:28-33; type enum examination.schema.ts:15-20.

2. Typography mapping (Ty, 00-shared/02 §2)

ElementToken
Screen titles (Exams, Results)titleLarge
Exam name (card/detail)titleMedium (card) / headlineMedium (detail)
Slot subject, roster student nametitleMedium / bodyLarge
Meta lines (dates, class, counts)bodySmall / bodyMedium onSurfaceVariant
Marks values, percentages, totalslabelLargedisplaySmall mono tabular
Badges/chipslabelSmall / labelMedium
Form labelslabelLarge onSurfaceVariant
Empty/error titlestitleMedium; body bodyMedium

3. Spacing (Sp, 00-shared/02 §3)

UseToken
Screen padding (phone)Sp.16
Screen padding (tablet+)Sp.24
Card internal paddingSp.16 (large), Sp.12 (rows)
Vertical rhythm between sectionsSp.24 (detail), Sp.16 (forms)
Row gaps (marks row)Sp.12; field-to-label Sp.8
Badge/chip paddingSp.4/Sp.8, Sp.4/Sp.10

4. Elevation (El, 00-shared/02 §5)

SurfaceToken
AppBar (S1/S2/S3)El-e1
Cards (ExamCard, SlotHeaderCard, ReportCard)El-e1
Publish dialogEl-e3
Floating sheets (grade sheet, conflict sheet)El-e3

5. Radius (R, 00-shared/02 §4)

ElementToken
Cards (list/detail)r-lg (16)
Rows, coverage tilesr-md (12)
Inputs, marks fieldr-md
Badges/chipsr-full
MarksSummaryBarr-full

6. Iconography

IconUse
Icons.assignment / fact_checklist empty state, module icon
Icons.addFAB "New exam" / "Add subject"
Icons.edit / Icons.deletedetail menu
Icons.campaign / Icons.verifiedpublish button / published badge
Icons.check_circle (tertiary)row saved
Icons.error_outlinefailed row
Icons.cloud_off / Icons.cloud_queueoffline queued row
Icons.scheduleslot time
Icons.sharereport card share (proposed)
Icons.warning_amberpublish/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 (primaryContainerprimary handling per 00-shared/02 §7).
  • Published badge: tertiaryContainer (light) / tertiary-tinted container (dark).
  • Unmarked report-card rows: onSurfaceVariant at 60% opacity in dark.
  • Screenshots/QA: 14_QA_Checklist.md §dark includes badge contrast spot-checks.

8. Component-to-shared mapping

Module componentBuilt onShared variants reused
ExamStatusBadge, ExamTypeChip, GradeChipAppBadge/AppChip
ExamCard, ExamSubjectCard, CoverageTile, SlotHeaderCard, ResultRowCardAppCardpressed/hover state layers
MarksTextFieldAppTextField (compact numeric variant)error/disabled states
PublishDialog, delete confirm, conflict sheetAppDialogbarrierDismissible lock while submitting
MarksSummaryBarcustom progress primitive (shared)Semantics(liveRegion)
Skeleton/empty/error/offlineAppSkeleton, AppEmptyState, AppErrorState, AppOfflineBanneras-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)

Exact wire contract for every screen → endpoint. Base /api/v1 (main.ts:44 global prefix); envelope per 00-shared/07_API_Conventions.md. All endpoints from examination.controller.ts and result.controller.ts; business rules from examination.service.ts and result.service.ts. All endpoints are guarded by JwtAuthGuard only (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

AspectContract
Basehttps://api.<domain>/api/v1
HeadersAuthorization: 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)
TenancytenantId from JWT claim — never in body (00-shared/07 §6)
Paginationpage (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)
Cachinglists: last-good cache TTL 24 h; marks/coverage: 5 min SWR (00-shared/06 §3.3)
Offlinereads cached; mark writes queued with idempotency keys (13_State_Management.md §4); create/edit/publish online-only
Retrybackoff on 5xx/network; no auto-retry on 429

1. S1 — Exams List

EndpointGET /examinations?page=1&limit=20 (examination.controller.ts:30-32)
RequestPaginationQueryDto {page?, limit?, sort?, q?} (pagination-query.dto.ts:5-30) — sort/q ignored by service (OQ-7)
Success200 → 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'
Errors400 bad page/limit; 401; 429; 5xx
Sourceexamination.service.ts:67-79
Offlinecached list (24 h)

2. S2 — Exam Detail

EndpointGET /examinations/:id (examination.controller.ts:33-35)
Success200 → data = exam doc (shape above)
Errors404 RESOURCE_NOT_FOUND "Examination not found." (examination.service.ts:61-65); cross-tenant/deleted → same 404
EndpointGET /examinations/:id/subjects (examination.controller.ts:51-53)
Success200 → 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)
Errors404s as above; 429
Offlinecached header + slots (slots 5 min)

3. S3 — Marks Entry

EndpointPOST /results/exam-subject/:examSubjectId/marks (result.controller.ts:26-31)
RequestEnterMarksDto {studentId, marksObtained, grade?, remarks?} (examination-subject.dto.ts:47-66)
Success201 (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 rulesmarksObtained > subject.maximumMarks404 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)
Errors400 VALIDATION_ERROR (bad ids / negative marks); 404 as above; 429; 5xx
Sourceexamination.service.ts:139-195
Offlinequeued with Idempotency-Key = op id; flush FIFO

4. S4 — Add Subject Slot

EndpointPOST /examinations/:id/subjects (examination.controller.ts:45-50)
RequestCreateExaminationSubjectDto {examinationId, subjectId, classId, date(ISO), startTime, endTime, maximumMarks, passingMarks} (examination-subject.dto.ts:11-45); examinationId injected from route (examination.service.ts:49)
Success201 → 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)
Errors400; 404 exam missing (create fails on repository); 429
Sourceexamination.service.ts:112-131

5. S5 — Publish

EndpointPOST /examinations/:id/publish (examination.controller.ts:54-56)
Success200 → 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 rulesno guards: publishes whatever marks exist (no completeness check); re-callable; does not lock edits (OQ-5); not a DB transaction (two sequential writes)
Errors404 exam missing (updateById no-op on missing id — client treats as refresh); 429
Sourceexamination.service.ts:203-220

6. S6 — Student Results (mine)

EndpointGET /results/student/:studentId (result.controller.ts:16-20)
Success200 → data = ExaminationResultDocument[]unpaginated (result.service.ts:36-38; examination-result.repository.ts:26-28); no publish filter — unpublished marks are returned
Errors401; 429
Noteclient groups rows by exam via slot ids (subject/exam names resolved client-side, OQ-9)

7. S7 — Report Card

EndpointGET /results/report-card/:studentId/:examId (result.controller.ts:32-37)
Success200 → 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 rulesno 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)
Errors404 as above; 429

8. S8 / S8a — Create / Edit Exam

EndpointPOST /examinations (examination.controller.ts:27-29)
RequestCreateExaminationDto {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)
Success201 → data = exam doc
EndpointPATCH /examinations/:id (examination.controller.ts:36-41)
RequestUpdateExaminationDto {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)
Errors400; 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)

ItemSource
ExamResultsPublished → queue in-app, job results-publishedevent-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)

CodeExams-specific UX
400 VALIDATION_ERRORinline field errors (08 §5); bulk → pre-validate client-side
401 UNAUTHENTICATEDrefresh once; else session expiry (00-shared/06 §5)
403 PERMISSION_DENIEDhide plan/publish/marks actions; read-only state (RBAC (planned), OQ-4)
404 RESOURCE_NOT_FOUNDtwo 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_RESOURCEnot expected today (no unique constraints on exams/slots, OQ-3); offline flush collision preview
422 BUSINESS_RULE_Violationreserved for future conflict/immutability checks — not emitted by exams service today (OQ-2/OQ-5)
429 RATE_LIMITEDbackoff, no auto-retry
5xxgeneric + requestId; retry; last-good cache

13 — State Management (Exams Module)

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

CubitScreen(s)State
ExamsListCubitS1paginated exam list, status/type filters (client-side — server ignores q/sort, OQ-7), cache
ExamDetailCubitS2header + slots + coverage per slot; publish lifecycle
ExamFormCubitS4, S8, S8acreate exam / edit exam / add slot; client-only validations (OQ-2/3/6)
MarksEntryCubitS3roster rows + marks, per-row save state machine, offline queue
StudentResultsCubitS6marks list for a student, grouped by exam
ReportCardCubitS7report 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/publish is online-only, no offline queue.
  • After published, the cubit exposes locked = 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 index examination-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-import is (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 flagged queued.
  • Dedupe: before enqueue, replace queued ops for the same studentId in the same examSubjectId (last-write-wins mirrors the server upsert).
  • Flush: on reconnect (ConnectivityCubit) → POST /results/exam-subject/:id/marks per op with Idempotency-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 flagged conflicted, 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 while hasNext (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 by status/type over the loaded window; sort by startDate asc locally; a "fetched all pages" footer when hasNext: false.
  • Cache: last-good list TTL 24 h; invalidated on create/delete from ExamFormCubit/ExamDetailCubit (module-internal event).
  • Realtime: subscribe to results.published WS topic (00-shared/07 §8) (planned) → re-fetch affected exam row (badge published).

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 stamps publishedAt, examination-result.repository.ts:42-51); otherwise 5 min. 404 ("No subjects found…", result.service.ts:51-53) → NotFound state, 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 (JwtAuthGuard only 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)

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:e2e stack with MongoDB + Redis).


1. Functional — planning (S1/S2/S4/S8)

  • List loads page 1 (limit default 20); scrolling appends pages; hasNext: false shows 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 badge draft.
  • Edit exam → PATCH $set only sent fields; version increments (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/subjects order; examinationId taken from route, never from body (examination.service.ts:49).
  • Slot fields persist exactly: date (Date), startTime/endTime as 'HH:mm' strings, maximumMarks/passingMarks numbers (examination-subject.schema.ts:18-31).

2. Validation & edge cases (module-specific)

  • Max marks: submit marksObtained > maximumMarks → server returns 404 NotFoundException "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 on SlotHeaderCard when 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 (updateById no runValidators) — document as known server gaps, client must not rely on them.
  • Status transitions: create → draft; publish → published; active/ completed only via explicit user action (OQ-1); client never sends status on create; status picker disabled when published.
  • 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: tertiaryContainer badges, 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: N new exam, P publish (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/:id per 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).
  • tenantId never sent in bodies; x-request-id echoed (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)

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:44 prefix api/v1).
  • Endpoints (exact, examination.controller.ts / result.controller.ts):
MethodPathNotes
POST/examinationscreate, forced draft
GET/examinations?page&limitpaginated; sort/q ignored (OQ-7)
GET/examinations/:id404 "Examination not found."
PATCH/examinations/:idpartial $set, version+1
DELETE/examinations/:idsoft delete
POST/examinations/:id/subjectsslot create
GET/examinations/:id/subjectsunpaginated slots
POST/examinations/:id/publishstamps publishedAt, status→published
POST/results/exam-subject/:id/marksmarks upsert (EnterMarksDto)
GET/results/exam-subject/:idslot marks (coverage)
GET/results/student/:studentIdown results
GET/results/report-card/:studentId/:examIdaggregate
  • Marks save sends Idempotency-Key: <opId> (offline flush); x-request-id generated per request (00-shared/07 §1).
  • studentId for 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)),
  ]),
)
  • ExamDetailCubit scoped per examId (recreated on route change); MarksEntryCubit scoped per examSubjectId.
  • Cubits expose LoadState-based sealed states (13_State_Management.md); screens are BlocBuilder/BlocListener only — 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 (badge published); dialog is barrierDismissible: false while submitting.
  • Max-marks pre-validation in MarksTextField (digits ≤ maximumMarks, clamp on paste) mirrors the server guard examination.service.ts:145-146; the 404 path refreshes the slot's max before re-entry.
  • Immutability flag exam.status == published gates: 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 in ExamFormCubit before any request.
  • Status picker only offers draft → active transition (OQ-1); create never sends status.
  • Report card: resolve subjectName from Academics subjects catalog by subjectId (payload returns raw id — OQ-9); unmarked rows → "0 + not marked" hint; percentage rendered toStringAsFixed(1) from server's 2-dp value.
  • Names/formatting: times displayed from startTime/endTime strings (never re-parsed); dates from ISO DateTime localized.

7. Build order

  1. Domain entities + enums + repositories (interfaces) — compile-time contract.
  2. Data layer: exams_api.dart (envelope/error decoding), models, repo impls, unit tests with mocked dio.
  3. Cubits + offline queue — pure Dart, unit-tested state machines (13 §7).
  4. Screens S1 → S2 → S3 (core value) with shared widgets (00-shared/03 + 07 module).
  5. S8/S4 forms (08_Form_Specifications.md), S5 publish dialog.
  6. S6/S7 read-only branch (student/parent).
  7. A11y pass (00-shared/09), dark mode, tablet layouts, analytics (proposed) instrumentation per 05.
  8. E2E against live stack (npm run test:e2e — MongoDB + Redis) for 14_QA_Checklist.md scenarios.

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)

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").

ResponsibilitySource
CRUD of tenant feature toggles (key, enabled, label, description, module)feature-flag.schema.ts:9-22
List all / by module / enabled-only flag setsfeature-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 flagfeature-flags.service.ts:47-52
Evaluation primitive for downstream servicesfeature-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:

  1. Flag store — the feature_flags collection (feature-flag.schema.ts:7): a per-tenant catalog of toggles with metadata. Served by /api/v1/feature-flags*.
  2. Org lifecycle overlayorganizations.metadata.featureFlags: Record<string, boolean> (organization.schema.ts:129-130), served by GET/PATCH /api/v1/organizations/:id/feature-flags. PATCH full-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

GoalMeasure
Tenant can enable/disable channel features without a deployflag toggle round-trip < 1 s p95 (single Mongo upsert, no cache layer today)
Flags are per-tenant, never cross-tenantevery read scoped by tenantId from JWT (base.repository.ts:20-30)
Flags survive tenant reuse of keysunique {tenantId, key} index (feature-flag.schema.ts:26)
Feature rollout can be staged per modulemodule field + {tenantId, module} index (feature-flag.schema.ts:22,27)
Downstream services gate on flagsisEnabled() primitive exists (feature-flags.service.ts:22-25); no consumer yet (OQ-1)
Config changes are auditableflag toggles listed under Config audit category — (planned) (AUDITING.md:52)
Cache keeps flag evaluation cheapsl: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

DependencyRoleSource
Auth (JwtAuthGuard, global)every flag endpoint requires bearer JWTfeature-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/minrate-limit.constants.ts:6, rate-limit.guard.ts:36
TenantContextServicetenant isolation + platform-admin bypassbase.repository.ts:20-30
Mongoose feature_flagssystem of recordfeature-flags.module.ts:13-15
Redis + BullMQ cache-rebuilddeclared, not wired to flags — flag cache invalidation (planned)queue.constants.ts:13, scheduler.service.ts:27,67
Organizations moduleorg-level flag overlay surfaceorganizations.controller.ts:74-89

7. Success metrics

  • Flag list/filter/read latency < 200 ms p95 (direct Mongo, no cache today — CACHE_ARCHITECTURE.md:48 proposes 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 description and module: DTO accepts them (update-feature-flag.dto.ts:13-26) but FeatureFlagRepository.upsert only $sets enabled and label (feature-flag.repository.ts:40). Editing a flag's description/module silently persists nothing.
  • PUT /feature-flags with a new key = create (upsert); enabled is required → you cannot create a "disabled-by-default" flag without sending enabled: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 PATCH full-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 calls requireTenantId() 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/delete are 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)

#ItemImpact
OQ-1isEnabled() 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-2Org 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-3Bulk 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-4Re-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-5feature-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-6Gradual rollout / A/B testing (MODULE_ARCHITECTURE.md:310-312): no fields, no endpoints. Planned phase?Flag editor fields (percentage/audience)
OQ-7No 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)

TermMeaning
FlagOne feature_flags doc: key, enabled, label?, description?, module? + BaseSchema fields
KeyUnique per tenant identifier (biometric.enabled, channels.whatsapp — examples only; keys are free-form strings, update-feature-flag.dto.ts:5-7)
UpsertfindOneAndUpdate(..., {upsert:true}) — insert or $set merge on enabled+label (feature-flag.repository.ts:38-42)
Enabled setThe list returned by GET /feature-flags/enabled — the client's gate data
Flag storeThe feature_flags collection (module surface)
Org overlayorganizations.metadata.featureFlags map (lifecycle surface, full-replace semantics)
Soft deleteisDeleted: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)

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 in ALL_PERMISSIONS but are not enforced on any flag endpoint today — RbacGuard allows when no @Roles()/@Permissions() metadata is present (rbac.guard.ts:29) and the controller declares only JwtAuthGuard (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

AttributeValue
FrequencyWeekly-ish; during onboarding and channel setup
PermissionsALL_PERMISSIONS incl. feature-flags.* (role.schema.ts:23 → seeded org_admin)
GoalSee what capabilities their institution has on/off; flip channels (biometric, SMS, WhatsApp) on demand
Pain pointsDoesn't know which module a key belongs to; enabling a flag but the feature "not working" (propagation delay, OQ-1)
Core jobGET /feature-flags (browse), PUT /feature-flags (toggle one), PUT /feature-flags/bulk (rollout)
RestrictionsWrites are not permission-gated today (OQ-5) — but per contract they will be; UI hides write affordances without feature-flags.update
Expected behaviourInstant 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)

AttributeValue
FrequencyDuring rollouts and migrations; support tickets
Permissionsfeature-flags.read, feature-flags.update (custom role; possible today in RBAC config)
GoalBatch-configure flags (e.g., enable SMS for all campuses), fix label/description typos, stage a module's rollout
Core jobPUT /feature-flags/bulk (feature-flags.controller.ts:48-52), editor with key/enabled/label/description/module (update-feature-flag.dto.ts:4-26)
Pain pointsBulk 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)
RestrictionsNo feature-flags.delete → delete affordance hidden (server would accept it anyway — OQ-5)
Expected behaviourSee 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)

AttributeValue
FrequencyEvery session — flags gate the app they see
PermissionsUsually none of feature-flags.*; low-permission roles (role.schema.ts:31-42)
GoalNever crash, never see a grayed "coming soon" wall; a disabled feature simply isn't there
Core interactionNone direct — the client gates UI via FeatureFlagsCubit (00-shared/06 §4: "gates UI per tenant (biometric, SMS, WhatsApp channels)")
Pain pointsA flag flips mid-session (admin toggles SMS off while teacher is in the SMS-send flow)
Expected behaviourFeature 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)

AttributeValue
PermissionsisPlatformAdmin=true when role present (jwt-auth.guard.ts:54); reads bypass tenant scope (base.repository.ts:21-23)
FrequencyOn-demand support / diagnostics / platform-level rollouts
GoalInspect a tenant's flag state during support; evaluate platform-level defaults
RestrictionsUpsert 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 behaviourRead 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)

AttributeValue
RoleServices, not users — they call FeatureFlagsService.isEnabled(key) (feature-flags.service.ts:22-25)
ContractisEnabled returns flag?.enabled ?? falsemissing flag = disabled (fail-closed)
StatusNo 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 impactThese modules' screens render behind flags the admin manages here — this package specs the gating mechanism (13_State_Management.md)

6. Role → flag-appearance matrix

Valueorg_adminconfiguratorend userplatform_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 flagsvia /organization settings
Toggle visibility to end usersn/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)

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 only enabled and label (feature-flag.repository.ts:40) — the editor's description/module are not persisted on update (OQ-3). The client must not claim "saved" for those fields.
  • Missing key + enabled required: PUT /feature-flags with {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 builds Set<key>; isEnabled('x.y') is a sync lookup in FeatureFlagsCubit (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() calls findByKey first (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)

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 by FeatureFlagsCubit, see 13). Everything is derived from feature-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)
  • Settings is 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/integrations once the integrations module lands (blueprint lists it separately, COLLECTIONS.md:805-818).

2. Route table (module-owned; extends 00-shared/05 §4)

RouteScreenAuthPerm (target)Source API
/settings/feature-flagsFlags listJWTfeature-flags.readGET /feature-flags (+?module=)
/settings/feature-flags?tab=enabledEnabled-only listJWTfeature-flags.readGET /feature-flags/enabled
/settings/feature-flags/:keyFlag detailJWTfeature-flags.readGET /feature-flags/:key
/settings/feature-flags/:key/editFlag editorJWTfeature-flags.updatePUT /feature-flags
/settings/feature-flags/bulkBulk update (sheet)JWTfeature-flags.updatePUT /feature-flags/bulk
(menu) deleteJWTfeature-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 per 00-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:

  1. Snackbar/toast — "Feature enabled", "3 of 5 flags updated", "Flag deleted", copy.
  2. Bottom sheet — bulk update (selection + apply), key quick-copy.
  3. 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).
  4. Full-screen page — flag editor (5 fields), flags list on phone.
  5. 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 + cubit Refresh).
  • Bulk sheet applies → list refetches → per-row result banner.
  • Delete → row removed (server-confirm, no optimistic removal for delete; toggle IS optimistic).
  • Global: whenever FeatureFlagsCubit refreshes (app.resume, pull-to-refresh, TTL), every gated widget rebuilds via BlocBuilder/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" FilterChip row under the AppBar.
  • Keyboard (desktop): Ctrl+K search n/a (list is short); N new flag; Esc closes sheet; space toggles focused switch (00-shared/08 §2).
PatternTargetNote
studylyon://settings/feature-flagsFlags listadmin
studylyon://settings/feature-flags/:keyFlag detailsupports support-links
studylyon://settings/feature-flags?module=biometricFiltered listprefill filter

9. Accessibility of IA

  • One headlineMedium per 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)

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)

FieldDetail
PurposeBrowse tenant toggle catalog; grouped by module; quick-toggle; entry to detail/bulk
EntrySettings hub; deep link with ?module= prefilter
Exitrow → detail; bulk sheet; back → settings
SourceGET /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)
CompositionAppSearchBar(optional, client-side), module FilterChips (distinct module values), "Enabled only" toggle tab, grouped list (AppSectionHeader per module), row = AppFlagRow
Statesloading (AppSkeleton(list)); empty (AppEmptyState — fresh tenant); error (AppErrorState); offline (cached + banner); permission (no feature-flags.read → route guarded, 403 screen)
Row actionsswitch (optimistic toggle, PUT /feature-flags), AppMenu: Edit, Delete
Bulk CTAAppButton "Bulk update" (enabled when ≥ 1 selection; selection mode via long-press or checkbox toggle)
Analyticsfeatureflags.list.view, featureflags.list.filter{module}, featureflags.toggle{key,value} (proposed)
Adaptivephone: list full width, selection via long-press; tablet/desktop: master-detail at ≥ 840 px, hover row highlight
a11yeach row Semantics(button, toggled:); module headers announced; filter chips with selected state
Motionm-entrance stagger on rows; switch m-fast

2. Flag Detail (/settings/feature-flags/:key)

FieldDetail
PurposeFull picture of one flag + primary toggle
Entrylist row tap; deep link
Exitedit; delete; back to list
SourceGET /feature-flags/:key → doc; 404 if key missing (feature-flags.service.ts:27-31)
Compositionheader (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)
Statesloading (skeleton); 404 → AppEmptyState "Flag not found" (deleted/renamed); error; offline
Permissionsread → view; update → toggle/edit enabled; delete → delete (target contract, OQ-5)
Analyticsfeatureflags.detail.view, featureflags.detail.toggle{key,value} (proposed)
a11yswitch semantics; key read as mono code; description in body text
Motionhero from list row (m-slow) — optional; toggle m-fast

3. Flag Editor (/settings/feature-flags/:key/edit) — create + edit modes

FieldDetail
PurposeEdit/create one flag: key, enabled, label, description, module
Entrydetail "Edit"; FAB "New flag" (create mode — key empty)
ExitSave → detail (refresh); discard → back
SourceSave = PUT /feature-flags (upsert — create-or-update, feature-flags.controller.ts:42-46); in create mode a missing key creates a new flag
Compositionform fields per 08_Form_Specifications.md; save AppButton (loading)
Statesedit 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
Analyticsfeatureflags.edit.save{key}, featureflags.create.save{key} (proposed)
a11ylabels linked, focus first field, error announce
Adaptivefull-screen page (5 fields); tablet centered column ≤ 560 px
Motionm-base push; save success → check + pop

4. Bulk Update (bottom sheet over list)

FieldDetail
PurposeApply a target state to N selected flags
Entrylist "Bulk update" (≥ 1 selected)
SourcePUT /feature-flags/bulk — body array of UpdateFeatureFlagDto (feature-flags.controller.ts:48-52)
Compositionheader "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
Resultper-row outcome list (success/fail) — server is sequential, no transaction (feature-flags.service.ts:40-44); failed subset can be retried
Statesidle (count N), applying (progress), done (per-row results), error (partial)
Analyticsfeatureflags.bulk.apply{n,target}, featureflags.bulk.result{n_ok,n_fail} (proposed)
a11ysheet focus trap; progress announced (live region)
Motionm-base sheet; rows fade-in stagger

5. Affected-Features Map (detail companion) — (proposed) client composition

FieldDetail
PurposeShow the end-user surface a flag gates ("turning this off hides: Biometric check-in")
Entrydetail screen "Affects" section / tab
SourceNo 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.
Statesempty ("no client screens mapped to this key yet") — key is not in the registry
NoteMust 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; N new.
  • Editor: Tab key→enabled→label→description→module→save; Enter submits; Esc discards.
  • Bulk sheet: arrow keys navigate rows; Enter toggles selection.
  • All lists ListView.builder; keyboards avoid insets on phone.

06 — Screen Specifications (Feature Flags Module)

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 by key asc (feature-flags.service.ts:10-12) — not paginated (plain array; no meta).
  • 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, AppProgress inline) → 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) no feature-flags.update permission (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 subtle AppBanner(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: FeatureFlagsCubit TTL (default 30 s, (proposed), mirrors CACHE_ARCHITECTURE.md:48) — the app re-reads the enabled set at that cadence; a RefreshIndicator forces an immediate re-read.

Cache-rebuild awareness:

  • The cache-rebuild BullMQ 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." CTA FilledButton.tonal "New flag" (if update).
  • error → AppErrorState(code) with Retry; 401 → silent refresh flow.
  • offline → AppOfflineBanner + last-good cache (client cache key sl: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 "" not found." — 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-base route transitions; cards El-e1; sheet e-5 r-xl.
  • Snackbar durations: success < 3 s, error ≥ 4 s (00-shared/03 A).
  • Switches everywhere: Mo.m-fast; error rollback = AnimatedSwitcher fade + error ring, no shake on toggle (shake reserved for forms).
  • Safe areas: bottom-nav height respected; sheets respect insets.

07 — Component Library (Feature Flags Module)

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: AppListTile height 56; leading AppFlagKeyChip-ish icon Icons.toggle_on/off (or Icons.flag); title = label ?? key; subtitle = key mono bodySmall + module AppBadge; trailing AppSwitch (value=enabled, onChanged=onToggle, disabled= !toggleEnabled || pending). Row AppMenu (Edit / Delete) — menu hidden when onEdit/onDelete null.
  • States: default / selected (checkbox overlay, primaryContainer tint) / 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: surfaceContainerLow row, R-md, El-e-0; selected primaryContainer.
  • Motion: m-fast switch; m-entrance stagger on list insert.
  • Flutter: ListTile + Switch + MenuAnchor; selection via Checkbox overlay in AnimatedContainer.

AppFlagKeyChip

  • Purpose: compact key display with copy.
  • Props: key, copyable (default true).
  • Rendering: mono text in surfaceVariant chip R-sm + Icons.content_copy 16; copy → Clipboard.setData + snackbar "Copied" + selectionClick haptic.
  • a11y: Semantics(button, label: "flag key <key>, copy"); copy result live region.
  • Flutter: InputChip-style Material + 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) + AppBadge count; Ungrouped bucket for null module values (schema allows absent module, 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):
    • keyAppTextField, mono, required @IsString(); editable only in create mode.
    • enabledAppSwitch, required boolean.
    • labelAppTextField, optional string.
    • descriptionAppTextField multiline (2–3 lines), optional.
    • moduleAppTextField + AppChips suggestions, 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; Autocomplete for 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 label AppTextField
    • AppButton apply; results list of AppFlagRow-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: AppCard with AppListTile rows — 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, AppOfflineBanner with 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; envelope meta absent — 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)

ComponentFlutter widgets
AppFlagRowListTile + Switch + Checkbox + MenuAnchor
AppFlagKeyChipMaterial + InkWell + SelectableText
AppFlagGroupHeaderRow + Text + Badge
AppFlagEditorFormForm + TextFormField + SwitchListTile + Autocomplete
AppBulkUpdateSheetshowModalBottomSheet + SegmentedButton
AppAffectedMapCardCard + ListTiles
AppFlagToggleBannerMaterialBanner

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)

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 the VALIDATION_ERROR (400) envelope with per-field details (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.

#FieldLabelRequiredType/KeyboardValidation (server)Client UX
1keyKeytext, 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)
2enabledEnabledAppSwitch boolean@IsBoolean() (update-feature-flag.dto.ts:10-11)default OFF in create mode; "off until you flip it"
3labelLabelAppTextField@IsOptional() @IsString() (update-feature-flag.dto.ts:14-16)hint "Human-readable name shown to staff"; used in list row title
4descriptionDescriptionAppTextField 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)
5moduleModuleAppTextField + 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.upsert persists only {tenantId, key, enabled, label} (feature-flag.repository.ts:37-42) — description and module are 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 $set is extended (OQ-3); the form is built so enabling them is a one-line change.
  • A description/module present 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 → map error.details[].message to fields (http-exception.filter.ts:103-107); empty details → 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

FieldLabelRequiredValidationUX
(selection)N selected flags≥ 1 itemmulti-select rows in list (long-press / checkbox); count in header
stateSet tobooleanSegmentedButton [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 returned keys.
  • 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: .next through fields, last .done; Enter submits; Esc cancels.
  • Error copy: from message for business 4xx; codes for the rest; never render raw 5xx.

Client-side error priority

  1. 400 VALIDATION → fields / row mapping.
  2. 401 UNAUTHENTICATED → silent refresh; fail → sessionExpired.
  3. 404 RESOURCE_NOT_FOUND → "flag removed" (detail/delete).
  4. 403 PERMISSION_DENIED → hide affordance (target; not enforced today — OQ-5).
  5. 429 RATE_LIMITED → countdown (api tier 100/min, rate-limit.constants.ts:6 — rare for admin screens; treat generically).
  6. 5xx → AppErrorState + requestId.

09 — User Behaviour (Feature Flags Module)

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() returns flag?.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-flags full-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

MistakeRelatedUX countermeasure
Admin flips a flag and expects it visible on a colleague's phone "right now"propagationinfo banner on toggle success (immediate today; "~30 s" once cache lands); pull-to-refresh affordance
Editing description/module and believing it savedPUT /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 appliedsequential no-transaction bulk (feature-flags.service.ts:40-44)per-row results + "retry failed"
Delete → re-create same key → 500unique index + soft delete (OQ-4)delete dialog warning; error copy with cause hint
Org-level edit wiping sibling flagsfull-replace $set (organizations.service.ts:149-151)merge-before-PATCH; diff preview in the org settings UI (proposed)
Admin assumes permissions protect flagsnone enforced (rbac.guard.ts:29)UI gates affordances by permission anyway; server enforcement (planned) (OQ-5)
Typing keys with spaces/uppercaseno server pattern validationclient hint a-z 0-9 . _ - (proposed); trim on submit
Re-creating a key expecting a clean slateupsert matches on {tenantId,key} — same doc re-enablededitor 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 N new 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 mono strings may overflow — wrap with ellipsis + full value on detail; rows reflow.
  • Reduced motion: no row stagger; state changes = color+icon+text (09 §8 of 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)

FrustrationTreatment
"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 UIpermission-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)

Module-specific interactions layered on 00-shared/08. Covers every input modality on phone/tablet/desktop.


1. Phone / touch

GestureBehaviourWhere
Taptoggle switch, row → detail, chip filter, menu openlist, detail
Tap on switch while pendingignored (switch disabled during flight)all toggles
Long-pressenter selection mode (bulk), open AppMenu when menu existslist rows
Swipe left/rightnone (delete is destructive → menu + dialog only; 00-shared/08 §1 keeps ≤ 2 non-destructive actions)
Pull downRefreshIndicator → full list refetch and FeatureFlagsCubit.refresh() (gating set stays in sync)list
Hardware backsheet → dismiss (selection kept); detail → list; editor dirty → discard confirmall
Edge swipedrawer (shell)

2. Keyboard / desktop / mouse

InputBehaviour
Tabchips → rows (row = one stop: switch+menu) → FAB; focus ring primary
Spacetoggles focused switch; toggles chip selection
Enteropens focused row detail; submits editor; applies bulk
Esccloses sheet / dialog / discards editor (with confirm if dirty)
Ctrl/Cmd+Nnew flag (editor, create mode)
Ctrl/Cmd+Click / Shift+Clickmulti-select rows for bulk
Right-clickAppMenu (same as long-press)
Hoverrow tint m-fast; tooltip after 500 ms (switch disabled reason, key copy)
Arrowsnavigate chip rows in module filter; move between switch and menu in a row

3. Haptics (mobile)

EventHapticRef
Toggle applied (200)lightImpact
Toggle rollback (error)mediumImpact
Copy keyselectionClick
Bulk appliedlightImpact
Delete confirmheavyImpact
Ruleoff when system haptics off; none on desktop08 §3

4. Motion language (module extension of 08 §4)

TransitionDuration/curveUsed by
List → detailm-base slide (hero optional m-slow)rows
Row insert/removem-entrance fade-up stagger 40 msafter bulk/delete refresh
Switch state changem-fast color/thumball toggles
Filter chip swap / tabm-base cross-fade of list contentfilter row
Sheet (bulk)m-base bottom slide, r-xl topbulk
Result rowsm-entrance fade-upbulk results
Error rollbackAnimatedSwitcher fade + error ring (no shake on toggles)switches

Reduced motion: fades + m-instant only; no stagger, no hero.

5. Micro-interactions catalogue (module)

InteractionDetail
Switch flippedthumb animates m-fast; row enters pending ("Saving…") without blocking other rows
Save in editorbutton loading swap m-base; double-tap locked
Copy keysnackbar "Copied" + selectionClick
Bulk applyprogress bar m-base; count "Applying 3 of 5"
Deletedialog m-base scale+fade; row fade-out on 200
Filter changelist 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-fast ripple.
  • 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-44 sequential apply → client diffs by key).
  • Deep-link prefilter: studylyon://settings/feature-flags?module=biometric pre-selects the chip; :key deep 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)

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)

ScreensThemeException
Flags list / detail / editorstandard shell surfaceenabled switches primary; selected rows primaryContainer; danger error
Bulk sheetsurfaceContainerHighapply CTA primary; per-row failure errorContainer
Editor warning bannertertiaryContainer (advisory, not error)description/module-not-saved caveat
Affected-features card (proposed)surfaceContainerLow"hidden when off" badges outline

2. Widget → token

ComponentM3 / FlutterTokensElevRadius
Flag rowAppListTilesurface, switch primarye-0
Flag row (selected)ListTile tintedprimaryContainer/onPrimaryContainere-0
Key chipMaterial chipsurfaceVariant fill, mono textr-sm
Module badgeBadge/AppBadgesecondaryContainerr-full
Enabled-only filterFilterChipselected secondaryContainerr-sm
Detail cardAppCardsurfaceContainerLowe-1r-lg
Editor fieldsTextFormFieldfill surfaceVariant, focus primaryr-md
Save CTAFilledButtonprimary/onPrimaryr-md
DeleteTextButton destructiveerror
Warning bannerAppBannertertiaryContainere-1r-md
Bulk sheetAppBottomSheetsurfaceContainerHighe-5r-xl
Bulk progressLinearProgressIndicatorprimary
Dialog (delete)AppDialogsurfaceContainerHighe-4r-lg
FAB New flagFloatingActionButton.extendedprimaryContainere-3r-full
SnackbarSnackBarinverseSurfacee-5r-md
Switch (disabled-perm)SwitchonSurfaceVariant 38%

3. Typography mapping

TokenScreen element
headlineMediumscreen titles
titleMediumlist rows, section headers (module groups)
bodyMediumlabel/description values
bodySmallcreated/updated timestamps, bulk result notes
labelLargebuttons, segmented button
labelMediumbadge counts, "Saving…"
monoflag 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)

PurposeTokenApplied to
Route transitionsm-baselist↔detail, editor push
Switch statem-fastall toggles
Filter/list cross-fadem-slowchip changes
Row insert/removalm-entrance (40 ms stagger)post-bulk/post-delete
Sheetm-basebulk sheet
Hero list→detailm-slowoptional

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 mono on surfaceVariant keeps ≥ 4.5:1.

7. Breakpoint behaviour

ClassListDetail/Editor
phone < 600single pane, filter chips scroll horizontallypush navigation
600–839chips + listcentered editor ≤ 560 px
≥ 840master-detail (list 360 px left)right pane detail; editor dialog-ish page
≥ 1200master-detail + hoverkeyboard 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)

Exact wire contract for every screen → endpoint. Base /api/v1; envelope per 00-shared/07. All endpoints from src/modules/feature-flags/feature-flags.controller.ts and the org-overlay from src/modules/organizations/organizations.controller.ts; business rules from feature-flags.service.ts. Global guards: RateLimitGuardJwtAuthGuardRbacGuard (app.module.ts:129-131). No flag endpoint is @Public(), and none declares @Permissions() — effective auth = valid JWT only (OQ-5; perms declared at permissions.constants.ts:78-80).


0. Module-wide request envelope & client policy

AspectContract
Basehttps://api.<domain>/api/v1
HeadersAuthorization: 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)
Codes400 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)
TenancytenantId 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 limitglobal api tier 100/min default (rate-limit.constants.ts:6, rate-limit.guard.ts:36)
Cachingnone in code; blueprint: Redis sl:global:featureflag:{name} poll+cache 30 s (planned) (CACHE_ARCHITECTURE.md:33,48)
Offlinereads from last-good client cache; writes blocked
Retrybackoff on 5xx/network; no auto-retry on 429; bulk retry = failed subset only

Screen: Flags list (all / module / enabled)

EndpointGET /feature-flags (feature-flags.controller.ts:23-28)
Query?module=<string> optional → findByModule else findAll
Success200 data: [FeatureFlagDoc…] sorted by key asc (feature-flags.service.ts:11) — plain array, no meta (not paginated)
Errors401; 5xx
Enabled tabGET /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 flowfetch 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

EndpointGET /feature-flags/:key (feature-flags.controller.ts:36-40)
Success200 data: doc
Errors404 RESOURCE_NOT_FOUND "Feature flag "" not found." (feature-flags.service.ts:29); 401
Notesoft-deleted flags are invisible (scoped isDeleted:false) → 404

Screen: Flag editor (create/edit) — single upsert

EndpointPUT /feature-flags (feature-flags.controller.ts:42-46) — upsert: create or update
BodyUpdateFeatureFlagDto: {key: string (req), enabled: boolean (req), label?: string, description?: string, module?: string} (update-feature-flag.dto.ts:4-26)
Success200 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)
Errors400 VALIDATION_ERROR (missing key/enabled, wrong types — details[]); 5xx incl. E11000 duplicate on re-create after soft-delete (OQ-4)
Client flowcreate mode: new key → new doc; edit mode: key read-only, label/enabled editable; never claim description/module saved

Screen: Bulk update

EndpointPUT /feature-flags/bulk (feature-flags.controller.ts:48-52)
BodyJSON array of UpdateFeatureFlagDto (feature-flags.controller.ts:50)
Success200 data: [docs] — applied sequentially, no transaction, no rollback (feature-flags.service.ts:40-44)
Errors400 whole-body validation (array-item errors in details[]); 5xx mid-loop → partial apply
Client flowper-row results by key diff; retry failed subset only

Screen: Delete flag

EndpointDELETE /feature-flags/:key (feature-flags.controller.ts:54-58)
Success200 (envelope, data absent/void) — soft delete by _id (feature-flags.service.ts:47-52, base.repository.ts:68-74)
Errors404 first-guard (findByKey) / second-guard (softDelete returns falsy → 404) (feature-flags.service.ts:48-51); 401
Notekey 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)

EndpointGET /organizations/:id/feature-flags (organizations.controller.ts:74-79)
Success200 data: {flagKey: boolean…} from org.metadata.featureFlags (organizations.service.ts:139-142); empty object when unset
EndpointPATCH /organizations/:id/feature-flags (organizations.controller.ts:81-89)
Bodyplain Record<string, boolean> (organizations.controller.ts:86) — no DTO, no validation
Semanticsfull replace: $set: {'metadata.featureFlags': flags} (organizations.service.ts:149-151) — omitted keys are deleted
Success200 data: {flagKey: boolean…} (updated map)
Errors404 org missing; 401; (id is org doc id — platform/org-admin context)
Client flowload map → merge locally → PATCH full merged map; diff preview (proposed)
Notemetadata 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

ScreenLoadingStreamingRealtime
listAppSkeleton(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
detailskeleton
editorsave spinner
bulkprogress bar

Client-side error mapping table (module)

ScreencodeUI
any401silent refresh; fail → sessionExpired
detail404AppEmptyState "Flag not found — deleted?"
editor save400field errors from details[]
editor save5xxsnackbar + requestId; form retained
toggle5xxrollback switch + snackbar
bulk400offending rows highlighted
bulk5xx midpartial results + "retry failed"
delete404treat as already-removed
org overlay404org 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)

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: "FeatureFlagsCubit gates UI per tenant (biometric, SMS, WhatsApp channels)"; also referenced at auth/13 §9: "FeatureFlagsCubit not used (auth module runs pre-feature-gate)"). Backed by FeatureFlagsRepository (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 of enabled:true docs. The admin surfaces also fetch full docs (GET /feature-flags, :key) — the cubit keeps a light FlagMeta mirror (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 server flag?.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 with CACHE_ARCHITECTURE.md:48 30 s poll; server cache is (planned)) while app foregrounded; also refresh on app.resume, on connectivity restore, and on RefreshIndicator (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) topic featureflag.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 by AppAffectedMapCard, 07/05 §5). Used only for the affected-features map; gating itself needs no registry — any widget calls isEnabled(key).

2. Per-screen Cubits

ScreenCubitEvents → State
ListFlagsListCubitLoad, 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}}
DetailFlagDetailCubitLoad(key), Toggle(enabled), Delete(), Save(dto), Refresh{LoadState, flag?, pending, deleted}
EditorFlagEditorCubitInit(flag?, createMode), Submit(dto){form, saving, fieldErrors, saved(doc), notSaved: {description, module}}
BulkBulkUpdateCubitApply(keys, state, label?), RetryFailed(), Dismiss() → `{applying, progress, results: Map<key, ok
Org overlay (context)OrgFlagsCubitLoad(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)); RefreshIndicator bypasses 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 eventCubit methodRepository call
app boot / resume / TTLflagsCubit.refresh()GET /feature-flags/enabled
screen open (admin)list.load()GET /feature-flags or ?module= / /enabled
row switch fliplist.toggle(key, v)PUT /feature-flags {key, enabled:v, label}
detail toggledetail.toggle(v)PUT /feature-flags …
editor saveeditor.submit(dto)PUT /feature-flags
bulk applybulk.apply(keys, state, label)PUT /feature-flags/bulk (array)
bulk retry failedbulk.retryFailed()PUT /feature-flags/bulk (failed subset)
delete confirmlist.delete(key) / detail.delete()DELETE /feature-flags/:key
org overlay saveorgFlags.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 are loading|loaded).
  • List: last-good per filter; detail: none; editor/bulk: none.
  • TTL 30 s (proposed); RefreshIndicator and admin toggle success force immediate refresh.

6. Realtime

  • No realtime today. (planned): WS topic featureflag.changed to user rooms → FeatureFlagsCubit applies the new state immediately (subscribes via repository subscribe(channel) per 00-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

ActionErrorState →
gating refresh401refresh → sessionExpired (gates keep last-good during refresh)
gating refresh5xxstaleError; keep last-good; retry next tick — never gate on error
toggle5xx/400rollback + row error
save400fieldErrors
save404"flag removed" banner → reopen list
bulk400/5xxpartial results + retry
delete404treat-as-removed
org patch404org missing → settings error

8. Testing hooks (00-shared/06 §6)

  • Pure-Dart cubits: FeatureFlagsCubit state machine (initial→loading→loaded→staleError), fail-closed isEnabled, 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 → FeatureFlagsCubit reset + load for the new tenant; on sessionExpired → 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's isEnabled() primitive (feature-flags.service.ts:22-25) and of the roadmap's useFeatureFlags() 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)

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-flags returns all tenant flags sorted by key asc (feature-flags.service.ts:11), plain array, no meta.
  • GET /feature-flags?module=channels returns only matching module (feature-flag.repository.ts:24-26).
  • GET /feature-flags/enabled returns only enabled:true docs (feature-flag.repository.ts:28-30).
  • GET /feature-flags/:key returns the doc; unknown/soft-deleted key → 404 RESOURCE_NOT_FOUND ("Feature flag "" not found." — feature-flags.service.ts:29).
  • PUT /feature-flags with new key creates (upsert:true, feature-flag.repository.ts:38-42); same key updates (merge).
  • Upsert persists exactly enabled + labeldescription/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 key or non-boolean enabled → 400 VALIDATION_ERROR with details[] (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/:key soft-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-flags replaces the whole map (organizations.service.ts:149-151) — QA: PATCH {a:true} then {b:true}a is gone. Merge-first client behavior verified.
  • Flag written via PUT /feature-flags does not appear in org.metadata.featureFlags and 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 follow feature-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 absent module, 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; mono keys ≥ 4.5:1 on surfaceVariant.
  • 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).
  • FeatureFlagsCubit TTL 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 without feature-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.sms off → SMS entry absent; admin enables → within TTL it appears (or on pull-to-refresh immediately).

15 — Flutter Implementation Guide (Feature Flags Module)

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 to ConnectivityCubit for refresh-on-reconnect; reset on tenant switch (listen to AuthCubit).
  • 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, keep notSaved flags 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: isEnabled returns false for missing keys (feature-flags.service.ts:24 mirrored); during loading render 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.toggled helper 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: FeatureFlagsCubit state machine + fail-closed isEnabled + TTL timer (fake-async); FlagsListCubit toggle rollback; BulkUpdateCubit partial results; DTO→model mapper (including absent module → "Ungrouped").
  • Widget: list filter/toggle/selection; editor warning banner; bulk results; FlagGate three 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.builder for groups (flatten groups → index); const constructors; selector (BlocSelector) on enabledKeys so 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

  1. When the server enforces feature-flags.* (OQ-5), wire permissionGuards — UI is already permission-shaped.
  2. When the Redis flag cache + invalidation land (CACHE_ARCHITECTURE.md:33,48, OQ-7), enable the AppFlagToggleBanner "~30 s" copy and drop the client TTL to match.
  3. When description/module persist (OQ-3) and rollout fields exist (OQ-6), extend the editor form (one-line enablement per 08 §1) + percentage/audience controls.
  4. When a WS featureflag.changed topic exists (OQ-7), subscribe in FeatureFlagsCubit and bypass the TTL.
  5. Analytics wiring waits the shared AnalyticsService interface (00-shared/10 §8).

01 — Product Overview (Fees 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, and docs/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.

ResponsibilitySource
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 invoicesfees.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 + receiptspayments.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 queuefee-reminder.job.ts:16-46
Repeatable scheduler jobs (daily cron)scheduler.service.ts:48-119
Tenant scoping + soft-delete on every querybase.repository.ts:20-30

2. Business goals

GoalMeasure
No invoice duplicatesunique index {tenantId, studentId, feeStructureId, academicYearId} + service 409 (invoice.schema.ts:58-60, fees.service.ts:107-113)
No double-counted paymentsunique idempotencyKey; replay returns existing payment (payment.schema.ts:40-41, fees.service.ts:148-152)
Dues always computabledue = totalAmount − paidAmount per invoice (fees.service.ts:213-216)
Cross-tenant isolationevery query tenant-scoped via BaseRepository.scopedFilter (base.repository.ts:20-30)
Audit + notification trailFeeStructureCreated / 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

DependencyRoleSource
Payments moduleOnline capture (gateway), refund, reconcile, receiptspayments.controller.ts:21-73, payments_v2 schema
Notifications/emailsInvoiceIssued → send-invoice; PaymentCompleted → send-receiptevent-queue-map.ts:41-42
Scheduleroverdue scan + fee-reminder cronsscheduler.service.ts:48-97
BullMQ invoice-generateFinanceWorker (overdue marking; event handling)finance.worker.ts:16-91
BullMQ payment-reminderreminder fan-out jobsfee-reminder.job.ts:26-39
Collectionsfee_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 idempotencyKey returns the original payment (no dup).
  • Zero cross-tenant leaks in any fees list/detail (BaseRepository scope).
  • Dues balance renders exact: totalAmount − paidAmount from 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 idCastError → 400 VALIDATION_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 as discounts[].
  • Overpayment has no cap server-side — @Min(0) only (record-payment.dto.ts:19); paidTotal >= totalAmount is 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-generated transactionReference (payments.service.ts:215-217) — two idempotency models.
  • InvoiceIssued / PaymentCompletedemails queue (event-queue-map.ts:41,42) → email worker; the FinanceWorker also handles these event types on invoice-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) but OverdueScanJob.execute / FeeReminderJob.execute are not invoked anywhere, and no worker listens on payment-reminder (OQ-6).
  • Late fees KML: FeeStructure.lateFee exists (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; only fees.collect exists in permissions.constants.ts:31 and 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)

#ItemImpact
OQ-1Money is plain Number in schemas; blueprint says minor units — no rounding guaranteeDisplay/format strategy; currency decimals
OQ-2Overpayment allowed (no cap) — paidTotal >= total flips PAID; excess is never refunded into fees UIOverpay UX; catch-up ledger values
OQ-3GET /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 removedReport UI can only sum locally
OQ-4Payments module is a separate payments_v2 stack keyed on transactionReference, linked via invoiceId — two parallel collectionsWhich data feeds a "payments history" screen
OQ-5Two 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 paidAmountReconciliation display
OQ-6Overdue/reminder jobs registered but their .execute() is never called; no payment-reminder workerReminder + overdue status "(planned)"
OQ-7Late fees: field exists, behavior absent"Late fee" UI deferred
OQ-8Invoice generation is synchronous today; INVOICE_GENERATE queue + FinanceWorker exist but generation never enqueuesBulk generation later (planned)
OQ-9RBAC: no @Permissions on fees endpoints; only fees.collect (+ payments.*, receipts.read) are in ALL_PERMISSIONSRole-gated UI waits for perms
OQ-10Receipt print/share has no endpoint — receipt is a stored doc (receipt.schema.ts:9-40); print is client-side representationPrint/share receipt (proposed)

11. Glossary (this module)

TermMeaning
Fee structurefee_structures doc: per class+term, line-item items[], totalAmount, currency, dueDate, lateFee, isActive (fee-structure.schema.ts:8-38)
Invoiceinvoices 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)
Receiptreceipts doc with receiptNumber RCP-{ts}-{n} (payments.service.ts:180-198)
Duesoutstanding 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)

Who touches fees, what they can do on the current backend, and what the client should do for them. Permissions reflect permissions.constants.ts and the system roles in role.schema.ts:42-48 — fees endpoints today are guarded by JwtAuthGuard only (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 defaults XAF, lateFee defaults 0 (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 a totalAmount that 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 via GET /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 doublesfees.service.ts:148-152). Payment method must be cash|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 (PARTIAL is derived from paidTotal >= 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) — child studentId comes from the Students/Profiles module (no parent-scoped endpoint). Online pay goes through Payments: POST /payments (process-payment.dto.ts), receipts via GET /payments/receipts (payments.controller.ts:63-67).
  • Role perms: default Parent = ['student.read'] (role.schema.ts:50-56); no fees.* 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 receipts doc.

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:208 discounts, :211 statement).

5. Platform Admin (cross-tenant support)

  • Summary: support agent; BaseRepository.scopedFilter bypasses 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

ScreenAdmin/BursarAccountantParentOrg admin
Fee structures list/detailCRUD (fees.controller.ts:28-56)readread
Student invoices✓ (:64-68)child only
Record payment✓ (till)via POST /paymentsview only
Dues✓ (76-80)child dues (derived)✓ (e.g. aggregation)
Payments v2 + receiptspayments.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)

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

  1. 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).
  2. Create structurePOST /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 stamps dueDate Date, defaults currency='XAF', lateFee=0, isActive=true (fee-structure.schema.ts:27-37).
  3. Server emits FeeStructureCreatedin-app queue job fee-structure-created (fees.service.ts:49-56, event-queue-map.ts:40).
  4. List refreshes (200 envelope, doc incl. _id, tenantId, createdAt… from BaseSchema).
  5. (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

  1. 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).
  2. 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).
  3. totalAmount = max(0, structure.totalAmount − Σ discounts); status issued, issuedAt=now, dueDate copied from structure (fees.service.ts:117-130).
  4. InvoiceIssued event → emails queue job send-invoice (fees.service.ts:131-138, event-queue-map.ts:41).
  5. Parent sees it under GET /fees/students/:studentId/invoices (fees.controller.ts:64-68).
  6. (planned) Bulk/async generation for a whole class → INVOICE_GENERATE (queue.constants.ts:8) + FinanceWorker exist (finance.worker.ts:16-91) but generateInvoice runs synchronously today (OQ-8).

3. Journey: Collect a payment at the till (fees module)

  1. Cashier opens Dues → GET /fees/dues?page&limit — only status issued|partial|overdue (fees.service.ts:190-212); each row due = totalAmount − paidAmount (fees.service.ts:213-216).
  2. 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).
  3. 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).
  4. Status recomputed: paidTotal >= total → paid else partial (fees.service.ts:168-176); PaymentCompletedemails/send-receipt (fees.service.ts:178-185, event-queue-map.ts:42).
  5. Dues row updates (or disappears when paid).

4. Journey: Pay online (payments module) (forward-looking)

  1. 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).
  2. Server: transactionReference = TXN-{ts}-{uuid8} (payments.service.ts:215-217), payment stored in payments_v2 with status: completed by default (payments.service.ts:36-49).
  3. Receipt auto-created RCP-{ts}-{n} (payments.service.ts:180-198).
  4. If invoiceId given → linkToInvoice recomputes invoice paidAmount + status (payments.service.ts:200-213).
  5. Gateway async path: PATCH /payments/:transactionRef/reconcile body {status:'success'|'failed'|…} → completed/failed/pending (payments.controller.ts:36-43, payments.service.ts:113-134).
  6. Refund → POST /payments/refund (only completed; partial allowed) (payments.controller.ts:30-34, payments.service.ts:74-111).
  7. Receipts read via GET /payments/receipts[:id] (payments.controller.ts:63-73).

5. Journey: Reminder + overdue lifecycle (planned)

  1. Daily fee-reminder cron 0 8 * * * UTC on payment-reminder queue (scheduler.service.ts:91-97).
  2. FeeReminderJob scans issued|partial invoices with now ≤ dueDate ≤ now+3d, enqueues send-payment-reminder jobs (fee-reminder.job.ts:16-42).
  3. Daily overdue-scan cron 0 6 * * * UTC on invoice-generate (scheduler.service.ts:48-55); FinanceWorker marks issued|partial invoices past due as overdue (finance.worker.ts:75-91).
  4. Status today: crons registered, but .execute() is never called and no worker listens on payment-reminder → deliveries/overdue marking not active (OQ-6). UI must render overdue chips when the status arrives, and treat reminders as absent until wired.

6. Journey: Reconciliation (support)

  1. GET /payments/invoice/:invoiceId (payments.controller.ts:57-61) vs GET /fees/students/:studentId/invoices — note the two payment collections (fees payments vs payments_v2) and two sumByInvoice implementations (fees sums all statuses fees/repositories/payment.repository.ts:23-35, payments sums only completed payments/repositories/payment.repository.ts:25-37) — OQ-5.
  2. Mismatch handling: mark refunds/partials on the v2 side; invoice paidAmount recomputed on next post (linkToInvoice).

7. Journey: Report on dues (admin) (proposed)

  1. No dedicated report endpoint — GET /fees/reports from 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"; :211 per-student statement).
  2. Per-student statement → GET /fees/students/:studentId/invoices + per-invoice GET /payments/invoice/:invoiceId composed client-side (proposed).

04 — Information Architecture (Fees Module)

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)

RouteScreenBackendSource
/feesFees home (dues summary + quick actions)GET /fees/duesfees.controller.ts:76-80
/fees/structuresFee structures listGET /fees/structuresfees.controller.ts:34-38
/fees/structures/newCreate structurePOST /fees/structuresfees.controller.ts:28-32
/fees/structures/:idStructure detailGET /fees/structures/:idfees.controller.ts:40-44
/fees/structures/:id/editEdit structurePATCH /fees/structures/:idfees.controller.ts:46-50
/fees/students/:studentId/invoicesStudent invoicesGET /fees/students/:studentId/invoicesfees.controller.ts:64-68
/fees/invoices/:idInvoice detail(compose: student invoices + GET /payments/invoice/:id)payments.controller.ts:57-61
/fees/invoices/:id/payRecord payment (till)POST /fees/invoices/:id/paymentsfees.controller.ts:70-74
/fees/duesDues listGET /fees/duesfees.controller.ts:76-80
/fees/dues/reportDues report (proposed)client-side aggregation
/payments/:transactionRefPayment detail (v2)GET /payments/:idpayments.controller.ts:51-55
/payments/receipts/:idReceipt view/printGET /payments/receipts/:idpayments.controller.ts:69-73
/fees/remindersReminders (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

FromActionTo
Dues rowtapInvoice 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 (always completed today).
  • 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)

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); only issued|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/structures paginated (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 from isActive, 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/academicYearId are immutable after create — not in UpdateFeeStructureDto (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|cancelledinvoice.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/:id endpoint exists — OQ-3); payment history via GET /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|otherrecord-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/dues paginated (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: noneGET /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 from PaymentStatus incl. partially_refunded, payments/schema/payment.schema.ts:18-26), detail with gatewayResponse, 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-reminder on payment-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)

StatusChipSource
invoice draftneutralinvoice.schema.ts:8
invoice issuedprimary outline:9
invoice partialtertiary:10
invoice paidsuccess:11
invoice overdueerror:12
invoice cancelledneutral:13
payment pending/processing/failed/refunded/partially_refunded/cancelledper statuspayments/schema/payment.schema.ts:18-26
structure isActivesuccess/neutralfee-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: .next through forms, Ctrl/Cmd+Enter submit on desktop, N = new structure (desktop).

06 — Screen Specifications (Fees Module)

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): XAF 0 decimals, USD 2 decimals, client-side AppMoney helper (see 15_Flutter_Implementation_Guide.md §4).


0. Amount display & currency rules (module-wide)

  • Every amount (totalAmount, paidAmount, due, payment amount, refundedAmount, receipt amount, fee, structure items[].amount, totalAmount, lateFee, discounts) is a plain Number on 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 (default XAF, fee-structure.schema.ts:27-28) → copied conceptually to invoice; v2 payments default USD (payments/schemas/payment.schema.ts:42-43), receipts default USD (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 — due comes from the server (fees.service.ts:213-216). Client-side totals only for display (structure items vs totalAmount sanity check).
  • Formatting: NumberFormat.currency from intl, locale from app; XAF = 0 decimals (CFA has no minor units), USD = 2 decimals; always FontFeature.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"; error AppErrorState + retry; offline banner + cached dues (TTL 5 min, 00-shared/06 §3.3).
  • A11y: stat tiles = Semantics(label: "Total due {amount}"); row = one Semantics(button).
  • Dark/light: token-only; overdue amounts use error in 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: AppDialog confirm — copy must say soft-delete: "Delete this fee structure? Existing invoices are kept." → DELETE /fees/structures/:id (fees.service.ts:95-98) → row fade-out Mo.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; N shortcut = 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) → inline AppBanner(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"
  • due is not on this endpoint — the client derives display via totalAmount − 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):

StatusLightDarkIcon
draftneutral outlineneutral outlinedescription
issuedprimaryContainerprimaryContainermail
partialtertiaryContainertertiary-tintedhourglass
paidsecondaryContainersecondary-tintedcheck_circle
overdueerrorContainererrorContainerschedule
cancelledneutralneutralcancel

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 (paidAt ISO IsDateString, 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); if status == paid show 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 resulting paidAmount preview.
  • 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"
  • due value 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, cancelledpayments/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; include fee, 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|partial and now ≤ 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

ScreenLoadingRefreshRealtime
Fees homeAppSkeletonpull(planned) WS invoice.updated (00-shared/07 §8) → re-fetch dues
Structures listskeletonpullre-fetch on focus
Structure detailskeletonpull
Student invoicesskeletonpullre-fetch on focus (payment may land)
Invoice detailskeleton (invoice+payments parallel)pullinvoice.updated (planned)
Duesskeletonpullinvoice.updated (planned)
Payments listskeletonpull
Record paymentbutton 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)

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 AppMoney helper (15_Flutter_Implementation_Guide.md §4).


A. Fees representation

MoneyAmount

  • Purpose: canonical amount rendering (mono, tabularFigures).
  • Props: amount (num), currency (default from context: structure currency default XAF fee-structure.schema.ts:27-28; v2 USD payments/schemas/payment.schema.ts:42-43), style (display|title|label), zeroState ("Settled"/"Free").
  • Rules: intl.NumberFormat.currency; XAF 0 decimals, USD 2; never "-0"; always FontFeature.tabularFigures().
  • Flutter: Text + TextStyle.monospace wrapper AmountText.

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|cancelledinvoice.schema.ts:7-14), paymentStatus? (PaymentStatus payments/schemas/payment.schema.ts:18-26), boolStatus? (structure isActive).
  • Flutter: custom StatusChipContainer + 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-based DueRow (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/titleLarge mono).

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 warning AppBanner.
  • Props: items, totalAmount, currency.
  • Behaviour: if Σ items ≠ totalAmount show 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 RepaintBoundary for 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) per record-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: AppBottomSheet picker 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 due is 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. No waive, 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)

Every form field-by-field. Validation mirrors class-validator decorators exactly from src/modules/fees/dto/*.ts; errors arrive as 400 VALIDATION_ERROR with error.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)

#FieldLabelKeyboardValidation (server)Client UX
1nameNametext@IsString() required (:28-29)required; autofocus; TextInputAction.next
2classIdClass@IsMongoId() required (:32-33)AppDropdown (Academics classes); immutable after create
3academicYearIdAcademic year@IsMongoId() requiredAppDropdown; immutable after create
4items[]Line itemsrequired@IsArray() + @ValidateNested FeeItemDto (:39-43)LineItemsEditor — dynamic list of {name, amount}
4aitems[].nameItem nameyes@IsString() (fee-item dto :16-18)text; required
4bitems[].amountItem amountyes@IsNumber() @Min(0) (:20-23)MoneyField; ≥ 0; currency context
5totalAmountTotalrequired@IsNumber() @Min(0) (create-fee-structure.dto.ts:45-48)auto-sum hint Σ items; editable; flag mismatch (server won't)
6currencyCurrencyno@IsOptional() @IsString(); server default XAF (:50-53, fee-structure.schema.ts:27-28)AppDropdown default XAF (USD etc. future (proposed))
7dueDateDue daterequired@IsDateString() (:55-57), server converts to Date (fees.service.ts:46-47)AppDatePicker; client min = today
8lateFeeLate feeno@IsOptional() @IsNumber() @Min(0); default 0MoneyField optional; unused by backend today (OQ-7)
9isActiveActiveno@IsOptional() @IsBoolean(); default trueAppSwitch default on
  • Server sends dueDate as ISO string; store Date in schema (fee-structure.schema.ts:30-31, fees.service.ts:88).
  • SubmitPOST → 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)

#FieldRequiredServer validationClient UX
1nameno@IsOptional() @IsString()prefilled
2items[]no@IsOptional() + nestedprefilled; full replace on PATCH (fees.service.ts:86-89 — whole array swaps)
3totalAmountno@IsOptional() @IsNumber() @Min(0)prefilled
4currencyno@IsOptional() @IsString()prefilled
5dueDateno@IsOptional() @IsDateString()picker; past-due warned (server allows)
6lateFeeno@IsOptional() @IsNumber() @Min(0)prefilled
7isActiveno@IsOptional() @IsBoolean()prefilled switch
  • Immutable: classId, academicYearId are 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)

#FieldLabelRequiredValidation (server)Client UX
1studentIdStudentrequired@IsMongoId() (:28-29)AppDropdown (Students module); searchable
2feeStructureIdFee structurerequired@IsMongoId() (:32-33)hidden — bound to the structure being viewed
3academicYearIdAcademic yearrequired@IsMongoId() (:36-37)from active academic year selector
4discounts[]Discountsno@IsOptional() @IsArray() + nested DiscountDto (:40-42)DiscountEditor (name + amount ≥ 0)
4adiscounts[].namenameyes (per item)@IsString() (:16-18)text
4bdiscounts[].amountamountyes (per item)@IsNumber() @Min(0) (:20-22)MoneyField ≥ 0
  • Side effects (server): totalAmount = max(0, structure.totalAmount − Σ disc) (fees.service.ts:117-119); status issued, issuedAt: now (fees.service.ts:122-130); InvoiceIssuedemails/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) → inline info banner, do not close dialog.
  • Preview: client shows totalAmount after discounts before submitting.

4. Record Payment Form (sheet) — POST /api/v1/fees/invoices/:id/payments

(record-payment.dto.ts:11-44)

#FieldLabelRequiredValidation (server)Client UX
1invoiceIdInvoicerequired@IsMongoId() (:14-15)hidden from route :id — never editable
2amountAmountrequired@IsNumber() @Min(0) (:17-19)MoneyField, prefilled = due; overpay warn allowed (OQ-2); shows currency
3paymentMethodMethodrequired@IsString() + enum list (:22-25) — not @IsEnum, invalid string is 400 validationPaymentMethodSelector; values cash, bank_transfer, mobile_money, cheque, other (payment.schema.ts:7-13)
4referenceReferenceno@IsOptional() @IsString()optional; placeholder "Bank ref / MoMo tx id"
5idempotencyKeyidempotency keyrequired@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`
6paidAtPaid onrequired@IsDateString() (:36-38), server → Date (fees.service.ts:164)AppDatePicker default today; no future-date cap server-side (2026)
7notesNotesno@IsOptional() @IsString()optional, maxLength 500 (client)
  • Success: 201 payment doc; server sets status: completed always (fees.service.ts:162-166) and recomputes invoice paidAmount + 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):

FieldRequiredServer validationClient
amountyes@IsNumber() @Min(0)MoneyField
currencyno@IsOptional() @IsString(); default USD (:13-15)locked to tenant/display
gatewayyes@IsEnum(PaymentGateway)stripe,paypal,flutterwave,paystack,razorpay,cash,bank_transfer,cheque (payments/schemas/payment.schema.ts:7-14)embed/gateway SDK (forward-looking)
gatewayTransactionIdnooptional stringgateway response
invoiceIdnooptional stringfrom invoice detail
invoiceTypenooptional string
payerId/Email/Namenooptional stringspayer identity
descriptionnooptional stringline item text

Refund:

FieldRequiredValidationClient
paymentIdyes@IsString()hidden
amountno@IsOptional() @IsNumber() @Min(0); server caps refundedAmount + amount ≤ amount (payments.service.ts:81-86)default = remaining
reasonnooptional stringtext
  • Refund rules: only completed payments 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; .next sequence; Ctrl/Cmd+Enter submits (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

  1. 400 VALIDATION_ERROR → field errors (map details[].message by field hint, http-exception.filter.ts:103-108).
  2. 404 RESOURCE_NOT_FOUND → empty/back state.
  3. 409 DUPLICATE_RESOURCE → info banner (invoice exists / already paid / replay).
  4. 422 BUSINESS_RULE_VIOLATION → banner (currently unused by fees; reserved).
  5. 429 → countdown; 5xx → keep form + retry.

09 — User Behaviour (Fees Module)

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 − paidAmount is produced by GET /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 idempotencyKey returns 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/paid by paidTotal >= totalAmount (fees.service.ts:168-176); overdue is 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 holds issued/partial (worker not yet run) is styled identically client-side but flagged (proposed).

2. Common mistakes & countermeasures

MistakeCountermeasure (client)
Double-tap "Record payment"disable while posting + reuse the same idempotencyKey on retry (record-payment.dto.ts:31-34)
Wrong amount paidprefill amount = due; MoneyField >= 0; overpay inline warning (server allows, OQ-2)
Posting to a paid/cancelled invoicehide the action when `paid
Misreading a 409 replay as failurerender as success — the returned doc is the original payment (fees.service.ts:148-152)
Items sum != totalAmountLineItemsTable warning banner; auto-sum helper on create (server never validates)
Confusing XAF vs USDcurrency 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 paiduse server paidAmount/dues; never hand-sum v2 logs (OQ-5 two sumByInvoice implementations)
Editing class/year after createfields absent from UpdateFeeStructureDto (update-fee-structure.dto.ts:15-45) — disabled + note
Offline payment attemptblocked with guidance (no offline write queue for fees; 00-shared/07 §10)

3. Power & new users

  • Power (cashier): remember last payment method; paidDate = today default; 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; MoneyField announces 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/status from totals (fees.service.ts:168-176).
  • Parent pays online while a cashier posts offline: both recorded; linkToInvoice sums all (payments.service.ts:200-213).
  • Lists vs version drift: re-fetch on focus + pull-to-refresh + invoice.updated WS (planned) topic (00-shared/07 §8).

6. Interrupted sessions & retries

  • Failure mid-POST payment: same idempotencyKey retried → 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 to shared_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)

FrustrationTreatment
Duplicate-invoice 409info banner + link to the existing invoice, dialog open
Pay on paid/cancelled invoiceclose sheet + info banner, never an error dialog
Overdue backlog with 5-10 rowsgroup/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 endpointinvoice 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)

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 bottom AppProgress.
  • 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 + Share action (proposed); long-press number to copy.

2. Keyboard / desktop / mouse

  • Amounts: decimal numeric pad; .next field order; Ctrl/Cmd+Enter submit 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-entrance stagger 40 ms; card fade for deletion Mo.m-base.
  • Amount changes animate by counting (AnimatedSwitcher/IntrinsicCounter) over Mo.m-base, only when server-confirmed — never optimistic.
  • Status chip swaps: AnimatedSwitcher Mo.fast, cross-fade only.
  • Sheet: standard spring up Mo.m-base; dialog Mo.m-base scale+fade.
  • Reduced-motion: all Mo.m-instant static; no counting animation, no parallax.

5. Micro-interactions catalogue

InteractionTriggerFeedback
Record payment submittap FilledButtonspinner (icon→spinner), disabled inputs, live-region announce
Generate invoicedialog submitspinner; then snackbar "Invoice issued"
Refresh duespullRefreshIndicator; success silent
Overpay attempt (amount > due)edgeinline tertiary banner "Exceeds remaining balance" + preview resulting paidAmount
Amount focusMoneyFieldselect-all, hide grouping suffix until blur
Copy referencelong-press on transactionReference rowssnackbar "Copied {ref}"
Back from dirty sheetsystem-back(proposed) "Discard payment?" dialog
409 duplicate-invoicesubmit responseinline AppBanner(info), dialog stays
409 paid/cancelledsubmit responseclose + 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).
  • (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 interactionServer semantics (must not diverge)
Sheet "Record payment" successserver 201 + recompute paidAmount/status (fees.service.ts:168-176)
Sheet retry with same keyreplay original doc (fees.service.ts:148-152)
Generate invoice successserver 201, _id returned; issuedAt set server-side (fees.service.ts:122-130)
Duplicate generation 409treated as already-exists navigation (fees.service.ts:107-115)
Refund amount reductionserver caps refunds ≤ payment (payments.service.ts:81-86)
Online paymenttransactionReference from server (payments.service.ts:215-217); local refs never sent
Any amount displayedsource of truth = server field; formatting client-side only (06 §0; OQ-1)

11 — Design System Mapping (Fees Module)

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)

PurposeRecipe (light / dark)Used for
Paid / settledsuccessContainer / onSuccessContainerpaid chip, settled banners
Partial / pendingwarningContainer / onWarningContainerpartial chip, "amount due" emphasis
OverdueerrorContainer / onErrorContainer + error iconoverdue chip, overdue amounts
IssuedprimaryContainer / onPrimaryContainerissued chip, info banners
Money emphasisonSurface mono tabular (02 §2)totals, dues, receipts
Money secondaryonSurfaceVariantlabels, per-item amounts
Negative/refunderrorrefund amounts
Zero stateonSurfaceVariant"Settled" text

Semantic roles: success (paid), warning (partial), error (overdue/refund) — standalone custom roles per 00-shared/02 §1.1.

2. Widget → token

WidgetTokens
AmountTextbodyLargedisplaySmall mono, tabularFigures, onSurface/onSurfaceVariant
StatusChip*Container role colors, labelMedium, R-r-sm, e-0
InvoiceCardsurfaceContainerLow, R-r-lg, e-1
DueRowsurface, R-r-md, e-0; row height ≥ 56
LineItemsTablesurfaceContainerLow, R-r-md; mismatch banner tertiaryContainer
ReceiptDocumentsurfaceContainerHigh, R-r-lg, e-1; mono for numbers
MoneyFieldsurfaceVariant fill, outline border, error on error, focus primary
Payment sheetsurfaceContainerHigh, R-r-xl, e-5

3. Typography mapping

TokenUse
displaySmallinvoice total, dues headline amounts
titleLargescreen titles (Fees, Dues)
titleMediumcard titles, section headers, amounts on cards
bodyMediumsecondary rows (structure meta, payment refs)
bodySmallcaptions ("Reduces total at issue time")
labelMediumchips, badges
monoall 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:

IconUsage
paymentsfees destination, collect action
receipt_longreceipt view
descriptioninvoice doc, draft chip
mailissued chip
hourglasspartial chip
check_circlepaid chip
scheduleoverdue chip, past-due dates
cancelcancelled chip
walletpayment method
content_copycopy reference
qr_codereceipt QR (forward-looking)

Every icon has a text label or tooltip+Semantics (02 §7).

5. Motion tokens (values 00-shared/02 §6)

TokenWhere
Mo.m-instantchip cross-fades under reduced motion, value changes
Mo.m-faststatus chip AnimatedSwitcher, hover tints
Mo.m-basepayment sheet spring, dialogs, card fade on delete, amount counting
Mo.m-entrancelist item stagger (40 ms)
Mo.m-slowskeleton shimmer

6. Contrast & dark parity

  • All amount text ≥ 4.5:1 (onSurface on surface); secondary amounts onSurfaceVariant ≥ 3:1 minimum per 02 §10.
  • Status chips: text + icon + container colors must pass 4.5:1 in both modes (verify tertiaryContainer/errorContainer pairs).
  • 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

WidthBehaviour
< 600 dpsingle pane; payment sheet full-height; bottom nav
600–839 dp2-column lists; sheet as right drawer (proposed)
840–1199 dpmaster-detail (dues list + invoice detail), nav rail
≥ 1200 dprail + full-width report (proposed); hover states

8. Theming rules (enforced)

  1. No color in widget code — tokens only (02 §10).
  2. Amounts always mono + tabular + currency — no exceptions.
  3. Status never color-only (09 §9 → icon + text + color).
  4. Tenant branding overrides primary only (02 §1); fees semantics fixed.
  5. AppTheme.light()/dark() unchanged; module adds constants only (AppSpacing, AppRadius, AppMotion).

12 — API Mapping (Fees Module)

Exact wire contract for every screen → endpoint. Base /api/v1; envelope per 00-shared/07. All endpoints from src/modules/fees/controllers/fees.controller.ts and src/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

AspectContract
Basehttps://api.<domain>/api/v1
HeadersAuthorization: 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)
Paginationpage (≥1), limit (1–100, default 20), sort (-field), q (pagination-query.dto.ts:5-30); meta = {page, limit, totalItems, totalPages, hasNext, hasPrevious} (:32-55)
Idempotencyfees payments: client idempotencyKey; payments-v2: server transactionReference
Cachingreads cached client-side (structure 24 h, dues 5 min); no cache on money-write responses
Offlinereads from cache; writes blocked (no fees offline queue)
Retrybackoff on 5xx/network; no auto-retry on 429 (api tier 100/min)

Screen: Fee structures list — GET /fees/structures

EndpointGET /api/v1/fees/structures (fees.controller.ts:34-38)
Querypage, limit, sort, q (accepted; q unused in service today — client filters)
Success200 data: [FeeStructureDoc…] + meta — skip/limit (page−1)*limit (fees.service.ts:62-71)
Errors400 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
EndpointGET /api/v1/fees/structures/:id (fees.controller.ts:40-44)
Success200 data: FeeStructureDoc
Errors404 RESOURCE_NOT_FOUND "Fee structure not found." (fees.service.ts:76); 400 invalid id (http-exception.filter.ts:47-48 CastErrorVALIDATION_ERROR)

Screen: Create structure — POST /fees/structures

Method
EndpointPOST /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)
Success201 data: FeeStructureDoc (server defaults currency='XAF', lateFee=0, isActive=true)
Side effectemits FeeStructureCreated {feeStructureId, name, classId, academicYearId, totalAmount}in-app/fee-structure-created (fees.service.ts:49-56, event-queue-map.ts:40)
Errors400 VALIDATION_ERROR (decorators: IsMongoId, IsArray, IsNumber Min(0), IsDateString, …)

Screen: Edit structure — PATCH /fees/structures/:id

Method
EndpointPATCH /api/v1/fees/structures/:id (fees.controller.ts:46-50)
Bodyany subset of {name, items, totalAmount, currency, dueDate, lateFee, isActive} (update-fee-structure.dto.ts:15-54) — classId/academicYearId immutable
Success200 doc; $set + version+1 (fees.service.ts:85-89, base.repository.ts:57-66)
Errors404 (fees.service.ts:91); 400 invalid dueDate/items

Screen: Delete structure — DELETE /fees/structures/:id

Method
EndpointDELETE /api/v1/fees/structures/:id (fees.controller.ts:52-56)
Success200 {message:"OK"}soft delete (fees.service.ts:95-98, base.repository.ts:68-74); invoice preserved, no cascade
Errors404 (fees.service.ts:97)

Screen: Generate invoice — POST /fees/invoices/generate

Method
EndpointPOST /api/v1/fees/invoices/generate (fees.controller.ts:58-62)
Body{studentId, feeStructureId, academicYearId, discounts?:[{name,amount}]} (generate-invoice.dto.ts:24-42)
Success201 data: InvoiceDoctotalAmount = max(0, structure.totalAmount − Σ discounts), status:'issued', issuedAt:now, dueDate from structure (fees.service.ts:117-130)
Side effectInvoiceIssued {invoiceId, studentId, feeStructureId, totalAmount, dueDate}emails/send-invoice (event-queue-map.ts:41)
Errors404 "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
NoteGeneration 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
EndpointGET /api/v1/fees/students/:studentId/invoices (fees.controller.ts:64-68)
Success200 data: [InvoiceDoc…] non-paginated (no meta, fees.service.ts:142-144)
Errors400 invalid id; 5xx

Screen: Record payment (till) — POST /fees/invoices/:id/payments

Method
EndpointPOST /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)
Success201 data: PaymentDoc(fees)status: 'completed' always (fees.service.ts:162-166); invoice paidAmount+status recomputed (fees.service.ts:168-176)
IdempotencyidempotencyKey unique (payment.schema.ts:40-41); replay returns the existing payment — no double-count (fees.service.ts:148-152)
Side effectPaymentCompleted {paymentId, invoiceId, amount, status}emails/send-receipt (event-queue-map.ts:42)
Errors404 "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
EndpointGET /api/v1/fees/dues (fees.controller.ts:76-80)
Querypage, limit (applied; sort/q not applied by the service) — service hard-filters status ∈ {issued,partial,overdue} (fees.service.ts:192-212)
Success200 data: [{invoice: InvoiceDoc, due: totalAmount−paidAmount}] (fees.service.ts:213-216); meta
Errors400 invalid ints; 5xx

Payments module (online) — shared screens (v2)

ScreenEndpoint
Payment capturePOST /api/v1/payments (body process-payment.dto.ts:5-54) → 201 {payment, receipt}; transactionReference server-generated (payments.service.ts:35-51)
RefundPOST /api/v1/payments/refund (body refund-payment.dto.ts:4-19); only completed, partial allowed (payments.service.ts:74-111)
ReconcilePATCH /api/v1/payments/:transactionRef/reconcile body {status} (payments.controller.ts:36-43); success→completed, failed→failed, else→pending (payments.service.ts:121-127)
Payment listGET /api/v1/payments?page&limit (defaults 1/20; :45-49)
Payment detailGET /api/v1/payments/:id (:51-55)
By invoiceGET /api/v1/payments/invoice/:invoiceId (:57-61)
Receipts listGET /api/v1/payments/receipts (:63-67)
Receipt detailGET /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

ScreenLoadingStreamingRealtime
Dues / fees homeAppSkeleton(planned) WS invoice.updated (00-shared/07 §8)
Structures listskeletonre-fetch on focus
Structure detailskeleton
Student invoicesskeletonre-fetch on focus
Invoice detailinvoice + payments Future.waitinvoice.updated (planned)
Record paymentbutton spinnerupload (none)
Payments listskeleton
Receiptdoc render

Client-side error mapping (module)

ScreencodeUI
record payment409 DUPLICATE_RESOURCEinfo banner (paid/cancelled) or success (replay)
generate invoice409"already exists" banner + link
any list/detail404 RESOURCE_NOT_FOUNDAppEmptyState
create/update400 VALIDATION_ERRORfield errors
any401 → refresh → failsessionExpired
any429 RATE_LIMITEDcountdown, no retry
any5xxgeneric + 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

InvoiceIssuedemails/send-invoice, PaymentCompletedemails/send-receipt (event-queue-map.ts:41-42); FeeStructureCreatedin-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)

Per-screen Cubits (Flutter/bloc; proposal, 00-shared/06) backed by FeesRepository + PaymentsRepository calling 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 from GET /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); RefreshIndicator bypasses.

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/sort on GET /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; RefreshIndicator bypasses.

4. FeeStructureDetailCubit

  • State: {status, structure?, items[], sumMismatch: bool}.
  • Load: GET /fees/structures/:id → doc; computes sumMismatch = Σ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} — mirrors create-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 (immutable classId/academicYearId disabled).
  • totalAmount auto-synced to Σitems when line items change (editable override); sumMismatch shown.
  • Errors: 400 → fieldErrors (map details[].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) WS invoice.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 (no GET /fees/invoices/:id endpoint; OQ-3), payments from GET /payments/invoice/:invoiceId (payments.controller.ts:57-61).
  • PaymentRecorded event 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: idempotencyKey is generated ONCE per form life-cycle; retries after network failure reuse it — the server replays the original payment (fees.service.ts:148-152) → conflictSuccess and duplicate-ish success, never a double record.
  • Form mirrors record-payment.dto.ts:11-44; paymentMethod from PaymentMethod enum (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 /payments paginated (page/limit defaults 1/20, payments.controller.ts:45-49); status chips from PaymentStatus (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/receipts paginated; detail: GET /payments/receipts/:id (payments.controller.ts:63-73).
  • State: {status, receipts[], receipt?, printing: bool} — print/share (proposed) via RepaintBoundary capture → system share.

11. Shared cross-cutting

  • AuthCubit 401 → 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.
  • FeatureFlagsCubit gates (forward-looking) online-pay / QR receipt, and (planned) reminder surfaces per tenant.
  • Realtime: subscribe invoice.updated topic (planned) → notify Dues + InvoiceDetail cues to re-fetch (00-shared/06 §3.4).

12. Testing hooks (00-shared/06 §6)

  • Unit: RecordPaymentCubit 409→success, 409→conflictPaid, retry-same-key; money formatting/derived due; 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)

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; dueDate stored as Date; defaults (currency='XAF', lateFee=0, isActive=true) applied (fee-structure.schema.ts:9-37).
  • Create with missing classId/academicYearId/items/totalAmount/dueDate → 400 VALIDATION_ERROR with details[] (create-fee-structure.dto.ts:26-68).
  • Edit: classId/academicYearId not accepted (absent from update-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); status issued, issuedAt set (: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/invoices immediately (fees.service.ts:142-144).

Payments (fees)

  • Record payment → 201; status: 'completed'; invoice paidAmount updated (fees.service.ts:162-176).
  • Partial payment (< due) → invoice partial; dues list shows remaining due (fees.service.ts:168-172,213-216).
  • Full payment (>= due) → invoice paid; row leaves dues list (fees.service.ts:194-201 filters paid out).
  • Overpayment (amount > due) → accepted (no cap); invoice → paid (fees.service.ts:169-172) — record the resulting paidAmount display (OQ-2).
  • Idempotency replay: re-POST with the SAME idempotencyKey → same payment doc, paidAmount unchanged (fees.service.ts:148-152); UI shows success.
  • Pay on paid/cancelled invoice → 409 "Invoice is already paid or cancelled." (fees.service.ts:155-160).
  • Invalid paymentMethod string → 400 (enum list in record-payment.dto.ts:21-25, payment.schema.ts:7-13).

Payments v2 / receipts

  • POST /payments{payment, receipt}; transactionReference format TXN-{ts}-{uuid8} (payments.service.ts:215-217); receipt RCP-{ts}-{n} (:180-198).
  • linkToInvoice recomputes invoice paidAmount/status when invoiceId given (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, else pending (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-41 unique index is the final arbiter; fees.service.ts:148-152 first line).
  • Cashier + online payment overlap → linkToInvoice sums 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 + RepaintBoundary on 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-POST payment → 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+Enter shortcuts.

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.collect for Accountant, role.schema.ts:42-48) degrade gracefully when server adds @Permissions; hide actions on 403.

10. Dark mode

  • All chips/amounts/banners pass 02 §10 parity; no hardcoded colors (11 §8).

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).
  • idempotencyKey never 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)FeeReminderJob enqueues one send-payment-reminder job per invoice without a dedup key (fee-reminder.job.ts:25-41); overlapping cron runs would re-enqueue (no jobId/dedup) → when the worker lands, QA must verify no duplicate reminders per invoice per day (OQ-6).
  • Overdue marking (planned)finance.worker.ts:75-91 flips issued|partial past-due → overdue; verify chips + dues filtering after the cron fires (repeatable job 0 6 * * * UTC, scheduler.service.ts:48-55).
  • Late feelateFee field 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)

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-216 dues; 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 amount Text styles.

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 in Hive/shared_preferences keyed fees:dues:{tenant}:{filter}, TTL 5 min (00-shared/06 §3.3); structure list TTL 24 h; receipts 24 h.
  • Render offline: FeesHomeCubit/DuesListCubit show cached dues + due snapshot with AppOfflineBanner("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.valuenever 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: AppMoney formatting (XAF 0-dec, USD 2-dec, number 0 → Settled, locales); RecordPaymentCubit (409 success/replay/paid, retry-same-key); derived due; structure-form sumMismatch; 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 + RepaintBoundary on receipt doc.
  • Invoice detail Future.wait (invoice + payments) — single skeleton.
  • Amount grid TextField does not rebuild the sheet on each keystroke (controller → ValueNotifier).
  • const constructors; memoize AmountText output; counting animation only under normal motion.

Proposals flagged to the team

  1. When server adds a single-invoice endpoint (GET /fees/invoices/:id) and a per-student dues endpoint, drop invoice-detail composition (OQ-3).
  2. When fees.* permission set expands (only fees.collect + payments/readership exist), wire all hidden actions to real perms (OQ-9).
  3. When the payment-reminder worker + endpoint land, enable Reminders screen and invoice.updated realtime (OQ-6).
  4. When late-fee behavior exists (field only today), render computed late fees (OQ-7).
  5. Analytics wiring waits shared AnalyticsService (00-shared/10 §8), events per 05 §Analytics.

01 — Product Overview (Files Module)

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 uploadThumbnail 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 BaseRepositoryFile sharing, versioning, folders UI
RBAC-permission decorators on all routesStreaming / 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). No file.update / file.share.
  • Every record is tenant-scoped via TenantContextService.requireTenantId() (files.service.ts:28) and soft-delete filtered by BaseRepository (repositories/file.repository.ts:9-15).
  • Storage key convention: R2 uses <tenantId>/<uuid>--<originalName> (r2.provider.ts:39); local disk uses storage/<tenantId>/<uuid>--<originalName> (local-storage.provider.ts:24-27); blueprint recommends sl/{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; mimetype is client-supplied (files.service.ts:32-33).
  • Delete = storage object delete + metadata soft-delete (files.service.ts:66-70).

5. Goals

  1. Reliable attach/download of school documents from any module screen.
  2. Tenant isolation by construction (repository scoping + provider key prefix).
  3. 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_URL config (Phase 1.1, IMPLEMENTATION_PLAN.md:32; R2_PUBLIC_URL absent from src/config/env.ts today) (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)

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, sorted createdAt: -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 file field 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 (no file.update permission exists).
  • Pain points: no multi-file upload; large scans held fully in server memory (FileInterceptor buffer → 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.read only (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-data with field name file (files.controller.ts:31-37), parses the returned FileRecord JSON, later pulls GET /:id/download (Content-Type echoed from stored mimeType, files.controller.ts:60).
  • Pain points: no signed-URL flow wired into the service (provider getSignedUrl exists, storage-provider.ts:25, but FilesService never calls it) — (planned).

Persona → permission matrix

ActionEndpointPermissionAdminOfficeTeacherStudent
UploadPOST /files/uploadfile.upload
List/getGET /files GET /files/:idfile.read
DownloadGET /files/:id/downloadfile.read
DeleteDELETE /files/:idfile.delete

03 — User Journeys (Files Module)

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 (FileInterceptor memory 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 Range support → 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)

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/height are declared number but typed String in the schema prop (file.schema.ts:39-43) — schema bug to fix when thumbnails land (planned).
  • Files are context-agnostic documents: no folderId, no module, no entityRef field. 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

LayerKey shapeSource
Local driverstorage/<tenantId>/<uuid>--<originalName>local-storage.provider.ts:24-27
R2 driver<tenantId>/<uuid>--<originalName>r2.provider.ts:39
Appwrite driverbucket APPWRITE_BUCKET_ID, object id = plain UUIDappwrite-storage.provider.ts:38-44
Blueprint targetsl/{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).
  • storageFileId doubles as storagePath in the service (files.service.ts:41-42); the filename on the key is the raw original name — no sanitization of originalName anywhere (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

FlowReadWrite
UploadStorage upload → Mongo create
ListMongo find (tenant-scoped, createdAt desc)
DownloadMongo findById → Storage download
DeleteMongo findByIdStorage 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)

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)

FieldDetail
PurposeList a context's files (student, fee, circular) newest-first
SourceGET /api/v1/files → array createdAt desc (files.service.ts:48-50); client filters by its context key
WidgetsAppListTile rows: FileTypeIcon + originalName, mimeType, size, date; trailing AppMenu
Entrycontext screen "Files" section; pull-to-refresh re-fetches
Statesloading skeleton; empty AppEmptyState; offline AppOfflineBanner; permission (403) → locked tile
Permissionsfile.read (files.controller.ts:44)
Row actionsDownload (GET /:id/download), Detail sheet, Delete (admin, file.delete)
Analyticsfiles.list.refresh, files.list.open_detail (proposed)
NoteNo server pagination/filter — large tenants get full lists; client-side filter only (proposed)

2. File Picker / Upload Sheet (bottom sheet)

FieldDetail
PurposePick a local file → upload via multipart
Routesheet on context screen; FAB or "Attach" affordance
SourcePOST /api/v1/files/upload, field file (files.controller.ts:29-41)
Permissionsfile.upload (files.controller.ts:30)
Contentpicker row (system picker / camera (forward-looking)), selected-file preview card, Upload CTA
Statesidle → picking (system) → selected (validate) → uploading (progress, cancelled) → success/error
Cancellationclient-side abort; no server cancel endpoint
Analyticsfiles.upload.pick, files.upload.start, files.upload.success, files.upload.failure(code) (proposed)

3. Upload Progress (inline card / dialog)

FieldDetail
PurposeShow multipart transfer progress (client-computed; no server progress API)
WidgetsAppProgress + filename + size + Cancel text button
Terminal statessuccess → tile appears; error → inline retry (POST re-send, idempotency not server-enforced — retry creates a new record)
NoteServer 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)

FieldDetail
PurposeFetch GET /:id/download and deliver bytes to OS
Permissionsfile.read (files.controller.ts:56)
WidgetsAppProgress row/notification; cancel aborts transport, not server work
Terminal statesdone → open with system viewer (share intent (forward-looking)); fail → retry (no Range/resume)
NoteServer sets Content-Disposition: attachment (files.controller.ts:61) — the OS decides save vs open

5. File Detail Sheet (/files/:id)

FieldDetail
PurposeMetadata + actions for one file
SourceGET /api/v1/files/:id → 404 File not found (files.service.ts:54)
Rowsname, type, size, uploaded date, provider id (dev only)
ActionsDownload; Delete (if file.delete); Copy name
Statesloading, not-found (deleted elsewhere → leave list), permission

6. Delete Confirmation Dialog

FieldDetail
PurposeConfirm permanent object deletion
SourceDELETE /api/v1/files/:id{ message: 'File deleted' } (files.controller.ts:66-71)
Permissionsfile.delete (files.controller.ts:67)
Copydestructive: "Delete file? The stored copy is removed permanently."
Terminalsuccess → optimistic removal; 404 → already gone, remove row silently

7. Permission-Denied / Locked State

FieldDetail
PurposeSurfaced when a role lacks file.read / file.upload / file.delete
Trigger403 from guard (planned); role lookup client-side fallback
WidgetsAppEmptyState 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 fields thumbnailFileId etc. 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)

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, createdAt desc (files.service.ts:48-50). No page/limit, no filter params — client holds files.byContext index (13_State_Management.md).
  • Response shape (envelope per 00-shared/07_API_Conventions): FileRecord[] with {id, originalName, mimeType, size, storageProvider, createdAt, updatedAt}. storageFileId/storagePath are server-internal; client must not render them.

1.3 States

StateBehavior
loadingAppSkeleton rows (n=6)
successgrouped rows, newest first; size formatted KB/MB (size, file.schema.ts:16)
emptyAppEmptyState "No files yet — attach the first one" + Attach CTA
errorinline banner + retry (re-run GET /files); offline via AppOfflineBanner
permission403 → locked row style + "Ask admin for access" (file.read missing)
disabledupload 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 button AppMenu exposes actions to a11y.
  • Motion: rows m-fast fade/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 name file (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)

RuleValueVerdict
File requiredblock Upload; sheet error "Choose a file"
Size capnone enforced (gap)client soft-cap 25 MB w/ warning; hard user block (proposed)
MIME allowlistnone server-sideclient may gate common doc/image types (proposed)
Filenameany (un-sanitized, files.service.ts:31)display-only; quote in header Content-Disposition (files.controller.ts:61)

2.4 States

StateBehavior
idlepicker rows only; Upload disabled
pickingsystem picker open (platform sheet)
selectedpreview card; Upload enabled; re-pick replaces selection
uploadingUploadProgressCard (§2.5); Cancel available
successsheet closes → tile appears in list (optimistic insert, rollback on fail)
errorinline: 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, see 15_Flutter_Implementation_Guide.md), size, Cancel text button.
  • Cancel: aborts the client request only; a partially-sent body is discarded by server (no partial-object cleanup job yet — (planned) per STORAGE_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) → 404 File not found (files.service.ts:54) when missing or soft-deleted (repository filters).

3.3 States & behavior

StateBehavior
loadingskeleton card
not foundsheet closes; row removed from list; snackbar "File no longer available"
permission403 → lock + admin note

4. Download Flow

4.1 Behavior

  • Tap download → GET /api/v1/files/:id/download (files.controller.ts:55-64).
  • Service: findByIdstorage.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

StateBehavior
startingprogress row appears (0%) — client-computed from transport
progress% bytes received; cancel allowed (no server resume; no Range support)
successweb: browser save; mobile: system notification + "Open" share (forward-looking)
failureinline 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.delete holders 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

StateBehavior
confirmingdestructive AppDialog (red confirm, m-fast)
pendingbutton spinner; double-tap ignored
successoptimistic row removal + snackbar; no undo endpoint (proposed: restore)
error403 (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/height fields, 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)

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 (from FileRecord.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) + trailing AppMenu.
  • 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: AppBottomSheet shell; picker rows (system file picker; camera (forward-looking)); selected-file preview; primary Upload AppButton.
  • 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, Cancel text button.
  • Behavior: driven by transport onSendProgress (dio) or XMLHttpRequest upload 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 DownloadTask rows: 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: AppDialog destructive variant; message "Delete originalName? The stored copy is removed permanently."
  • Behavior: confirm → DELETE /files/:id; spinner on confirm; error inline; no undo.

7. EmptyFilesState

  • AppEmptyState variant: icon, "No files yet", caption, optional "Attach first file" CTA (shown only with file.upload).

8. StorageInfoBar (proposed)

  • Optional tenant quota line (server has no quota API — purely informational from client-aggregated size sums; 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

AspectValueSource
EndpointPOST /api/v1/files/uploadfiles.controller.ts:29-41
Content-Typemultipart/form-dataApiConsumes, files.controller.ts:32
Field namefile (single, binary)FileInterceptor('file'), files.controller.ts:31
Success201 + FileRecordservice returns persisted doc, files.service.ts:45
Authfile.uploadfiles.controller.ts:30

1.2 Fields

FieldTypeRequiredServer validationNotes
file (binary)Bufferyesnone (gap)client supplies originalname, mimetype, size; server stores verbatim (files.service.ts:32-33)

There is no DTOdto/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

RuleClient behaviorServer today
File selectedblock Upload until selected
Size 0 bytesblock: "Empty file"accepts (gap)
Size > 25 MBwarn + confirm (soft cap) (proposed)accepts (no limit anywhere — gap; consider multer limits.fileSize (planned))
MIME allowlistoptional gating on common types (proposed)accepts any mimetype string (spoofable — QA item, 14_QA_Checklist.md)
Filename sanitystrip control chars client-side onlystored raw into key ${uuid}--${originalName} (files.service.ts:31) and into Content-Disposition header (files.controller.ts:61)

1.4 Error mapping

HTTPMeaningUI
400malformed multipart (missing file part)sheet error "Choose a file"
401 / 403unauthenticated / no file.uploadauth flow / permission state (guards (planned))
413payload too large — when a multer limit lands (planned)size copy + suggestion
500storage provider failure (storage.upload throws, files.service.ts:29-34)"Upload failed — retry" (no server idempotency; retry = new record)
offlinetransport errorAppOfflineBanner + 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 Upload on failure.

2. Future forms (planned)

  • Signed-URL request: GET /files/:id/signed-url?expiresInSeconds=3600 → provider getSignedUrl(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.update permission exists (permissions.constants.ts:86-88) — skip until a module needs it.

09 — User Behaviour (Files Module)

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 file part (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 Range headers served). Cancel is client-side only.
  • Trust the mimeType… carefully. The Content-Type header is the client-supplied mimeType echoed 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.read see no files; without file.upload no Attach control; without file.delete no 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-stream or 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)

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 → AppBottomSheet slides 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-fast fade+slide 8 dp); Upload CTA 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: AppProgress fills 0→100% with per-frame value from transport onSendProgress; percent label; bytes counter 1.2 MB / 3.2 MB.
  • Indeterminate until first progress event (server buffering) — AppProgress indeterminate 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-base slide + 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 → AppDialog destructive variant: title + file name + red Delete (danger color token), Cancel ghost.
  • Confirm: button spinner (no double-tap), row removal animates out (m-base collapse), 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 standard RefreshIndicator; no haptic unless completion > 500 ms (platform default).

11. Motion budget summary

InteractionTokenNote
Sheet open/closem-basebottom slide
Row insert/removem-basecollapse + highlight pulse
Preview card swapm-fastfade + 8 dp slide
Error shakem-fast8 dp
Dialog confirmm-fastscale+fade

11 — Design System Mapping (Files Module)

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 groupFiles usage
Color — primaryUpload CTA fill, active progress bar
Color — dangerDelete dialog confirm, error progress, cancel emphasis
Color — successupload/download completion check
Color — surface/textfile tiles, detail sheet rows (surface-1 / text-primary-secondary)
Color — info"Large file" warnings
Typefile name = title-medium; metadata line = body-small + text-secondary
Spacinglist rows space-16; sheet padding space-24; card gaps space-8
Radiussheets/cards use radius-lg; progress card radius-md
Elevationbottom sheets per tokens; cards flat in list
Motionm-fast / m-base as per 10_Interaction_Specification.md
IconsFileTypeIcon uses the token icon set + mime map

2. Component → system mapping

Files componentSystem basis (00-shared/03)Files-specific extension
FileTileAppListTile+ FileTypeIcon, size/date subline, conditional menu items
UploadSheetAppBottomSheet + AppButton+ picker rows, preview card, validation
UploadProgressCardAppProgress + AppButton(text)+ bytes counter, cancel
DownloadManagerSheetAppBottomSheet + AppProgress+ per-task list (queued/active/done)
DeleteConfirmDialogAppDialog destructive+ file-name interpolated copy
EmptyFilesStateAppEmptyState+ attach CTA (conditional)
Permission stateAppEmptyState+ lock variant
OfflineAppOfflineBannerglobal, no module variant

3. Semantics by state (files-specific)

StateColorIconCopy pattern
uploadingprimaryprogress"Uploading name — 40%"
successsuccesscheck"Uploaded name" / "Downloaded name"
errordangeralert"Upload failed — retry"
emptyneutralfile-outline"No files yet"
lockedneutral dimlock"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-small AA.

12 — API Mapping (Files Module)

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

#MethodPathPermissionSourcePurpose
1POST/api/v1/files/uploadfile.uploadfiles.controller.ts:29-41Upload single file (multipart)
2GET/api/v1/filesfile.readfiles.controller.ts:43-47List tenant files, createdAt desc
3GET/api/v1/files/:idfile.readfiles.controller.ts:49-53File metadata; 404 if missing
4GET/api/v1/files/:id/downloadfile.readfiles.controller.ts:55-64Download raw bytes (attachment)
5DELETE/api/v1/files/:idfile.deletefiles.controller.ts:66-71Storage delete + metadata soft-delete

2. Detail

2.1 POST /api/v1/files/upload — 201

  • Request: multipart/form-data, part file (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/storagePath are 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 via BaseRepository (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: findByIdstorage.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

PermissionEndpointsNote
file.readlist, get, downloadpermissions.constants.ts:86
file.uploaduploadpermissions.constants.ts:87
file.deletedeletepermissions.constants.ts:88

No file.update/file.share — matching UI (no replace, no share UI).

4. Planned / forward-looking endpoints

EndpointStatusSource
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,66r2.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/CubitEndpoint(s)
FilesCubit (list)GET /files
UploadCubitPOST /files/upload
Detail sheetGET /files/:id
DownloadCubitGET /files/:id/download
Delete flowDELETE /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; byContext derived 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/upload with onSendProgress (dio) driving progress (see 15_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 DownloadTask in state).
  • Cancel: abort dio request; no resume (no server Range) — retry restarts from 0.
  • Completed: localPath from path_provider temp dir; "Open"/"Share" (forward-looking).

1.4 FileDetailCubit — detail sheet

  • Loads GET /api/v1/files/:id; 404 → emit FileGone → 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

  • byContext linkage index: cached in app prefs (shared LocalStore) (proposed) — rebuilt from GET /files on 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)

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, size matching the multipart part (files.service.ts:32-33).
  • List returns newest-first (createdAt desc, files.service.ts:48-50).
  • GET /files/:id returns 404 for unknown and soft-deleted ids (files.service.ts:54, repository soft-delete filter).
  • Download sets Content-Type = stored mimeType and Content-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: FileInterceptor buffer → 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 serves Content-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-stream round-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 getSignedUrl ignores expiresInSeconds (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 /files does not include it; GET /files/:id with 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); local storage/<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.delete matrix 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 (healthCheck throws, 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.delete throws; 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, height fields never populated (file.schema.ts:31-43) — verify no client code reads them.

15 — Flutter Implementation Guide (Files Module)

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.fromFile must be wrapped in a map with key file.
  • Progress: dio onSendProgress gives (sent, total). Until the first event (server buffering), show indeterminate AppProgress (10_Interaction_Specification.md §3).
  • Cancel: CancelToken per 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 holds file.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 in byContext (see 13_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 (no Range) — retry restarts from 0.
  • Open: open_filex / url_launcher for the temp path; Share via share_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: UploadCubit state machine (cancel, retry, success path); byContext grouping.
  • Widget: FileTile labels; 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.ts and docs/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 HealthCheckResult shape: status (ok|error), info, error, details; individual dependencies report up/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 the public tier (health.controller.ts:16, rate-limit.constants.ts:4, IMPLEMENTATION_PLAN.md:48).
ResponsibilitySource
Terminus registrationhealth.module.ts:8-17 (imports TerminusModule, declares 3 custom indicators)
Combined check endpointhealth.controller.ts:27-37
MongoDB indicatorTerminus 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 wiringapp.module.ts:87
Public tier rate limitrate-limit.constants.ts:4, IMPLEMENTATION_PLAN.md:48

2. Business goals

GoalMeasure
One-probe dependency visibilityall 4 indicators answered in a single request (health.controller.ts:31-36)
Orchestrator-ready signals200 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 containment30 req/min sliding-window, Redis-backed (rate-limit.constants.ts:4)
Partial-failure detailerror + 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/ready and GET /api/v1/health/live appear in docs/user-flows/END_TO_END_USER_FLOWS.md:738-740 but no such routes exist in health.controller.ts today; the single combined check covers both concerns - marked (planned) throughout.
  • Disk and memory indicators - Terminus ships DiskHealthIndicator / MemoryHealthIndicator but neither is wired; only mongodb, redis, storage, bullmq are checked (health.controller.ts:31-36).
  • Uptime / version fields - the response carries no uptime, process version, or build hash; HealthCheckResult is 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 in permissions.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 tenantId scoping; health is a global infrastructure concern (AGENTS.md tenancy 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/health on a fixed interval.
  • Contract: 200 = healthy, 503 = drain (health.controller.ts:27-37, Terminus @HealthCheck throws ServiceUnavailableException on 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/error keys 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 → read status; on error, read error.<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 ping value (redis-health.indicator.ts:16); BullMQ reports pendingJobs per 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 all up, escalates with tenant context; if a dependency is down, 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

PersonaSurfaceAuthSource
LB / orchestratorGET /api/v1/healthnone (public) + rate 30/minhealth.controller.ts:15-16
DevOpsendpoint + (proposed) screennone for endpointrate-limit.constants.ts:4
Support(proposed) screen(proposed) - no health.* permission exists todaypermissions.constants.ts:1-97
Admin(proposed) screen(proposed)-

03 - User Journey (Health Module)

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', info still lists the healthy deps, error.<key>.status === 'down' names the culprit (health.controller.spec.ts:21-36).

2. DevOps incident journey (human)

  1. Detect - probe alert, or user-reported slowness.
  2. Triage - curl /api/v1/health; read data.status.
    • ok → API and its four dependencies are reachable; problem is upstream (network, DNS, tenant-specific), not the platform.
    • error → read data.error for the down key (mongodb | redis | storage | bullmq).
  3. Diagnose - down key names the console to open:
    • redis → check the ping detail was lost on failure (redis-health.indicator.ts:17-22 throws without payload).
    • bullmq → pending-job counts were the last healthy signal (bullmq-health.indicator.ts:30); a growing pendingJobs smells like a stalled worker before a hard down.
  4. Resolve - fix the dependency; next probe flips the instance back to 200; LB re-routes automatically.
  5. Confirm - re-curl; info lists all four up (health.controller.spec.ts:23-28).

3. Support engineer journey (screen (proposed))

  1. Open App Health screen; auto-poll runs (13).
  2. Cards: API (implicit 200), DB, Redis, Queues, Storage (05 S1). A red card names the culprit - no curl needed.
  3. Tap Retry for an immediate re-check instead of waiting for the next poll tick (06 S1).
  4. Screenshot the card states + last-check time into the ticket.

4. School admin journey (forward-looking)

  1. Parent reports "app is down" → admin opens App Health.
  2. Green cards → "working here; issue may be device/network" → generic status communication.
  3. Red cards → confirm platform issue → relay to support with the card screenshot.
  4. Admin cannot fix anything from this screen - read-only (proposed).

5. Key moments of truth

MomentSource truth
Endpoint answers fast, all up200 + data.status === 'ok'
One dependency down503 + data.error names it; others remain up in info
All dependencies down503; info empty/absent, error lists all keys
Rate-limit pressure429 (sliding window; probes on 5s intervals risk it - keep ≥ 10s)

04 - Information Architecture (Health Module)

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: /api global prefix, URI versioning, default version 1 (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/live and /api/v1/health/ready (liveness/readiness split) exist only in docs/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)           
NodeContentState source
API cardimplicit - the request itself succeededHTTP 200 of the call
DB carddata.info.mongodb.status (health.controller.ts:32)up/down
Redis carddata.info.redis.status + ping (redis-health.indicator.ts:16)up/down
Storage carddata.info.storage.status (storage-health.indicator.ts:19)up/down
Queues carddata.info.bullmq.status + pendingJobs (bullmq-health.indicator.ts:30)up/down

3. Information priorities

  1. Overall state - one glance: all green / one red.
  2. Culprit identification - which dependency, from data.error (health.controller.spec.ts:29-35).
  3. Freshness - last-check time (client-side timestamp of the 200/503).
  4. Detail - pendingJobs backlog 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)

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)

FieldDetail
PurposeOps overview: are API, DB, Redis, Queues, Storage reachable right now?
Entryops menu, support hot-link, incident deep link /ops/health
Exitback to ops zone; no downstream screens (read-only surface)
Data sourceGET /api/v1/health - data.info + data.error (health.controller.ts:27-37, health.controller.spec.ts:21-36)
CompositionAppBar "App Health" → overall status banner → 5 status cards (API, DB, Redis, Queues, Storage) → last-check time + retry button
WidgetsAppCard, AppBadge, AppSkeleton, AppEmptyState, AppOfflineBanner, AppButton (00-shared/03); module widgets StatusCard, StatusDot, PendingJobsTile (07)
Statesloading (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)
Pollingauto-poll every 30 s while foregrounded (13 §2); manual retry always available
Analyticshealth.screen.open, health.screen.poll_success, health.screen.poll_failure(503), health.retry.tap
Adaptivephone single column; tablet/desktop 2-column card grid; landscape scroll
a11yper-card semantics label "DB: up"; overall banner as live region on state change (00-shared/09)
Motioncard flash on state change (m-fast); banner collapse animation (00-shared/08)

2. Incident Detail Sheet (per failed card) (proposed)

FieldDetail
PurposeShow what the endpoint knew about the failure - no extra API call
Entrytap a red card
Contentfailed key (mongodb/redis/storage/bullmq), data.error.<key> payload, Queues card shows pendingJobs (bullmq-health.indicator.ts:30), last-check time
Exitdismiss sheet
Statesstatic - renders the already-fetched payload; no network
a11ysheet focus-trapped; content readable by screen reader
NoteFor 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

#EventResult
1Enter screenHealthCubit.fetch() immediately (13 §2); 5 skeleton cards
2200, all upbanner "All systems operational"; cards green; last-check = now
3503, partialbanner "1 of 5 degraded — Redis is down"; red card + detail sheet affordance; healthy cards stay green (health.controller.spec.ts:29-35)
4503, all downbanner "All systems degraded"; all cards red; API card also red
5Network error / timeoutAppOfflineBanner + cards show last known state + "stale" badge; Retry prominent
6429 rate-limitedcountdown on Retry (remaining seconds from Retry-After-style client math); poll paused
7Retry tapimmediate single fetch (rate-safe: skips poll tick on success)
8Poll tick (30 s)silent refetch; only state changes animate (m-fast)
9App backgroundedpoll paused; resume → immediate refetch (13 §3)
10Tap red cardincident detail sheet (S2) from cached payload - no network

Data mapping

CardPayload keyExtraSource
APIimplicit (request reached server)-health.controller.ts:27-37
DBdata.info.mongodb.status / data.error.mongodb-health.controller.ts:32
Redisdata.info.redis.statusping value when upredis-health.indicator.ts:16
Storagedata.info.storage.status-storage-health.indicator.ts:19
Queuesdata.info.bullmq.statuspendingJobs (emails, in-app, webhook-deliver, dlq)bullmq-health.indicator.ts:30
Last-checkclient 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)

FieldSpec
Triggertap any card with down state
Contentfailed 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)
CTAnone beyond close; Retry lives on S1 (avoid duplicate retry paths)
Notesrenders exclusively from the cached fetch - zero network calls; keeps parity with endpoint truth

07 - Component Library (Health Module)

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

PropertySpec
PurposeOne dependency's availability + optional detail line
Slotsleading StatusDot, title (API, DB, Redis, Storage, Queues), status text (up/down), optional detail line (ping: PONG), optional trailing chip
Statesup (green), down (red), stale (amber + "stale" badge), loading (skeleton)
Semantics`label: ": <up</td></tr> <tr><td>Usage</td><td>S1 card grid; tap target only when <code>down</code> (avoid dead taps)</td></tr> </tbody></table> </div> <pre><code class="language-dart">// 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, ) </code></pre> <h2 id="2-statusdot"><a class="header" href="#2-statusdot">2. StatusDot</a></h2> <div class="table-wrapper"><table><thead><tr><th>Property</th><th>Spec</th></tr></thead><tbody> <tr><td>Purpose</td><td>10 dp filled circle; color + semantics, never color alone</td></tr> <tr><td>Colors</td><td><code>success</code> green / <code>error</code> red / <code>warning</code> amber (design tokens <code>00-shared/02</code>)</td></tr> <tr><td>a11y</td><td>`Semantics(label: 'up'</td></tr> </tbody></table> </div> <h2 id="3-pendingjobstile"><a class="header" href="#3-pendingjobstile">3. PendingJobsTile</a></h2> <div class="table-wrapper"><table><thead><tr><th>Property</th><th>Spec</th></tr></thead><tbody> <tr><td>Purpose</td><td>Shows BullMQ backlog - the only quantitative signal in the module</td></tr> <tr><td>Data</td><td><code>data.info.bullmq.pendingJobs</code> - array <code>[emails, in-app, webhook-deliver, dlq]</code> (<code>bullmq-health.indicator.ts:27-30</code>)</td></tr> <tr><td>Rendering</td><td><code>emails 3 · in-app 0 · webhook-deliver 8 · dlq 1</code>; dlq count highlighted amber when > 0 (dead-letter backlog is an ops smell)</td></tr> <tr><td>Source note</td><td>counts are <strong>summed per queue</strong> (<code>Object.values(r).reduce</code>, <code>:27-29</code>) - waiting/active/delayed/… collapsed; label must not claim per-state detail</td></tr> </tbody></table> </div> <h2 id="4-lastcheckedlabel"><a class="header" href="#4-lastcheckedlabel">4. LastCheckedLabel</a></h2> <div class="table-wrapper"><table><thead><tr><th>Property</th><th>Spec</th></tr></thead><tbody> <tr><td>Purpose</td><td>"Last checked 12:04:31" - client timestamp of the last 200/503</td></tr> <tr><td>Data</td><td>no server field exists (non-goal, <code>01</code> §5) - local clock only</td></tr> <tr><td>Stale rule</td><td>> 60 s since last success → amber text + "stale" (<code>14</code> QA-4)</td></tr> </tbody></table> </div> <h2 id="5-overallbanner"><a class="header" href="#5-overallbanner">5. OverallBanner</a></h2> <div class="table-wrapper"><table><thead><tr><th>Property</th><th>Spec</th></tr></thead><tbody> <tr><td>Purpose</td><td>One-glance verdict at top of S1</td></tr> <tr><td>Content</td><td>"All systems operational" / "N of 5 degraded — <first down key>" / "All systems degraded"</td></tr> <tr><td>Motion</td><td><code>m-base</code> slide swap; live region announce (<code>00-shared/08</code>)</td></tr> </tbody></table> </div> <h2 id="6-incidentsheet-s2"><a class="header" href="#6-incidentsheet-s2">6. IncidentSheet (S2)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Property</th><th>Spec</th></tr></thead><tbody> <tr><td>Purpose</td><td>Read-only failure detail from cached payload</td></tr> <tr><td>Content</td><td>failed key, monospace <code>SelectableText</code> of <code>data.error.<key></code>, <code>pendingJobs</code> when bullmq, bare-payload notice for redis (<code>redis-health.indicator.ts:17-22</code>)</td></tr> <tr><td>Motion</td><td>bottom sheet per <code>00-shared/03</code></td></tr> </tbody></table> </div> <h2 id="7-not-built-flag-in-code"><a class="header" href="#7-not-built-flag-in-code">7. Not built (flag in code)</a></h2> <ul> <li><strong>History/uptime charts</strong> - endpoint is stateless; no data source <code>(forward-looking)</code>.</li> <li><strong>Threshold config widgets</strong> - no server config surface <code>(proposed)</code> only.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="08---form-specifications-health-module"><a class="header" href="#08---form-specifications-health-module">08 - Form Specifications (Health Module)</a></h1> <ul> <li><a href="health/08_Form_Specifications.html#1-retry-action-s1-proposed">1. Retry action (S1) <code>(proposed)</code></a></li> <li><a href="health/08_Form_Specifications.html#2-poll-cadence-input-s1-internal-proposed">2. Poll cadence input (S1, internal) <code>(proposed)</code></a></li> <li><a href="health/08_Form_Specifications.html#3-explicitly-not-built-proposed--planned">3. Explicitly not built <code>(proposed)</code> / <code>(planned)</code></a></li> <li><a href="health/08_Form_Specifications.html#4-validation-notes">4. Validation notes</a></li> </ul> <blockquote> <p>The Health module has <strong>no data-entry forms</strong>. This file documents the only interactive input (Retry) and the <code>(proposed)</code> configuration surface that is deliberately not built. Backend has no DTOs: the endpoint takes no body, query or params (<code>health.controller.ts:27-37</code> - bare <code>@Get()</code> + <code>check()</code>).</p> </blockquote> <hr /> <h2 id="1-retry-action-s1-proposed"><a class="header" href="#1-retry-action-s1-proposed">1. Retry action (S1) <code>(proposed)</code></a></h2> <p>Not a form - a single async action button:</p> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Spec</th></tr></thead><tbody> <tr><td>Trigger</td><td>tap Retry</td></tr> <tr><td>Behavior</td><td>immediate single <code>GET /api/v1/health</code>; on success, resets the poll timer (skip next tick, <a href="health/13_State_Management.html">13</a> §2)</td></tr> <tr><td>Guard</td><td>disabled while a fetch is in flight; disabled during 429 countdown (<code>rate-limit.constants.ts:4</code> - 30 req/min shared with all clients of the endpoint)</td></tr> <tr><td>Result</td><td>state swap per <a href="health/06_Screen_Specifications.html">06</a> S1 rows 2-4; no snackbar on success (the screen IS the result); error → offline banner</td></tr> <tr><td>Semantics</td><td><code>Button('Retry')</code>; announce result via banner live region</td></tr> </tbody></table> </div> <p><strong>Rate-limit math (client must respect it):</strong> poll every 30 s + manual retries must stay under 30 req/min for the <em>whole deployment</em> - the endpoint is public and shared with LBs (<a href="health/02_User_Personas.html">02</a> §1). Client-only guidance: never auto-retry a 503 in a loop; 429 → back off ≥ 60 s (<a href="health/14_QA_Checklist.html">14</a> QA-5).</p> <h2 id="2-poll-cadence-input-s1-internal-proposed"><a class="header" href="#2-poll-cadence-input-s1-internal-proposed">2. Poll cadence input (S1, internal) <code>(proposed)</code></a></h2> <p>Internal, not user-facing: poll interval fixed at 30 s foreground (<a href="health/13_State_Management.html">13</a> §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.</p> <h2 id="3-explicitly-not-built-proposed--planned"><a class="header" href="#3-explicitly-not-built-proposed--planned">3. Explicitly not built <code>(proposed)</code> / <code>(planned)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Surface</th><th>Status</th><th>Why not</th></tr></thead><tbody> <tr><td>Health "thresholds" form (alert when redis down, etc.)</td><td><code>(proposed)</code></td><td>no server config; alerting out of module scope (<a href="health/01_Product_Overview.html">01</a> §5)</td></tr> <tr><td>Dependency detail form (DB name, queue picker)</td><td><code>(proposed)</code></td><td>nothing user-writable; endpoint fixed 4 indicators (<code>health.controller.ts:31-36</code>)</td></tr> <tr><td>Liveness/readiness toggle (which probes /live runs)</td><td><code>(planned)</code></td><td>split endpoints not implemented (<code>docs/user-flows/END_TO_END_USER_FLOWS.md:738-740</code>); server-side concern anyway</td></tr> </tbody></table> </div> <h2 id="4-validation-notes"><a class="header" href="#4-validation-notes">4. Validation notes</a></h2> <p>No text inputs → no client validation rules, no keyboard types, no autofill, no submit semantics. If a future <code>(proposed)</code> config form ever lands, it must validate client-side before submit and map server 400 details (<code>http-exception.filter.ts:103-108</code>); until then: nothing to validate.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="09---user-behaviour-health-module"><a class="header" href="#09---user-behaviour-health-module">09 - User Behaviour (Health Module)</a></h1> <ul> <li><a href="health/09_User_Behaviour.html#1-poll-dont-push">1. Poll, don't push</a></li> <li><a href="health/09_User_Behaviour.html#2-probe--act-on-status-code-not-body">2. Probe → act on status code, not body</a></li> <li><a href="health/09_User_Behaviour.html#3-one-culprit-at-a-time">3. One culprit at a time</a></li> <li><a href="health/09_User_Behaviour.html#4-detail-is-payload-conditional">4. Detail is payload-conditional</a></li> <li><a href="health/09_User_Behaviour.html#5-retry-discipline">5. Retry discipline</a></li> <li><a href="health/09_User_Behaviour.html#6-trust-gradient-human-screen-proposed">6. Trust gradient (human screen <code>(proposed)</code>)</a></li> <li><a href="health/09_User_Behaviour.html#7-non-behaviour-explicitly-out">7. Non-behaviour (explicitly out)</a></li> </ul> <blockquote> <p>Observed and expected behaviours around the health surface. Machine behaviours are the implemented contract; human behaviours are <code>(proposed)</code> / <code>(forward-looking)</code>. Sources: <code>health.controller.ts:14-37</code>, <code>redis-health.indicator.ts:12-23</code>, <code>bullmq-health.indicator.ts:19-37</code>, <code>rate-limit.constants.ts:4</code>.</p> </blockquote> <hr /> <h2 id="1-poll-dont-push"><a class="header" href="#1-poll-dont-push">1. Poll, don't push</a></h2> <ul> <li><strong>Behaviour:</strong> consumers ask on an interval; the server never pushes health (no WS/SSE in <code>health.module.ts</code>; the <code>WsModule</code> is a separate surface).</li> <li><strong>Consequence:</strong> screen polls every 30 s (<a href="health/13_State_Management.html">13</a> §2); load balancers choose their own cadence ≥ 10 s (<a href="health/03_User_Journey.html">03</a> §5 - the 30/min cap is a hard shared budget).</li> </ul> <h2 id="2-probe--act-on-status-code-not-body"><a class="header" href="#2-probe--act-on-status-code-not-body">2. Probe → act on status code, not body</a></h2> <ul> <li>200 → healthy; 503 → unhealthy. The body (<code>data.status</code>, <code>data.info</code>/<code>data.error</code>) explains <em>which</em> dependency (<code>health.controller.spec.ts:21-36</code>).</li> <li><strong>Trap:</strong> the 503 error body arrives through the error envelope (<code>http-exception.filter.ts:73-81</code>) - <code>data.error.<key></code> carries the down details; do not discard the body on non-2xx (<a href="health/14_QA_Checklist.html">14</a> QA-1).</li> </ul> <h2 id="3-one-culprit-at-a-time"><a class="header" href="#3-one-culprit-at-a-time">3. One culprit at a time</a></h2> <ul> <li>Partial failure keeps healthy deps <code>up</code> in <code>info</code> (<code>health.controller.spec.ts:29-35</code>). Humans read the <em>first red card</em>, fix that dependency, then re-check - the combined check makes cascading diagnosis quick because it always runs <strong>all four</strong> indicators (<code>health.controller.ts:31-36</code>), even when one fails.</li> </ul> <h2 id="4-detail-is-payload-conditional"><a class="header" href="#4-detail-is-payload-conditional">4. Detail is payload-conditional</a></h2> <div class="table-wrapper"><table><thead><tr><th>Indicator</th><th>Detail when up</th><th>Detail when down</th></tr></thead><tbody> <tr><td>mongodb</td><td>none (<code>pingCheck</code>, <code>health.controller.ts:32</code>)</td><td>none</td></tr> <tr><td>redis</td><td><code>ping: 'PONG'</code> (<code>redis-health.indicator.ts:16</code>)</td><td><strong>none</strong> - bare <code>getStatus('redis', false)</code> (<code>:17-22</code>)</td></tr> <tr><td>storage</td><td>none (<code>storage-health.indicator.ts:19</code>)</td><td><strong>none</strong> - bare (<code>:20-24</code>)</td></tr> <tr><td>bullmq</td><td><code>pendingJobs</code> array (<code>:30</code>)</td><td><strong>none</strong> - bare (<code>:31-36</code>)</td></tr> </tbody></table> </div> <ul> <li>Behaviour: never render a "detail" section from an up-only contract; show "no additional detail reported" (<a href="health/06_Screen_Specifications.html">06</a> S2).</li> <li>Ops habit: use <code>pendingJobs</code> while <strong>up</strong> as a leading indicator - growing backlog precedes hard down (<a href="health/03_User_Journey.html">03</a> §2).</li> </ul> <h2 id="5-retry-discipline"><a class="header" href="#5-retry-discipline">5. Retry discipline</a></h2> <ul> <li>Manual retry is the human's tool; auto-retry loops on 503 are the classic abuse (and hit the 30/min shared cap).</li> <li>429 → back off 60 s, surface countdown (<a href="health/06_Screen_Specifications.html">06</a> S1 row 6).</li> </ul> <h2 id="6-trust-gradient-human-screen-proposed"><a class="header" href="#6-trust-gradient-human-screen-proposed">6. Trust gradient (human screen <code>(proposed)</code>)</a></h2> <ol> <li><strong>Fresh + green</strong> = high trust.</li> <li><strong>Stale + green</strong> = low trust - "stale" badge forces attention (<a href="health/07_Component_Library.html">07</a> §4).</li> <li><strong>Red</strong> = the truth the screen exists to surface; no smoothing, no "degraded but fine" copy - the endpoint says <code>down</code>, the UI says <code>down</code> (<code>health.controller.spec.ts:29-35</code>).</li> </ol> <h2 id="7-non-behaviour-explicitly-out"><a class="header" href="#7-non-behaviour-explicitly-out">7. Non-behaviour (explicitly out)</a></h2> <ul> <li>No per-tenant health checks, no tenant-scoped filtering - endpoint is global (<a href="health/01_Product_Overview.html">01</a> §5). Users do not expect school-level health.</li> <li>No write-back from screen to infra; screen is read-only + Retry (<a href="health/04_Information_Architecture.html">04</a> §4).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="10---interaction-specification-health-module"><a class="header" href="#10---interaction-specification-health-module">10 - Interaction Specification (Health Module)</a></h1> <ul> <li><a href="health/10_Interaction_Specification.html#1-endpoint-interaction-contract-implemented">1. Endpoint interaction contract (implemented)</a></li> <li><a href="health/10_Interaction_Specification.html#2-s1-screen-interactions-proposed">2. S1 screen interactions <code>(proposed)</code></a></li> <li><a href="health/10_Interaction_Specification.html#3-state-machine-s1">3. State machine (S1)</a></li> <li><a href="health/10_Interaction_Specification.html#4-motion-map">4. Motion map</a></li> <li><a href="health/10_Interaction_Specification.html#5-accessibility-interactions">5. Accessibility interactions</a></li> </ul> <blockquote> <p>Interaction, state and motion details for the Health surfaces. Shared interaction/motion language in <a href="health/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>. Screens <code>(proposed)</code>; the endpoint interaction contract is implemented and quoted.</p> </blockquote> <hr /> <h2 id="1-endpoint-interaction-contract-implemented"><a class="header" href="#1-endpoint-interaction-contract-implemented">1. Endpoint interaction contract (implemented)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Spec</th><th>Source</th></tr></thead><tbody> <tr><td>Request</td><td><code>GET /api/v1/health</code> - no params, no body, no headers beyond standard</td><td><code>health.controller.ts:27-37</code></td></tr> <tr><td>Auth</td><td>none - <code>@Public()</code> skips JWT/RBAC guards</td><td><code>health.controller.ts:15</code>, <code>app.module.ts:129-131</code></td></tr> <tr><td>Rate limit</td><td>30 req/min sliding window, Redis-backed</td><td><code>health.controller.ts:16</code>, <code>rate-limit.constants.ts:4</code></td></tr> <tr><td>Success</td><td>200, envelope-wrapped Terminus result</td><td><code>response-envelope.interceptor.ts:47-60</code></td></tr> <tr><td>Failure</td><td>503, error envelope; 429 when rate-capped</td><td><code>http-exception.filter.ts:56-58, 73-81</code></td></tr> <tr><td>Latency</td><td>as fast as the slowest dependency probe; no timeout inside the check itself (non-goal, <a href="health/01_Product_Overview.html">01</a> §5)</td><td><code>health.controller.ts:31-36</code></td></tr> </tbody></table> </div> <h2 id="2-s1-screen-interactions-proposed"><a class="header" href="#2-s1-screen-interactions-proposed">2. S1 screen interactions <code>(proposed)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Interaction</th><th>Spec</th></tr></thead><tbody> <tr><td>I1</td><td>Enter screen</td><td>immediate <code>fetch()</code>; skeletons for 5 cards</td></tr> <tr><td>I2</td><td>Poll tick (30 s)</td><td>silent refetch; animate only changed cards (<code>m-fast</code> flash)</td></tr> <tr><td>I3</td><td>Tap down-card</td><td>open S2 sheet from cached payload (zero network)</td></tr> <tr><td>I4</td><td>Tap up-card</td><td>nothing (not a button; avoids dead-affordance taps)</td></tr> <tr><td>I5</td><td>Retry tap</td><td>immediate fetch; resets poll timer on success; disabled while in-flight; countdown during 429</td></tr> <tr><td>I6</td><td>Pull-to-refresh</td><td>optional at grid top; same as Retry (skip when I5 timer active)</td></tr> <tr><td>I7</td><td>App resume from background</td><td>immediate refetch (stale rule, <a href="health/07_Component_Library.html">07</a> §4)</td></tr> <tr><td>I8</td><td>Offline</td><td><code>AppOfflineBanner</code>; cards show last state + "stale"; Retry stays enabled</td></tr> </tbody></table> </div> <h2 id="3-state-machine-s1"><a class="header" href="#3-state-machine-s1">3. State machine (S1)</a></h2> <pre class="mermaid">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) </pre> <p>Down-key counting: API card counts as down when the request itself is 503; dependency keys come from <code>data.error</code> (<code>health.controller.spec.ts:29-35</code>).</p> <h2 id="4-motion-map"><a class="header" href="#4-motion-map">4. Motion map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Motion</th><th>Token</th><th>Use</th><th>Source</th></tr></thead><tbody> <tr><td>Card state flash</td><td><code>m-fast</code> (150 ms)</td><td>status change on poll</td><td><code>00-shared/08</code></td></tr> <tr><td>Banner swap</td><td><code>m-base</code> (250 ms)</td><td>overall verdict change</td><td><code>00-shared/08</code></td></tr> <tr><td>Sheet</td><td>bottom sheet curve</td><td>S2 open/close</td><td><code>00-shared/03</code></td></tr> <tr><td>Skeleton shimmer</td><td>loop</td><td>loading cards</td><td><code>00-shared/08</code></td></tr> </tbody></table> </div> <h2 id="5-accessibility-interactions"><a class="header" href="#5-accessibility-interactions">5. Accessibility interactions</a></h2> <ul> <li>Retry, cards, banner: full focus traversal; cards announce <code>button</code> when down (<code>06</code> S1 a11y).</li> <li>Live region on banner for state changes (<code>00-shared/09</code>).</li> <li>No gestures required for any core action (no swipe-to-refresh dependency - Retry button is the canonical path, <code>00-shared/09</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="11---design-system-mapping-health-module"><a class="header" href="#11---design-system-mapping-health-module">11 - Design System Mapping (Health Module)</a></h1> <ul> <li><a href="health/11_Design_System_Mapping.html#1-status-semantics--color-tokens">1. Status semantics → color tokens</a></li> <li><a href="health/11_Design_System_Mapping.html#2-typography">2. Typography</a></li> <li><a href="health/11_Design_System_Mapping.html#3-spacing--layout-tokens">3. Spacing / layout tokens</a></li> <li><a href="health/11_Design_System_Mapping.html#4-component-mapping-all-00-shared03-unless-module-defined">4. Component mapping (all 00-shared/03 unless module-defined)</a></li> <li><a href="health/11_Design_System_Mapping.html#5-icons">5. Icons</a></li> <li><a href="health/11_Design_System_Mapping.html#6-theming-notes">6. Theming notes</a></li> </ul> <blockquote> <p>Maps the Health module onto the global design system. Tokens in <a href="health/../00-shared/02_Design_Tokens.html">00-shared/02</a>, mapping conventions in <a href="health/../00-shared/04_Design_System_Mapping.html">00-shared/04</a>, components in <a href="health/../00-shared/03_Component_Library.html">00-shared/03</a>. All screens <code>(proposed)</code>.</p> </blockquote> <hr /> <h2 id="1-status-semantics--color-tokens"><a class="header" href="#1-status-semantics--color-tokens">1. Status semantics → color tokens</a></h2> <div class="table-wrapper"><table><thead><tr><th>Semantic</th><th>Token</th><th>Hex (from <a href="health/../00-shared/02_Design_Tokens.html">00-shared/02</a> palette)</th><th>Usage</th></tr></thead><tbody> <tr><td>Dependency up</td><td><code>color-success</code></td><td>green</td><td><code>StatusDot</code>, card border, banner positive</td></tr> <tr><td>Dependency down</td><td><code>color-error</code></td><td>red</td><td><code>StatusDot</code>, card border, banner negative</td></tr> <tr><td>Stale / caution</td><td><code>color-warning</code></td><td>amber</td><td>stale badge, dlq > 0 chip (<a href="health/07_Component_Library.html">07</a> §3)</td></tr> <tr><td>Neutral</td><td><code>color-surface-*</code></td><td>greys</td><td>card base, skeleton</td></tr> <tr><td>Text</td><td><code>color-text-*</code></td><td>on-surface</td><td>labels, detail lines</td></tr> </tbody></table> </div> <p>Never color-only: every status token is paired with text (<code>up</code>/<code>down</code>/<code>stale</code>) (<a href="health/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>, <a href="health/07_Component_Library.html">07</a> §2).</p> <h2 id="2-typography-1"><a class="header" href="#2-typography-1">2. Typography</a></h2> <div class="table-wrapper"><table><thead><tr><th>Element</th><th>Token</th><th>Example</th></tr></thead><tbody> <tr><td>Screen title</td><td><code>text-title</code></td><td>"App Health"</td></tr> <tr><td>Banner verdict</td><td><code>text-body</code> strong</td><td>"1 of 5 degraded — Redis is down"</td></tr> <tr><td>Card title</td><td><code>text-label</code></td><td>"Redis"</td></tr> <tr><td>Card status</td><td><code>text-label</code> + color</td><td>"up" / "down"</td></tr> <tr><td>Detail line</td><td><code>text-caption</code></td><td>"ping: PONG"</td></tr> <tr><td>Monospace payload (S2)</td><td><code>text-code</code></td><td><code>data.error.redis</code> render</td></tr> </tbody></table> </div> <h2 id="3-spacing--layout-tokens"><a class="header" href="#3-spacing--layout-tokens">3. Spacing / layout tokens</a></h2> <ul> <li>Card grid: 8 dp gutter (<code>space-2</code>), 16 dp page padding (<code>space-4</code>).</li> <li>2-column grid ≥ 700 dp (<a href="health/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a>); single column below.</li> <li>Banner: full-width <code>AppBanner</code>, 12 dp internal padding.</li> </ul> <h2 id="4-component-mapping-all-00-shared03-unless-module-defined"><a class="header" href="#4-component-mapping-all-00-shared03-unless-module-defined">4. Component mapping (all <a href="health/../00-shared/03_Component_Library.html">00-shared/03</a> unless module-defined)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen element</th><th>Component</th><th>Source</th></tr></thead><tbody> <tr><td>Overall verdict</td><td><code>AppBanner</code> (custom <code>OverallBanner</code>, <a href="health/07_Component_Library.html">07</a> §5)</td><td>S1</td></tr> <tr><td>Status cards</td><td><code>AppCard</code> + <code>StatusCard</code> (<a href="health/07_Component_Library.html">07</a> §1)</td><td>S1</td></tr> <tr><td>Status indicator</td><td><code>StatusDot</code> (<a href="health/07_Component_Library.html">07</a> §2)</td><td>S1/S2</td></tr> <tr><td>Queue backlog</td><td><code>PendingJobsTile</code> (<a href="health/07_Component_Library.html">07</a> §3)</td><td>S1</td></tr> <tr><td>Last-check time</td><td><code>LastCheckedLabel</code> (<a href="health/07_Component_Library.html">07</a> §4)</td><td>S1</td></tr> <tr><td>Retry</td><td><code>AppButton</code> (outlined, <code>m-app-</code> style)</td><td>S1</td></tr> <tr><td>Skeletons</td><td><code>AppSkeleton</code></td><td>S1 loading</td></tr> <tr><td>Offline</td><td><code>AppOfflineBanner</code></td><td>S1</td></tr> <tr><td>Detail</td><td><code>AppBottomSheet</code> + <code>SelectableText</code> monospace</td><td>S2</td></tr> </tbody></table> </div> <h2 id="5-icons"><a class="header" href="#5-icons">5. Icons</a></h2> <div class="table-wrapper"><table><thead><tr><th>Icon</th><th>Where</th><th>Note</th></tr></thead><tbody> <tr><td><code>Icons.favorite</code>-style heartbeat glyph (checkmark/circle per library)</td><td>banner positive</td><td>semantic, not decorative</td></tr> <tr><td><code>Icons.refresh</code></td><td>Retry</td><td>standard action glyph</td></tr> <tr><td><code>Icons.info_outline</code></td><td>stale badge</td><td>pairs with amber</td></tr> </tbody></table> </div> <h2 id="6-theming-notes"><a class="header" href="#6-theming-notes">6. Theming notes</a></h2> <ul> <li>Health colors ride the existing semantic tokens - no new palette entries, no module-local color constants (<a href="health/../00-shared/04_Design_System_Mapping.html">00-shared/04</a>).</li> <li>Dark mode: status colors keep contrast ≥ 4.5:1 against <code>color-surface</code> (<a href="health/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>); amber-on-dark validated for stale badge.</li> <li>Motion tokens per <a href="health/10_Interaction_Specification.html">10</a> §4 (<a href="health/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="12---api-mapping-health-module"><a class="header" href="#12---api-mapping-health-module">12 - API Mapping (Health Module)</a></h1> <ul> <li><a href="health/12_API_Mapping.html#1-health-check-implemented">1. Health check (implemented)</a></li> <li><a href="health/12_API_Mapping.html#2-success-response---200-all-up">2. Success response - 200 (all up)</a></li> <li><a href="health/12_API_Mapping.html#3-failure-response---503-any-dependency-down">3. Failure response - 503 (any dependency down)</a></li> <li><a href="health/12_API_Mapping.html#4-partial-failure-semantics-200-never-happens-503-with-healthy-siblings">4. Partial failure semantics (200 never happens; 503 with healthy siblings)</a></li> <li><a href="health/12_API_Mapping.html#5-error-map">5. Error map</a></li> <li><a href="health/12_API_Mapping.html#6-planned--not-yet-in-source">6. Planned / not yet in source</a></li> </ul> <blockquote> <p>Exact wire contracts for the Health module. Base path <code>/api/v1</code> (URI versioning, <code>main.ts:44-48</code>; prefix <code>api</code>, version <code>1</code> - <code>env.ts:14,16</code>). Endpoint is <code>@Public()</code> (<code>health.controller.ts:15</code>) so the JWT/RBAC global guards (<code>app.module.ts:129-131</code>) are bypassed; rate-limited on the <code>public</code> tier (<code>health.controller.ts:16</code>, <code>rate-limit.constants.ts:4</code>). Envelopes per <a href="health/../00-shared/07_API_Conventions.html">00-shared/07</a> §2-3.</p> </blockquote> <hr /> <h2 id="1-health-check-implemented"><a class="header" href="#1-health-check-implemented">1. Health check (implemented)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Rate</th><th>Source</th></tr></thead><tbody> <tr><td>GET</td><td><code>/api/v1/health</code></td><td>public (<code>@Public()</code>)</td><td>30 req/min (<code>public</code> tier)</td><td><code>health.controller.ts:27-37</code></td></tr> <tr><td>GET</td><td><code>/api/v2/health</code></td><td>public</td><td>30 req/min</td><td><code>@Version(['1','2'])</code>, <code>health.controller.ts:28</code></td></tr> </tbody></table> </div> <p>No params, no body, no query. Four indicators registered in one check (<code>health.controller.ts:31-36</code>):</p> <div class="table-wrapper"><table><thead><tr><th>Indicator key</th><th>Mechanism</th><th>Detail payload</th><th>Source</th></tr></thead><tbody> <tr><td><code>mongodb</code></td><td>Terminus <code>MongooseHealthIndicator.pingCheck('mongodb')</code></td><td>-</td><td><code>health.controller.ts:32</code></td></tr> <tr><td><code>redis</code></td><td><code>RedisHealthIndicator.check()</code> - <code>PING</code> must return <code>PONG</code></td><td><code>{ ping: 'PONG' }</code> when up</td><td><code>redis-health.indicator.ts:12-23</code></td></tr> <tr><td><code>storage</code></td><td><code>StorageHealthIndicator.check()</code> - provider <code>healthCheck()</code></td><td>-</td><td><code>storage-health.indicator.ts:16-26</code>, <code>storage-provider.ts:27</code></td></tr> <tr><td><code>bullmq</code></td><td><code>BullMqHealthIndicator.check()</code> - <code>getJobCounts</code> on <code>emails</code>, <code>in-app</code>, <code>webhook-deliver</code>, <code>dlq</code></td><td><code>{ pendingJobs: [n,n,n,n] }</code> when up</td><td><code>bullmq-health.indicator.ts:19-37</code>, <code>queue.constants.ts:2,5,14</code>, <code>dlq.constants.ts:1</code></td></tr> </tbody></table> </div> <h2 id="2-success-response---200-all-up"><a class="header" href="#2-success-response---200-all-up">2. Success response - 200 (all up)</a></h2> <p><code>@HealthCheck()</code> + response envelope (<code>response-envelope.interceptor.ts:47-60</code>); shape per <code>HealthCheckResult</code> (<code>health.controller.spec.ts:21-36</code>):</p> <pre><code class="language-json">{ "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": "" } </code></pre> <ul> <li><code>pendingJobs</code> order = injection order: <code>[emails, in-app, webhook-deliver, dlq]</code> (<code>bullmq-health.indicator.ts:21-25</code>); values are per-queue <strong>totals</strong> (<code>Object.values(r).reduce</code>, <code>:27-29</code>).</li> <li><code>requestId</code> echoes <code>x-request-id</code> header or is <code>""</code> (<code>response-envelope.interceptor.ts:44</code>).</li> </ul> <h2 id="3-failure-response---503-any-dependency-down"><a class="header" href="#3-failure-response---503-any-dependency-down">3. Failure response - 503 (any dependency down)</a></h2> <p><code>@HealthCheck</code> throws <code>ServiceUnavailableException</code>; the global filter renders the error envelope (<code>http-exception.filter.ts:73-81</code>). 503 is not in <code>STATUS_CODE_MAP</code> (<code>:27-35</code>), so <code>code</code> falls to <code>INTERNAL_SERVER_ERROR</code> (<code>:56-58</code>):</p> <pre><code class="language-json">{ "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": "" } </code></pre> <p><strong>Contract note:</strong> the down dependency's key is NOT in this envelope - the <code>HealthCheckResult</code> is only returned on 200. Clients get the culprit from the last successful poll, or from the 503 <code>message</code> string; a <code>(proposed)</code> future change should include the failing key in the error body (<code>14</code> QA-3).</p> <h2 id="4-partial-failure-semantics-200-never-happens-503-with-healthy-siblings"><a class="header" href="#4-partial-failure-semantics-200-never-happens-503-with-healthy-siblings">4. Partial failure semantics (200 never happens; 503 with healthy siblings)</a></h2> <p>The Terminus result that WOULD be returned (per <code>health.controller.spec.ts:29-35</code>) keeps healthy deps <code>up</code> in <code>info</code> and lists failed keys in <code>error</code>:</p> <pre><code class="language-json">{ "status": "error", "info": { "mongodb": { "status": "up" }, "storage": { "status": "up" } }, "error": { "redis": { "status": "down" }, "bullmq": { "status": "down" } }, "details": { "mongodb": { "status": "up" }, "redis": { "status": "down" }, … } } </code></pre> <h2 id="5-error-map"><a class="header" href="#5-error-map">5. Error map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Code</th><th>Meaning</th><th>Source</th></tr></thead><tbody> <tr><td>200</td><td>all indicators <code>up</code></td><td><code>health.controller.ts:31-36</code></td></tr> <tr><td>503</td><td>≥ 1 indicator <code>down</code> (Terminus throws)</td><td><code>@HealthCheck()</code> decorator</td></tr> <tr><td>429</td><td>rate cap - 30 req/min sliding window, Redis-backed</td><td><code>rate-limit.constants.ts:4</code>, <code>IMPLEMENTATION_PLAN.md:48</code></td></tr> <tr><td>400</td><td>malformed request path/version edge</td><td><code>main.ts:50-57</code> (global pipes)</td></tr> </tbody></table> </div> <h2 id="6-planned--not-yet-in-source"><a class="header" href="#6-planned--not-yet-in-source">6. Planned / not yet in source</a></h2> <div class="table-wrapper"><table><thead><tr><th>Path / feature</th><th>Status</th><th>Source</th></tr></thead><tbody> <tr><td><code>GET /api/v1/health/ready</code> (readiness)</td><td><code>(planned)</code></td><td><code>docs/user-flows/END_TO_END_USER_FLOWS.md:739</code> - no route in <code>health.controller.ts</code></td></tr> <tr><td><code>GET /api/v1/health/live</code> (liveness)</td><td><code>(planned)</code></td><td><code>docs/user-flows/END_TO_END_USER_FLOWS.md:740</code> - no route in <code>health.controller.ts</code></td></tr> <tr><td>Disk + memory indicators</td><td><code>(planned)</code></td><td>Terminus supports (<code>DiskHealthIndicator</code>/<code>MemoryHealthIndicator</code>); not wired - <code>health.controller.ts:31-36</code></td></tr> <tr><td>Uptime / version / build hash in payload</td><td><code>(planned)</code></td><td><code>HealthCheckResult</code> returned verbatim - <code>health.controller.ts:30-37</code></td></tr> <tr><td>Auth'd admin health variant + <code>health.*</code> permission</td><td><code>(proposed)</code></td><td>no <code>health.*</code> in <code>permissions.constants.ts:1-97</code> (verified)</td></tr> <tr><td>Failing-key in 503 error body</td><td><code>(proposed)</code></td><td><code>http-exception.filter.ts:56-58, 73-81</code></td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="13---state-management-health-module"><a class="header" href="#13---state-management-health-module">13 - State Management (Health Module)</a></h1> <ul> <li><a href="health/13_State_Management.html#1-cubit-map">1. Cubit map</a></li> <li><a href="health/13_State_Management.html#2-healthcubit-core">2. HealthCubit (core)</a></li> <li><a href="health/13_State_Management.html#3-models">3. Models</a></li> <li><a href="health/13_State_Management.html#4-error-envelope-handling-503-is-data-not-failure">4. Error envelope handling (503 is data, not failure)</a></li> <li><a href="health/13_State_Management.html#5-cross-cutting">5. Cross-cutting</a></li> </ul> <blockquote> <p>Per-screen Cubit/Bloc design on top of <a href="health/../00-shared/06_State_Management.html">00-shared/06</a> conventions (stack: <code>flutter_bloc</code> + <code>get_it</code>; server state via dio repository; <code>LoadState</code> = Initial/Loading/Success/Error(ApiException)). Screens <code>(proposed)</code>. Backend truth: single stateless endpoint (<code>health.controller.ts:27-37</code>) - all freshness logic lives client-side.</p> </blockquote> <hr /> <h2 id="1-cubit-map-1"><a class="header" href="#1-cubit-map-1">1. Cubit map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Cubit</th><th>Screen (<a href="health/05_Screen_Inventory.html">05</a>)</th><th>Data</th></tr></thead><tbody> <tr><td><code>HealthCubit</code></td><td>1 (App Health)</td><td><code>HealthSnapshot</code> (per-dependency status + extras + freshness), poll timer, retry</td></tr> <tr><td>(none for S2)</td><td>2 (Incident sheet)</td><td>renders from <code>HealthCubit</code> cached payload - no own cubit</td></tr> </tbody></table> </div> <p>Repository (<code>HealthRepository</code> in <code>features/health/data/</code>) is the only layer touching HTTP; it maps both the 200 envelope and the 503 error envelope to models and throws <code>ApiException(status, message)</code> (<a href="health/../00-shared/06_State_Management.html">00-shared/06</a> §2-3).</p> <h2 id="2-healthcubit-core"><a class="header" href="#2-healthcubit-core">2. HealthCubit (core)</a></h2> <pre class="mermaid">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 </pre> <ul> <li><strong>Polling:</strong> <code>Timer.periodic(30 s)</code> while foregrounded; canceled on background (<code>AppLifecycleState.paused</code>) and restarted with an immediate <code>fetch()</code> on resume (<code>10</code> I7). 30 s chosen to stay far under the 30 req/min public cap shared with LBs (<code>12</code> §5, <code>rate-limit.constants.ts:4</code>).</li> <li><strong>Retry:</strong> manual <code>retry()</code> = single immediate fetch; on success resets the poll timer (skip next tick). In-flight guard: one request at a time (<code>08</code> §1).</li> </ul> <h2 id="3-models"><a class="header" href="#3-models">3. Models</a></h2> <pre><code class="language-dart">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 </code></pre> <ul> <li>Source mapping: <code>data.info.<key>.status</code> → <code>up</code>; <code>data.error.<key></code> → <code>down</code> (keys present in <code>info</code> stay <code>up</code> on partial failure, <code>health.controller.spec.ts:29-35</code>); extras from <code>data.info.redis.ping</code> (<code>redis-health.indicator.ts:16</code>) and <code>data.info.bullmq.pendingJobs</code> (<code>bullmq-health.indicator.ts:30</code>).</li> <li><strong>API card</strong> is <code>reachable</code> (request reached server) - no explicit key.</li> </ul> <h2 id="4-error-envelope-handling-503-is-data-not-failure"><a class="header" href="#4-error-envelope-handling-503-is-data-not-failure">4. Error envelope handling (503 is data, not failure)</a></h2> <p><code>HealthRepository</code> treats 503 as a <strong>successful fetch of a down state</strong>, not an exception: it maps the error envelope (<code>http-exception.filter.ts:73-81</code>) into a <code>HealthSnapshot</code> with all keys <code>down</code> + <code>reachable: false</code>. Only network errors/timeouts/5xx-without-health-shape become <code>LoadState.Error</code>. This is the single most important client decision in the module (<code>09</code> §2, <code>14</code> QA-1).</p> <h2 id="5-cross-cutting-1"><a class="header" href="#5-cross-cutting-1">5. Cross-cutting</a></h2> <ul> <li><strong>No cache layer:</strong> the screen is its own freshest state; no persistence - restarting the app refetches. Stateless endpoint = nothing to warm.</li> <li><strong>No domain events:</strong> the client does not consume any event stream for health (<code>event-queue-map</code> has no health events); polling is the only source.</li> <li><strong>Rate budget:</strong> poll (30 s) + manual retry must respect the shared 30 req/min public cap; on 429 the cubit pauses polling 60 s (<code>12</code> §5).</li> <li><strong>Permission gating:</strong> screen gating is <code>(proposed)</code> - no <code>health.*</code> permission exists (<code>permissions.constants.ts:1-97</code>); when added, gate <code>HealthCubit</code> start on it.</li> <li><strong>Planned:</strong> liveness/readiness split polling per endpoint when <code>/live</code> + <code>/ready</code> land <code>(planned)</code> - <code>docs/user-flows/END_TO_END_USER_FLOWS.md:738-740</code>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14---qa-checklist-health-module"><a class="header" href="#14---qa-checklist-health-module">14 - QA Checklist (Health Module)</a></h1> <ul> <li><a href="health/14_QA_Checklist.html#1-contract-tests-endpoint">1. Contract tests (endpoint)</a></li> <li><a href="health/14_QA_Checklist.html#2-failure-modes">2. Failure modes</a></li> <li><a href="health/14_QA_Checklist.html#3-client-behaviours-proposed-screen">3. Client behaviours (proposed screen)</a></li> <li><a href="health/14_QA_Checklist.html#4-security--leakage">4. Security / leakage</a></li> <li><a href="health/14_QA_Checklist.html#5-load-shedding--performance">5. Load-shedding / performance</a></li> <li><a href="health/14_QA_Checklist.html#6-planned--forward-looking-flag-in-tests">6. Planned / forward-looking (flag in tests)</a></li> </ul> <blockquote> <p>Verification checklist for the Health module. Baseline in <a href="health/../00-shared/10_QA_Baseline.html">00-shared/10</a>. Items marked with the failure class they catch; every item maps to a source line so tests stay honest to the contract.</p> </blockquote> <hr /> <h2 id="1-contract-tests-endpoint"><a class="header" href="#1-contract-tests-endpoint">1. Contract tests (endpoint)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expectation</th><th>Source</th></tr></thead><tbody> <tr><td>C1</td><td>All-up request</td><td>200; <code>data.status: 'ok'</code>; 4 keys in <code>info</code>; <code>error: {}</code></td><td><code>health.controller.spec.ts:21-36</code>, <code>health.controller.ts:31-36</code></td></tr> <tr><td>C2</td><td>Indicator count</td><td>exactly <code>mongodb</code>, <code>redis</code>, <code>storage</code>, <code>bullmq</code> - no more, no less</td><td><code>health.controller.ts:31-36</code></td></tr> <tr><td>C3</td><td>Redis detail</td><td><code>info.redis.ping === 'PONG'</code> on success</td><td><code>redis-health.indicator.ts:12-23</code></td></tr> <tr><td>C4</td><td>BullMQ detail</td><td><code>info.bullmq.pendingJobs</code> is a 4-element int array in injection order <code>[emails, in-app, webhook-deliver, dlq]</code></td><td><code>bullmq-health.indicator.ts:21-30</code>, <code>queue.constants.ts:2,5,14</code></td></tr> <tr><td>C5</td><td>Version both</td><td><code>/api/v1/health</code> and <code>/api/v2/health</code> both 200</td><td><code>@Version(['1','2'])</code>, <code>health.controller.ts:28</code></td></tr> <tr><td>C6</td><td>Auth bypass</td><td>request without any Authorization header → 200 (not 401/403)</td><td><code>@Public()</code>, <code>health.controller.ts:15</code>; guards <code>app.module.ts:129-131</code></td></tr> <tr><td>C7</td><td>Rate cap</td><td>> 30 req/min → 429</td><td><code>rate-limit.constants.ts:4</code></td></tr> <tr><td>C8</td><td>Envelope</td><td>success body has <code>success/message/data/timestamp/requestId</code></td><td><code>response-envelope.interceptor.ts:47-60</code></td></tr> <tr><td>C9</td><td>No DTO</td><td>no validation 400 on any query/body (none accepted)</td><td><code>health.controller.ts:27-37</code></td></tr> </tbody></table> </div> <h2 id="2-failure-modes"><a class="header" href="#2-failure-modes">2. Failure modes</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expected</th><th>Gaps to flag</th></tr></thead><tbody> <tr><td>F1</td><td>Redis down</td><td>503; <code>data.error.redis.status === 'down'</code> in the result Terminus built; error envelope <code>code: 'INTERNAL_SERVER_ERROR'</code> (503 unmapped in filter)</td><td>error body does not carry the key - <code>http-exception.filter.ts:56-58</code></td></tr> <tr><td>F2</td><td><strong>Partial</strong> - Redis + BullMQ down, Mongo + storage up</td><td>503; <code>info</code> keeps healthy keys <code>up</code></td><td>verify client never blanks healthy cards (<a href="health/13_State_Management.html">13</a> §4)</td></tr> <tr><td>F3</td><td>All four down</td><td>503; all keys in <code>error</code></td><td>-</td></tr> <tr><td>F4</td><td>Storage provider throws</td><td>indicator catches → <code>HealthCheckError</code> → down</td><td><code>storage-health.indicator.ts:16-26</code></td></tr> <tr><td>F5</td><td>Redis PING returns non-<code>PONG</code></td><td><code>down</code> (status computed, <code>:15</code>)</td><td>-</td></tr> <tr><td>F6</td><td>Queue unreachable (Redis-backed queues)</td><td><code>getJobCounts</code> rejects → <code>HealthCheckError</code> → down</td><td><code>bullmq-health.indicator.ts:31-36</code></td></tr> <tr><td>F7</td><td>BullMQ partial queue failure</td><td><code>Promise.all</code> fails whole indicator → all-or-nothing; no per-queue down granularity</td><td>acceptable; document</td></tr> </tbody></table> </div> <h2 id="3-client-behaviours-proposed-screen"><a class="header" href="#3-client-behaviours-proposed-screen">3. Client behaviours (proposed screen)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expectation</th></tr></thead><tbody> <tr><td>G1</td><td>503 mapped to down-state, not error state</td><td><code>HealthRepository</code> maps error envelope → <code>HealthSnapshot(down)</code>; never a red error page (<a href="health/13_State_Management.html">13</a> §4)</td></tr> <tr><td>G2</td><td>Partial failure rendering</td><td>red only on culprit cards; healthy cards stay green (<a href="health/06_Screen_Specifications.html">06</a> S1 row 3)</td></tr> <tr><td>G3</td><td>Stale guard</td><td>snapshot > 60 s old renders <code>stale</code> + amber, never fresh-green (<a href="health/07_Component_Library.html">07</a> §4)</td></tr> <tr><td>G4</td><td>Poll pause/resume</td><td>backgrounded → timer canceled; resume → immediate refetch (<a href="health/13_State_Management.html">13</a> §2)</td></tr> <tr><td>G5</td><td>429 handling</td><td>poll pauses 60 s; retry disabled with countdown (<a href="health/10_Interaction_Specification.html">10</a> I5)</td></tr> <tr><td>G6</td><td>In-flight guard</td><td>double-tap Retry issues exactly one request</td></tr> <tr><td>G7</td><td>PendingJobs drift</td><td>dlq > 0 shows amber chip (<a href="health/07_Component_Library.html">07</a> §3)</td></tr> <tr><td>G8</td><td>Detail sheet</td><td>renders from cache; zero network calls on open (<a href="health/06_Screen_Specifications.html">06</a> S2)</td></tr> </tbody></table> </div> <h2 id="4-security--leakage"><a class="header" href="#4-security--leakage">4. Security / leakage</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expectation</th></tr></thead><tbody> <tr><td>S1</td><td>No secret leakage</td><td>payload carries only statuses + counts; <strong>no</strong> Redis URLs/credentials, queue connection strings, storage keys</td></tr> <tr><td>S2</td><td>Public endpoint abuse</td><td>30 req/min cap enforced; no auth bypass of <em>other</em> endpoints via <code>@Public()</code> scope creep (<code>health.controller.ts:15</code> is controller-scoped only)</td></tr> <tr><td>S3</td><td>No tenant data</td><td>response must never contain tenant names/ids; health is global (<a href="health/01_Product_Overview.html">01</a> §5)</td></tr> <tr><td>S4</td><td>DoS via hanging probe</td><td>check has no internal timeout - a hung dependency stalls the request until client/middleware timeout; monitor p99 of <code>/health</code></td></tr> </tbody></table> </div> <h2 id="5-load-shedding--performance"><a class="header" href="#5-load-shedding--performance">5. Load-shedding / performance</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expectation</th></tr></thead><tbody> <tr><td>L1</td><td>Probe storm</td><td>30 concurrent probes → all served within rate window; no queue buildup (each probe runs 4 checks in parallel, <code>health.controller.ts:31-36</code>)</td></tr> <tr><td>L2</td><td>Indicator parallelism</td><td>4 checks run concurrently (<code>Promise</code> batch via <code>HealthCheckService</code>) - total latency ≈ slowest check, not the sum</td></tr> <tr><td>L3</td><td>Monitor drift</td><td><code>pendingJobs</code> in <code>info</code> is a sum, not per-state; alert thresholds must not be built on it (<code>bullmq-health.indicator.ts:27-29</code>)</td></tr> </tbody></table> </div> <h2 id="6-planned--forward-looking-flag-in-tests"><a class="header" href="#6-planned--forward-looking-flag-in-tests">6. Planned / forward-looking (flag in tests)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Item</th><th>Status</th></tr></thead><tbody> <tr><td>P1</td><td><code>/health/live</code> + <code>/health/ready</code> route tests</td><td><code>(planned)</code> - <code>END_TO_END_USER_FLOWS.md:738-740</code></td></tr> <tr><td>P2</td><td>Disk/memory indicator tests</td><td><code>(planned)</code> - not wired</td></tr> <tr><td>P3</td><td>Uptime/version fields</td><td><code>(planned)</code> - absent from payload</td></tr> <tr><td>P4</td><td>Failing-key in 503 body</td><td><code>(proposed)</code> - filter change needed (<code>http-exception.filter.ts:56-58</code>)</td></tr> <tr><td>P5</td><td>Authenticated admin variant</td><td><code>(proposed)</code> - needs <code>health.*</code> permission (<code>permissions.constants.ts:1-97</code>)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="15---flutter-implementation-guide-health-module"><a class="header" href="#15---flutter-implementation-guide-health-module">15 - Flutter Implementation Guide (Health Module)</a></h1> <ul> <li><a href="health/15_Flutter_Implementation_Guide.html#1-folder-layout">1. Folder layout</a></li> <li><a href="health/15_Flutter_Implementation_Guide.html#2-repository-the-critical-mapping">2. Repository (the critical mapping)</a></li> <li><a href="health/15_Flutter_Implementation_Guide.html#3-healthcubit">3. HealthCubit</a></li> <li><a href="health/15_Flutter_Implementation_Guide.html#4-screen--widgets">4. Screen & widgets</a></li> <li><a href="health/15_Flutter_Implementation_Guide.html#5-key-implementation-details">5. Key implementation details</a></li> <li><a href="health/15_Flutter_Implementation_Guide.html#6-tests">6. Tests</a></li> <li><a href="health/15_Flutter_Implementation_Guide.html#7-analytics-proposed">7. Analytics (proposed)</a></li> <li><a href="health/15_Flutter_Implementation_Guide.html#8-roadmap-items-not-built-flag-in-code">8. Roadmap items NOT built (flag in code)</a></li> </ul> <blockquote> <p>Build order and concrete Flutter implementation notes for the Health module client, on top of <a href="health/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a> (app architecture) and <a href="health/../00-shared/06_State_Management.html">00-shared/06</a> (state). Reminder: per PRD the native app is post-Phase 1 (<code>PRODUCT_REQUIREMENTS_DOCUMENT.md:144</code>); this guide is the forward-looking build plan.</p> </blockquote> <hr /> <h2 id="1-folder-layout"><a class="header" href="#1-folder-layout">1. Folder layout</a></h2> <pre><code>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) </code></pre> <h2 id="2-repository-the-critical-mapping"><a class="header" href="#2-repository-the-critical-mapping">2. Repository (the critical mapping)</a></h2> <ul> <li><code>fetch()</code> → <code>GET /api/v1/health</code> (<code>health.controller.ts:27-37</code>).</li> <li><strong>200</strong> → parse <code>data</code> (<code>HealthCheckResult</code>): <code>info.<key>.status</code>, <code>error.<key></code> (<code>health.controller.spec.ts:21-36</code>); extras <code>ping</code> (<code>redis-health.indicator.ts:16</code>), <code>pendingJobs</code> (<code>bullmq-health.indicator.ts:30</code>).</li> <li><strong>503</strong> → parse the error envelope (<code>http-exception.filter.ts:73-81</code>) as a <strong>down snapshot</strong> - <code>reachable: false</code>, all keys <code>down</code>; do NOT throw. Only network errors/timeouts and non-health-shaped 5xx throw <code>ApiException</code> (<code>13</code> §4).</li> <li><strong>429</strong> → surface <code>rateLimited</code> so the cubit pauses polling 60 s (<code>rate-limit.constants.ts:4</code>).</li> <li><code>checkedAt</code> is client-clock (server sends none, <code>12</code> §6).</li> </ul> <h2 id="3-healthcubit"><a class="header" href="#3-healthcubit">3. HealthCubit</a></h2> <pre><code class="language-dart">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(); } } </code></pre> <ul> <li>One in-flight request at a time (guard in <code>fetch</code>).</li> <li>On 429: cancel poll, <code>Timer(60 s, () => fetch())</code> (<code>13</code> §2).</li> </ul> <h2 id="4-screen--widgets"><a class="header" href="#4-screen--widgets">4. Screen & widgets</a></h2> <ul> <li><code>AppHealthScreen</code>: <code>BlocBuilder<HealthCubit, HealthState></code> → <code>OverallBanner</code> + 5 <code>StatusCard</code>s (grid, 2-col ≥ 700 dp) + <code>LastCheckedLabel</code> + Retry <code>AppButton</code>.</li> <li><code>StatusCard</code> (up/down/stale + detail; tap only when <code>down</code> → <code>IncidentSheet</code> from cached snapshot - zero network, <code>06</code> S2).</li> <li><code>PendingJobsTile</code>: label <code>emails n · in-app n · webhook-deliver n · dlq n</code>; amber chip when <code>dlq > 0</code> (<code>07</code> §3). Parse strictly; unknown → neutral.</li> <li>Monospace payload in <code>SelectableText</code> with copy for the incident sheet.</li> </ul> <h2 id="5-key-implementation-details"><a class="header" href="#5-key-implementation-details">5. Key implementation details</a></h2> <ul> <li><strong>Poll hygiene:</strong> 30 s + manual retry must stay under the shared 30 req/min public cap (<code>08</code> §1); never auto-retry 503 in a loop (<code>09</code> §5).</li> <li><strong>Status enum:</strong> <code>up</code>/<code>down</code>/<code>stale</code>; stale = snapshot > 60 s (<code>07</code> §4). Color never alone - text always (<code>00-shared/09</code>).</li> <li><strong>Version note:</strong> client should call <code>/api/v1/health</code>; <code>/v2</code> exists and is identical today (<code>health.controller.ts:28</code>) - pick one and keep it.</li> <li><strong>No auth header needed</strong> - endpoint is <code>@Public()</code> (<code>health.controller.ts:15</code>); the dio client must not attach the JWT interceptor (keeps probes clean).</li> <li><strong>Permission gating (proposed):</strong> when <code>health.read</code>-style permission lands (<code>permissions.constants.ts:1-97</code> has none today), gate screen entry on it.</li> </ul> <h2 id="6-tests"><a class="header" href="#6-tests">6. Tests</a></h2> <ul> <li><strong>Unit:</strong> model fromJson (200 envelope, extras <code>ping</code>/<code>pendingJobs</code>, <code>error</code> keys), status enum parsing, stale computation.</li> <li><strong>Repository:</strong> 503 error-envelope → down snapshot (NOT exception); 429 → <code>rateLimited</code>; network error → <code>ApiException</code>.</li> <li><strong>Cubit:</strong> initial fetch; poll tick silent refresh; 429 pause/resume; in-flight guard; lifecycle pause/resume refetch (<code>13</code> §2).</li> <li><strong>Widget:</strong> card states up/down/stale; banner text per state; detail sheet renders from cache; dlq amber chip.</li> <li><strong>Integration (<code>00-shared/10</code>):</strong> against running API - all-up 200; force-stop Redis in test env → 503; verify no secret/connection-string strings in any payload (<code>14</code> S1).</li> </ul> <h2 id="7-analytics-proposed"><a class="header" href="#7-analytics-proposed">7. Analytics (proposed)</a></h2> <p>Wire <code>health.screen.{open,poll_success,poll_failure}</code>, <code>health.retry.tap</code>, <code>health.card.detail_open</code> from <code>05</code>; no SDK selected yet (<code>00-shared/10</code> §8).</p> <h2 id="8-roadmap-items-not-built-flag-in-code"><a class="header" href="#8-roadmap-items-not-built-flag-in-code">8. Roadmap items NOT built (flag in code)</a></h2> <ul> <li>Liveness/readiness split screens/polling - wait for <code>/live</code> + <code>/ready</code> <code>(planned)</code>, <code>END_TO_END_USER_FLOWS.md:738-740</code>.</li> <li>Disk/memory cards - not wired server-side <code>(planned)</code>, <code>health.controller.ts:31-36</code>.</li> <li>Uptime/version display - payload absent <code>(planned)</code>, <code>12</code> §6.</li> <li>Authenticated admin health variant <code>(proposed)</code> - no <code>health.*</code> permission yet (<code>permissions.constants.ts:1-97</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-homework-module"><a class="header" href="#01--product-overview-homework-module">01 — Product Overview (Homework Module)</a></h1> <ul> <li><a href="homework/01_Product_Overview.html#1-purpose">1. Purpose</a></li> <li><a href="homework/01_Product_Overview.html#2-business-goals">2. Business goals</a></li> <li><a href="homework/01_Product_Overview.html#3-user-goals">3. User goals</a></li> <li><a href="homework/01_Product_Overview.html#4-stakeholders">4. Stakeholders</a></li> <li><a href="homework/01_Product_Overview.html#5-why-this-exists">5. Why this exists</a></li> <li><a href="homework/01_Product_Overview.html#6-dependencies">6. Dependencies</a></li> <li><a href="homework/01_Product_Overview.html#7-success-metrics">7. Success metrics</a></li> <li><a href="homework/01_Product_Overview.html#8-edge-cases">8. Edge cases</a></li> <li><a href="homework/01_Product_Overview.html#9-assumptions-module">9. Assumptions (module)</a></li> <li><a href="homework/01_Product_Overview.html#10-open-questions-module-level-global-ledger-in-00-shared12">10. Open questions (module-level; global ledger in 00-shared/12)</a></li> <li><a href="homework/01_Product_Overview.html#11-glossary-this-module">11. Glossary (this module)</a></li> </ul> <blockquote> <p>StudyLyon — multi-tenant ERP / School Management API. This package designs the <strong>Homework module client</strong> (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 <code>src/modules/homework/**</code>, <code>src/modules/files/**</code>, <code>src/modules/notifications/**</code>, <code>src/infrastructure/bullmq/**</code>, <code>src/modules/rbac/permissions.constants.ts</code>, <code>studylyon-blueprint/04-Modules/Homework.md</code>, <code>PLAN.md §6</code>, and <code>docs/IMPLEMENTATION_PLAN.md</code>. No feature is invented; gaps are flagged in <strong>Assumptions & Open Questions</strong>.</p> </blockquote> <hr /> <h2 id="1-purpose-12"><a class="header" href="#1-purpose-12">1. Purpose</a></h2> <p>Homework is the assignment lifecycle: a <strong>teacher</strong> creates an assignment for one class and subject with a due date and optional file attachments; a <strong>student</strong> submits work (remarks + optional attachments) exactly once per homework; the <strong>teacher</strong> grades the submission with marks + remarks; the <strong>parent</strong> sees pending and graded homework <em>(read-only; no parent API exists yet — OQ-5)</em>. Every state change emits a domain event that is routed over BullMQ to in-app notifications.</p> <div class="table-wrapper"><table><thead><tr><th>Responsibility</th><th>Source</th></tr></thead><tbody> <tr><td>Create homework (teacherId, classId, subjectId, title, description?, attachments?, dueDate)</td><td><code>homework.service.ts:27-43</code> <code>create()</code></td></tr> <tr><td>List homework for a class (sorted dueDate desc)</td><td><code>homework.repository.ts:17-21</code></td></tr> <tr><td>Get / update / soft-delete homework</td><td><code>homework.service.ts:45-81</code></td></tr> <tr><td>Accept exactly-once student submission (409 on duplicate)</td><td><code>homework.service.ts:83-112</code> <code>submit()</code></td></tr> <tr><td>List submissions of a homework</td><td><code>homework-submission.repository.ts:20-24</code></td></tr> <tr><td>Grade a submission (marks, remarks, status→graded, gradedAt)</td><td><code>homework.service.ts:120-145</code> <code>gradeSubmission()</code></td></tr> <tr><td>File attachments (upload/download/delete via FilesModule)</td><td><code>files.controller.ts:29-71</code>, <code>files.service.ts:27-70</code></td></tr> <tr><td>Domain events → BullMQ <code>in-app</code> / <code>audit-write</code> queues</td><td><code>event-queue-map.ts:22-26</code>, <code>inapp.worker.ts</code>, <code>queue-bridge.service.ts:40-75</code></td></tr> <tr><td>Multi-tenant scoping + soft-delete filtering on every query</td><td><code>base.repository.ts:20-30</code></td></tr> </tbody></table> </div> <h2 id="2-business-goals-11"><a class="header" href="#2-business-goals-11">2. Business goals</a></h2> <div class="table-wrapper"><table><thead><tr><th>Goal</th><th>Measure</th></tr></thead><tbody> <tr><td>Zero lost submissions</td><td>unique index <code>{tenantId, homeworkId, studentId}</code> + service-level 409 (<code>homework-submission.schema.ts:37-40</code>, <code>homework.service.ts:92</code>)</td></tr> <tr><td>Teacher grading turnaround</td><td>graded state visible immediately after <code>PATCH grade</code> (<code>homework.service.ts:127-134</code>)</td></tr> <tr><td>Class-level visibility</td><td><code>GET /homework/class/:classId</code> scoped + sorted by due date (<code>homework.repository.ts:19-20</code>)</td></tr> <tr><td>Cross-tenant isolation</td><td>every query tenant-scoped via <code>BaseRepository.scopedFilter</code> (<code>base.repository.ts:24-29</code>)</td></tr> <tr><td>Audit trail of homework changes</td><td><code>HomeworkUpdated</code>/<code>HomeworkDeleted</code> events → <code>audit-write</code> queue (<code>event-queue-map.ts:23,26</code>)</td></tr> </tbody></table> </div> <h2 id="3-user-goals-9"><a class="header" href="#3-user-goals-9">3. User goals</a></h2> <ul> <li><strong>Teacher:</strong> create a homework with a due date and attachments in seconds; see which students submitted; grade with marks + remarks and re-grade when needed.</li> <li><strong>Student:</strong> see pending homework for my class; submit exactly once (with optional attachment); see my marks and teacher feedback.</li> <li><strong>Parent (read):</strong> follow child's pending homework and grades.</li> <li><strong>Org admin:</strong> oversee homework activity; nothing admin-specific exists in the API (OQ-7).</li> </ul> <h2 id="4-stakeholders-10"><a class="header" href="#4-stakeholders-10">4. Stakeholders</a></h2> <p>Class teachers, students, parents, subject coordinators (read), org admin, notification delivery pipeline (in-app worker), audit/compliance (event log), QA + design + engineering.</p> <h2 id="5-why-this-exists-11"><a class="header" href="#5-why-this-exists-11">5. Why this exists</a></h2> <p>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).</p> <h2 id="6-dependencies-6"><a class="header" href="#6-dependencies-6">6. Dependencies</a></h2> <div class="table-wrapper"><table><thead><tr><th>Dependency</th><th>Role</th><th>Source</th></tr></thead><tbody> <tr><td>Files module</td><td>attachment upload/download/delete; <code>file.upload/read/delete</code> perms</td><td><code>files.controller.ts:30,44,50,56,67</code></td></tr> <tr><td>Storage provider</td><td>object storage behind <code>STORAGE_PROVIDER</code> token</td><td><code>storage-provider.ts:21-27</code></td></tr> <tr><td>Notifications module</td><td>in-app notification read API for homework events</td><td><code>notifications.controller.ts:21-47</code></td></tr> <tr><td>BullMQ <code>in-app</code> queue</td><td><code>HomeworkCreated/Updated/Submitted/Graded</code> → notification jobs</td><td><code>event-queue-map.ts:22-25</code></td></tr> <tr><td>BullMQ <code>audit-write</code> queue</td><td><code>HomeworkDeleted</code> → audit log job</td><td><code>event-queue-map.ts:26</code></td></tr> <tr><td>Academic modules</td><td><code>Class</code>, <code>Subject</code>, <code>Student</code>, <code>Teacher</code> referenced ids</td><td><code>homework.schema.ts:9-16</code>, <code>homework-submission.schema.ts:9-13</code></td></tr> <tr><td>Mongo collections</td><td><code>homework</code>, <code>homework_submissions</code></td><td><code>blueprint 03-Database/COLLECTIONS.md:1900-1958</code></td></tr> </tbody></table> </div> <h2 id="7-success-metrics-8"><a class="header" href="#7-success-metrics-8">7. Success metrics</a></h2> <ul> <li>Homework creation → visible to class < 2 s (create + list round-trip).</li> <li>Submission success rate ≥ 99.5% of attempts (excl. intended 409 duplicates).</li> <li>Duplicate-submission 409 handled gracefully 100% of the time (never a crash/blank).</li> <li>Graded feedback visible immediately after <code>PATCH …/grade</code> success.</li> <li>Zero cross-tenant leaks in lists/details (tenant scope on every query).</li> </ul> <h2 id="8-edge-cases-4"><a class="header" href="#8-edge-cases-4">8. Edge cases</a></h2> <ul> <li><strong>Duplicate submission</strong> → 409 <code>DUPLICATE_RESOURCE</code> "Already submitted." (<code>homework.service.ts:92</code>) — also structurally enforced by unique index (<code>homework-submission.schema.ts:37-40</code>).</li> <li><strong>Submit to unknown homework</strong> → 404 <code>RESOURCE_NOT_FOUND</code> "Homework not found." (<code>homework.service.ts:87,47</code>).</li> <li><strong>Grade unknown submission</strong> → 404 "Submission not found." (<code>homework.service.ts:126,135</code>).</li> <li><strong>Update/delete unknown homework</strong> → 404 (<code>homework.service.ts:56-58,71-72</code>).</li> <li><strong>Late submission:</strong> the server does <strong>not</strong> check <code>dueDate</code> on submit — late submissions are accepted (OQ-1). The UI must show a late badge derived client-side.</li> <li><strong>Regrading:</strong> <code>PATCH …/grade</code> is repeatable; each call overwrites marks/remarks, resets <code>gradedAt</code>, and emits another <code>HomeworkGraded</code> (OQ-2).</li> <li><strong>Closed homework:</strong> <code>status: 'closed'</code> is settable via update (<code>homework.dto.ts:55-58</code>), but <strong>nothing enforces it</strong> — submit/grade still work on closed homework (OQ-3).</li> <li><strong>Marks validation:</strong> <code>GradeSubmissionDto.marks</code> has <strong>no class-validator decorator</strong> (<code>submission.dto.ts:20-21</code>) — negative/non-numeric values are not blocked at the DTO layer (OQ-4).</li> <li><strong>Delete homework:</strong> soft-delete only (<code>base.repository.ts:68-74</code>); submissions are <strong>not</strong> cascaded (no cascade in <code>remove()</code>, <code>homework.service.ts:70-81</code>).</li> <li><strong>Attachment limits:</strong> no size/mime limits in code (<code>files.controller.ts:39</code>, <code>files.service.ts:27-46</code>) — client must enforce (see <a href="homework/14_QA_Checklist.html">14_QA_Checklist.md</a>).</li> <li><strong>Update semantics:</strong> only <code>title, description, attachments, dueDate, status</code> are updatable; <code>classId/subjectId/teacherId</code> are immutable after create (<code>homework.dto.ts:35-58</code> vs <code>4-33</code>).</li> <li><strong>findByClass sort:</strong> newest due date first (<code>dueDate: -1</code>, <code>homework.repository.ts:20</code>).</li> </ul> <h2 id="9-assumptions-module-8"><a class="header" href="#9-assumptions-module-8">9. Assumptions (module)</a></h2> <ul> <li>Mobile client is <strong>forward-looking</strong>: backend is complete; this package is the UI-side spec (<a href="homework/../00-shared/12_Assumptions_&_Open_Questions.html">00-shared/12 A1</a>).</li> <li>"Student sees homework scoped to my class" (PLAN.md:67, 6.2) has <strong>no dedicated endpoint</strong> — the client resolves the student's <code>classId</code> from the Students module profile, then calls <code>GET /homework/class/:classId</code> (OQ-6).</li> <li><code>attachments: string[]</code> on homework and submissions are <strong>file record ids</strong> returned by <code>POST /files/upload</code> (<code>files.service.ts:27-46</code>); the blueprint's storage path <code>sl/{tenantId}/homework/{uuid}</code> (Homework.md:58) is plan-only — actual storage names files <code>${randomUUID()}--${originalname}</code> (<code>files.service.ts:31</code>).</li> <li>The blueprint's domain-event table (Homework.md:37-41: <code>HomeworkAssigned</code>, <code>HomeworkSubmitted</code>) is <strong>stale</strong>; the code emits <code>HomeworkCreated/Updated/Deleted/ Submitted/Graded</code> (<code>homework.service.ts:34,59,73,99,136</code>). Code wins.</li> <li>Blueprint "Reminders and due alerts" (Homework.md:17) and "Overdue detection via scheduled job" (Homework.md:60) are <strong>not implemented</strong> — no scheduler/worker exists (OQ-1).</li> <li><code>HomeworkCreated/Updated/Submitted/Graded</code> are routed to the <code>in-app</code> queue (<code>event-queue-map.ts:22-25</code>), but <code>NotificationType</code> enum (<code>notification.schema.ts:7-12</code>) does <strong>not</strong> contain these values — notification persistence currently fails Mongoose enum validation; treat the in-app notification surface as <strong><code>(planned)</code></strong> until the enum is extended (OQ-8).</li> <li>Homework endpoints are guarded only by <code>JwtAuthGuard</code> (<code>homework.controller.ts:19</code>) — <strong>no <code>@Permissions()</code> metadata and no <code>homework.*</code> permissions exist</strong> (<code>permissions.constants.ts:1-97</code>). Any authenticated user can call every homework endpoint today (OQ-9).</li> </ul> <h2 id="10-open-questions-module-level-global-ledger-in-00-shared12-5"><a class="header" href="#10-open-questions-module-level-global-ledger-in-00-shared12-5">10. Open questions (module-level; global ledger in <a href="homework/../00-shared/12_Assumptions_&_Open_Questions.html">00-shared/12</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Item</th><th>Impact</th></tr></thead><tbody> <tr><td>OQ-1</td><td>No due-date enforcement and no overdue scheduler exists — late/duplicate-submission-after-due are accepted silently</td><td>Late-submission UI semantics, badge copy, future enforcement</td></tr> <tr><td>OQ-2</td><td>Grading can be repeated with no regrade guard — each call emits <code>HomeworkGraded</code></td><td>Regrade confirmation UX; audit noise</td></tr> <tr><td>OQ-3</td><td><code>status: 'closed'</code> is not enforced anywhere</td><td>"Closed" filter vs. submit/grade gating in UI</td></tr> <tr><td>OQ-4</td><td><code>marks</code> has no validation decorator (<code>submission.dto.ts:20-21</code>)</td><td>Client-side range validation only; flag to backend</td></tr> <tr><td>OQ-5</td><td>No parent-facing homework API (<code>IMPLEMENTATION_PLAN.md:227</code> "parent view" is <code>(planned)</code>)</td><td>Parent persona is read-only via… nothing yet</td></tr> <tr><td>OQ-6</td><td>No <code>GET /homework</code> (student's own class) endpoint despite PLAN.md:67</td><td>Student list resolves classId from student profile</td></tr> <tr><td>OQ-7</td><td>No admin/dashboard homework endpoints</td><td>Org admin has no homework surface beyond notifications</td></tr> <tr><td>OQ-8</td><td><code>NotificationType</code> enum lacks homework event types — in-app homework notifications fail validation today (<code>inapp.worker.ts:46-53</code>)</td><td>Notification badge/detail UX <code>(planned)</code></td></tr> <tr><td>OQ-9</td><td>No RBAC on homework endpoints; no <code>homework.*</code> perms in <code>ALL_PERMISSIONS</code> (<code>permissions.constants.ts</code>) despite blueprint intent (Homework.md:66-71)</td><td>Role-gated UI must wait for server-side perms</td></tr> <tr><td>OQ-10</td><td>No homework emails: <code>email.worker.ts:40-42</code> warns "No handler" for unknown event types</td><td>Email reminders <code>(planned)</code></td></tr> </tbody></table> </div> <h2 id="11-glossary-this-module-8"><a class="header" href="#11-glossary-this-module-8">11. Glossary (this module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Term</th><th>Meaning</th></tr></thead><tbody> <tr><td>Homework</td><td><code>homework</code> doc: assignment scoped to <code>teacherId+classId+subjectId</code>, <code>dueDate</code>, optional attachments, status <code>active</code>/<code>closed</code></td></tr> <tr><td>Submission</td><td><code>homework_submissions</code> doc: one per (homework, student); status <code>submitted</code>/<code>graded</code>; marks optional</td></tr> <tr><td>Attachment</td><td>string[] of <code>files</code> record ids (uploaded via FilesModule)</td></tr> <tr><td>Graded</td><td>submission status after <code>PATCH …/grade</code>; sets <code>marks, remarks, gradedAt</code></td></tr> <tr><td>Envelope</td><td><code>{success,message,data,meta?,timestamp,requestId}</code> (<code>response-envelope.interceptor.ts:49-52</code>)</td></tr> <tr><td>409 Duplicate</td><td>exactly-once submission semantics: service check + unique index</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-homework-module"><a class="header" href="#02--user-personas-homework-module">02 — User Personas (Homework Module)</a></h1> <ul> <li><a href="homework/02_User_Personas.html#1-teacher-subjectclass-teacher--primary-creator--grader">1. Teacher (subject/class teacher) — primary creator & grader</a></li> <li><a href="homework/02_User_Personas.html#2-student--single-time-submitter">2. Student — single-time submitter</a></li> <li><a href="homework/02_User_Personas.html#3-parent--read-only-monitor">3. Parent — read-only monitor</a></li> <li><a href="homework/02_User_Personas.html#4-org-admin--overseer">4. Org Admin — overseer</a></li> <li><a href="homework/02_User_Personas.html#5-platform-admin-cross-tenant-support">5. Platform Admin (cross-tenant support)</a></li> <li><a href="homework/02_User_Personas.html#6-role--homework-appearance-matrix">6. Role → homework-appearance matrix</a></li> </ul> <blockquote> <p>Roles, goals, permissions, and expected behavior for every consumer of the Homework module. Permission values are <strong>exact</strong> from <code>rbac/permissions.constants.ts</code> (<code>ALL_PERMISSIONS</code>). Homework endpoints today require <strong>only a valid JWT</strong> (<code>homework.controller.ts:19</code> <code>@UseGuards(JwtAuthGuard)</code>) — no <code>homework.*</code> permission exists (OQ-9 of <a href="homework/01_Product_Overview.html">01</a>). Files endpoints are the exception: <code>file.upload</code> / <code>file.read</code> / <code>file.delete</code> (<code>files.controller.ts:30,44,50,56,67</code>).</p> </blockquote> <hr /> <h2 id="1-teacher-subjectclass-teacher--primary-creator--grader"><a class="header" href="#1-teacher-subjectclass-teacher--primary-creator--grader">1. Teacher (subject/class teacher) — primary creator & grader</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Frequency</td><td>Daily; creates 1–5 homework per class per week, grades in batches</td></tr> <tr><td>Permissions</td><td>JWT only for homework CRUD/submit/grade today (<code>homework.controller.ts:19</code>); <code>file.upload</code> for attachments (<code>files.controller.ts:30</code>)</td></tr> <tr><td>Goal</td><td>Assign homework with a clear due date; see submission status per student; grade with marks + remarks; re-grade when a student asks</td></tr> <tr><td>Pain points</td><td>Late submissions mixed with on-time ones (no server late flag — OQ-1); repeated grading accidentally double-emits events; no bulk grade endpoints</td></tr> <tr><td>Expected</td><td>Class-filtered homework list sorted by due date (<code>homework.repository.ts:20</code>); submission list per homework; grade sheet that persists on error (never lose typed marks)</td></tr> <tr><td>Mental model</td><td>"I post it, they submit once, I mark it."</td></tr> <tr><td>Core jobs</td><td><code>POST /homework</code> (create), <code>GET /homework/class/:classId</code> (list), <code>GET /homework/:id/submissions</code> (grade sheet), <code>PATCH /homework/:id/submissions/:submissionId/grade</code> (grade), <code>PATCH /homework/:id</code> (edit), <code>DELETE /homework/:id</code> (remove)</td></tr> </tbody></table> </div> <h2 id="2-student--single-time-submitter"><a class="header" href="#2-student--single-time-submitter">2. Student — single-time submitter</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Frequency</td><td>Daily check; submit before due date</td></tr> <tr><td>Permissions</td><td>JWT only; no special student scoping server-side (any JWT may submit any homework with any <code>studentId</code> — <code>submit()</code> trusts <code>dto.studentId</code>, <code>homework.service.ts:83-112</code>)</td></tr> <tr><td>Goal</td><td>See pending homework for my class, submit exactly once with optional attachment, see my marks + teacher remarks</td></tr> <tr><td>Pain points</td><td>No per-student "my homework" endpoint (PLAN.md:67 is not implemented — OQ-6); duplicate tap on submit → 409; no server-side late flag</td></tr> <tr><td>Expected</td><td>My-class list resolved via student profile <code>classId</code> → <code>GET /homework/class/:classId</code>; submit button that disables after success; grade feedback row when <code>status: 'graded'</code> (<code>homework-submission.schema.ts:24</code>)</td></tr> <tr><td>Mental model</td><td>"Submit once, done. Teacher's feedback appears in the same place."</td></tr> <tr><td>Core jobs</td><td><code>GET /homework/class/:classId</code>, <code>GET /homework/:id</code>, <code>POST /homework/:id/submit</code>, <code>GET /files/:id/download</code></td></tr> </tbody></table> </div> <h2 id="3-parent--read-only-monitor"><a class="header" href="#3-parent--read-only-monitor">3. Parent — read-only monitor</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Frequency</td><td>Weekly; check pending/graded homework of children</td></tr> <tr><td>Permissions</td><td><code>student.read</code> (role seed intent, <code>role.schema.ts</code>); <strong>no homework API exists for parents</strong> — "parent view" is <code>(planned)</code> (<code>IMPLEMENTATION_PLAN.md:227</code>)</td></tr> <tr><td>Goal</td><td>Know what's assigned, whether the child submitted, and the grade</td></tr> <tr><td>Pain points</td><td>Nothing to call today (OQ-5); relies on notifications <code>(planned)</code> and the child's device</td></tr> <tr><td>Expected</td><td>Deep link to homework detail from notification; read-only rendering (no submit/grade controls)</td></tr> <tr><td>Mental model</td><td>"Is it done, and what did they get?"</td></tr> <tr><td>Core jobs</td><td>(planned) <code>GET /homework/class/:classId</code> + <code>GET /homework/:id</code> read-only; today: notification feed only</td></tr> </tbody></table> </div> <h2 id="4-org-admin--overseer"><a class="header" href="#4-org-admin--overseer">4. Org Admin — overseer</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Frequency</td><td>On demand (complaints, audits)</td></tr> <tr><td>Permissions</td><td>JWT only; <code>audit.read</code> exists in <code>ALL_PERMISSIONS</code> (<code>permissions.constants.ts:54</code>) but no homework admin surface</td></tr> <tr><td>Goal</td><td>See what homework exists in a class, who graded what, intervene (edit/delete)</td></tr> <tr><td>Pain points</td><td>No dashboard/aggregate homework endpoints (OQ-7)</td></tr> <tr><td>Expected</td><td>Class homework list + submission list via the same generic endpoints; delete as cleanup tool</td></tr> <tr><td>Mental model</td><td>"Same screens as a teacher, with delete power."</td></tr> </tbody></table> </div> <h2 id="5-platform-admin-cross-tenant-support-1"><a class="header" href="#5-platform-admin-cross-tenant-support-1">5. Platform Admin (cross-tenant support)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Permissions</td><td><code>isPlatformAdmin</code> bypasses tenant scope in <code>scopedFilter</code> (<code>base.repository.ts:21-23</code>)</td></tr> <tr><td>Behavior</td><td>Can read any tenant's homework via the same endpoints; support-diagnostics only</td></tr> </tbody></table> </div> <h2 id="6-role--homework-appearance-matrix"><a class="header" href="#6-role--homework-appearance-matrix">6. Role → homework-appearance matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th>Value</th><th>Teacher</th><th>Student</th><th>Parent</th><th>Org admin</th></tr></thead><tbody> <tr><td>Create homework form</td><td>✓</td><td>—</td><td>—</td><td>✓</td></tr> <tr><td>Edit / delete homework</td><td>✓</td><td>—</td><td>—</td><td>✓</td></tr> <tr><td>Class homework list</td><td>✓ (own classes)</td><td>✓ (my class, resolved)</td><td>(planned)</td><td>✓</td></tr> <tr><td>Submission form</td><td>—</td><td>✓</td><td>—</td><td>—</td></tr> <tr><td>Grade sheet</td><td>✓</td><td>—</td><td>—</td><td>—</td></tr> <tr><td>Grade feedback view</td><td>✓</td><td>✓ (own only, client-gated)</td><td>(planned)</td><td>✓</td></tr> <tr><td>Attachments upload</td><td>✓ (<code>file.upload</code>)</td><td>✓ (<code>file.upload</code>)</td><td>—</td><td>✓</td></tr> <tr><td>Attachment download</td><td>✓ (<code>file.read</code>)</td><td>✓ (<code>file.read</code>)</td><td>(planned)</td><td>✓</td></tr> </tbody></table> </div> <blockquote> <p><strong>Honesty note:</strong> the server does not distinguish these roles on homework endpoints today (JWT-only guard, OQ-9). The matrix is the <strong>intended product behavior</strong> the client should implement with role-based UI gating (hide create/grade controls for students), which becomes authoritative the moment <code>@Permissions('homework.*')</code> land.</p> </blockquote> <div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journeys-homework-module"><a class="header" href="#03--user-journeys-homework-module">03 — User Journeys (Homework Module)</a></h1> <ul> <li><a href="homework/03_User_Journeys.html#1-teacher-creates-homework-with-attachment-planmd-61">1. Teacher creates homework with attachment (PLAN.md 6.1)</a></li> <li><a href="homework/03_User_Journeys.html#2-student-views-my-class-homework-planmd-62">2. Student views my-class homework (PLAN.md 6.2)</a></li> <li><a href="homework/03_User_Journeys.html#3-student-submits-homework-planmd-63">3. Student submits homework (PLAN.md 6.3)</a></li> <li><a href="homework/03_User_Journeys.html#4-teacher-grades--feedback-planmd-64">4. Teacher grades + feedback (PLAN.md 6.4)</a></li> <li><a href="homework/03_User_Journeys.html#5-edit--delete-homework-planmd-65">5. Edit / delete homework (PLAN.md 6.5)</a></li> <li><a href="homework/03_User_Journeys.html#6-late-submission-handling">6. Late submission handling</a></li> <li><a href="homework/03_User_Journeys.html#7-parent-sees-pending--grade-read-only">7. Parent sees pending / grade (read-only)</a></li> <li><a href="homework/03_User_Journeys.html#8-cross-cutting">8. Cross-cutting</a></li> </ul> <blockquote> <p>End-to-end journeys computed from <code>homework.controller.ts</code>, <code>homework.service.ts</code>, <code>files.controller.ts</code>, <code>event-queue-map.ts</code> and <code>PLAN.md §6.1–6.5</code>. Each journey: entry, intent, decision points, system responses, loading, failures, recovery, exit, abandonment, offline. <code>(planned)</code> / <code>(forward-looking)</code> marks per global rules.</p> </blockquote> <hr /> <h2 id="1-teacher-creates-homework-with-attachment-planmd-61"><a class="header" href="#1-teacher-creates-homework-with-attachment-planmd-61">1. Teacher creates homework with attachment (PLAN.md 6.1)</a></h2> <pre><code>entry: Homework tab → FAB "New homework"; intent: assign work to a class </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>Decision point:</strong> due date picker min = today (client-side; server only validates <code>IsDateString</code>, <code>homework.dto.ts:31-32</code>).</li> <li><strong>Attachments:</strong> upload first via FilesModule, then reference <code>_id</code> in <code>attachments[]</code>. Upload is a separate request — a failure mid-upload must not create the homework.</li> <li><strong>Failure:</strong> 400 <code>VALIDATION_ERROR</code> (missing <code>title</code>/<code>dueDate</code>/MongoIds, <code>homework.dto.ts:4-33</code>) → field errors; 5xx → <code>AppErrorState</code> + retry (form state kept).</li> <li><strong>Exit:</strong> success → detail screen; abandonment → draft resume (client memo, <code>(proposed)</code>).</li> <li><strong>Offline:</strong> creation blocked (write queue not defined for homework create), attachment upload queued with retry per <code>00-shared/07 §10</code>.</li> </ul> <h2 id="2-student-views-my-class-homework-planmd-62"><a class="header" href="#2-student-views-my-class-homework-planmd-62">2. Student views my-class homework (PLAN.md 6.2)</a></h2> <pre><code>entry: Homework tab; intent: see what's due and my submission status </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>No dedicated student endpoint</strong> — classId resolution from the profile is the OQ-6 workaround (PLAN.md:67 lists <code>GET /api/v1/homework</code> as intended; not implemented).</li> <li><strong>Per-card my-state:</strong> client cross-checks against <code>GET /homework/:id/submissions</code> (only feasible for a single homework; full per-student state is <code>(planned)</code> — OQ-6).</li> <li><strong>Failure:</strong> 404 → <code>AppEmptyState</code>; 5xx → <code>AppErrorState</code> + retry; pull-to-refresh.</li> <li><strong>Loading:</strong> <code>AppSkeleton(list)</code>; cache TTL 5 min (volatile — due dates change).</li> </ul> <h2 id="3-student-submits-homework-planmd-63"><a class="header" href="#3-student-submits-homework-planmd-63">3. Student submits homework (PLAN.md 6.3)</a></h2> <pre><code>entry: homework detail → "Submit" (only if not already submitted) </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>Exactly-once:</strong> double-tap / retry → second call returns 409 <code>DUPLICATE_RESOURCE</code> "Already submitted." (<code>homework.service.ts:92</code>) — client maps to "Already submitted" info state, <strong>not</strong> an error.</li> <li><strong>Submit button</strong> disabled permanently after success (server truth; no optimistic write — the doc id comes from the server, <code>00-shared/07 §9</code>).</li> <li><strong>Failure:</strong> 404 homework deleted meanwhile → <code>RESOURCE_NOT_FOUND</code> empty state; network loss mid-upload → upload pending/retry, no silent corruption (<code>10 §2</code>).</li> <li><strong>Offline:</strong> submit blocked; guidance + draft kept locally (offline queue <code>(planned)</code>).</li> </ul> <h2 id="4-teacher-grades--feedback-planmd-64"><a class="header" href="#4-teacher-grades--feedback-planmd-64">4. Teacher grades + feedback (PLAN.md 6.4)</a></h2> <pre><code>entry: homework detail → "Submissions" tab → tap student row → grade sheet </code></pre> <pre class="mermaid">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) </pre> <ul> <li><strong>Regrade:</strong> grading an already-graded submission overwrites and emits another <code>HomeworkGraded</code> (OQ-2) — confirm dialog on regrade ("This will overwrite the existing grade").</li> <li><strong>Validation:</strong> client enforces <code>marks ≥ 0</code> and ≤ max marks (configurable; server has <strong>no</strong> decorator — <code>submission.dto.ts:20-21</code>, OQ-4).</li> <li><strong>Failure:</strong> 404 "Submission not found." (<code>homework.service.ts:126</code>) → row refresh; network loss → grade sheet keeps typed values, retry offered.</li> <li><strong>No optimistic grading</strong> (side effect + notification event): server-confirmed only.</li> </ul> <h2 id="5-edit--delete-homework-planmd-65"><a class="header" href="#5-edit--delete-homework-planmd-65">5. Edit / delete homework (PLAN.md 6.5)</a></h2> <pre><code>entry: homework detail → menu → Edit / Delete </code></pre> <pre class="mermaid">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) </pre> <ul> <li><strong>Immutable fields:</strong> <code>classId</code>, <code>subjectId</code>, <code>teacherId</code> are <strong>not</strong> in <code>UpdateHomeworkDto</code> (<code>homework.dto.ts:35-58</code>) — form disables them after create.</li> <li><strong>Delete is soft</strong> (<code>base.repository.ts:68-74</code>); <code>GET /homework/class/:classId</code> will no longer return it (isDeleted filter). Submissions are <strong>not</strong> deleted (no cascade, OQ-1/01).</li> <li><strong>Failure:</strong> 404 if already removed (refresh list); offline → blocked.</li> </ul> <h2 id="6-late-submission-handling"><a class="header" href="#6-late-submission-handling">6. Late submission handling</a></h2> <pre><code>entry: student submits after dueDate (server allows — OQ-1) </code></pre> <ul> <li>Server <strong>accepts</strong> late submissions; <code>submittedAt</code> is server-set (<code>homework.service.ts:96</code>).</li> <li>Client derives <code>isLate = submittedAt > dueDate</code> <strong>after</strong> the response and shows a <code>tertiary</code> badge "Late · 2d" on the submission row (no server flag exists — the badge is client-computed, <code>(forward-looking)</code> pending a server <code>isLate</code> field).</li> <li>Grading sheet highlights late rows (<code>AppBadge</code> warning) so the teacher can apply policy.</li> <li><strong>Open question (OQ-1):</strong> 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.</li> </ul> <h2 id="7-parent-sees-pending--grade-read-only"><a class="header" href="#7-parent-sees-pending--grade-read-only">7. Parent sees pending / grade (read-only)</a></h2> <pre><code>entry: notification deep link (planned) or child's device — no parent API today (OQ-5) </code></pre> <ul> <li><code>(planned)</code> <code>GET /homework/class/:classId</code> read-only + <code>GET /homework/:id/submissions</code> for the child's row (parent → student linkage lives in the Students module).</li> <li>Today: parent sees homework activity only through in-app notifications <code>(planned — OQ-8)</code>; deep link <code>studylyon://homework/:id</code> (<code>00-shared/05 §4</code>).</li> </ul> <h2 id="8-cross-cutting-1"><a class="header" href="#8-cross-cutting-1">8. Cross-cutting</a></h2> <div class="table-wrapper"><table><thead><tr><th>Entry</th><th>Behavior</th><th>Status</th></tr></thead><tbody> <tr><td>Deep link <code>studylyon://homework/:id</code></td><td>opens detail (role-gated actions)</td><td><code>(forward-looking)</code> client</td></tr> <tr><td>Push "homework graded"</td><td>opens grade feedback</td><td><code>(planned)</code> push infra (<code>00-shared/12 B3</code>)</td></tr> <tr><td>Notification → homework</td><td><code>HomeworkGraded</code> etc. become in-app notifications</td><td><code>(planned)</code> — enum gap OQ-8</td></tr> </tbody></table> </div> <p><strong>Abandonment & exit rules:</strong> 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.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-homework-module"><a class="header" href="#04--information-architecture-homework-module">04 — Information Architecture (Homework Module)</a></h1> <ul> <li><a href="homework/04_Information_Architecture.html#1-zone-authenticated-app--homework-destination">1. Zone: authenticated app → Homework destination</a></li> <li><a href="homework/04_Information_Architecture.html#2-route-table-module-owned-extends-00-shared05-4">2. Route table (module-owned; extends <code>00-shared/05 §4</code>)</a></li> <li><a href="homework/04_Information_Architecture.html#3-screen-hierarchy">3. Screen hierarchy</a></li> <li><a href="homework/04_Information_Architecture.html#4-modal-hierarchy-module-per-00-shared05-5">4. Modal hierarchy (module, per <code>00-shared/05 §5</code>)</a></li> <li><a href="homework/04_Information_Architecture.html#5-screen-relationships">5. Screen relationships</a></li> <li><a href="homework/04_Information_Architecture.html#6-quick-actions--fab--context-menus">6. Quick actions / FAB / context menus</a></li> <li><a href="homework/04_Information_Architecture.html#7-information-needs-per-screen-content-model">7. Information needs per screen (content model)</a></li> <li><a href="homework/04_Information_Architecture.html#8-accessibility-of-ia">8. Accessibility of IA</a></li> </ul> <blockquote> <p>Where Homework screens live in the shell (<a href="homework/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §2</a>: <code>/homework</code> destination, roles teacher, student, parent-read). Routes mirror the API exactly: <code>homework.controller.ts</code> exposes <code>POST /homework</code>, <code>GET /homework/class/:classId</code>, <code>GET /homework/:id</code>, <code>PATCH /homework/:id</code>, <code>DELETE /homework/:id</code>, <code>POST /homework/:id/submit</code>, <code>GET /homework/:id/submissions</code>, <code>PATCH /homework/:id/submissions/:submissionId/grade</code>.</p> </blockquote> <hr /> <h2 id="1-zone-authenticated-app--homework-destination"><a class="header" href="#1-zone-authenticated-app--homework-destination">1. Zone: authenticated app → Homework destination</a></h2> <pre><code>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) </code></pre> <ul> <li>Phone: push navigation; Tablet/desktop (≥ 840 dp): master-detail — list left, detail right (<code>00-shared/04 §6</code>).</li> </ul> <h2 id="2-route-table-module-owned-extends-00-shared05-4-3"><a class="header" href="#2-route-table-module-owned-extends-00-shared05-4-3">2. Route table (module-owned; extends <code>00-shared/05 §4</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Route</th><th>Screen</th><th>Auth</th><th>Source API</th></tr></thead><tbody> <tr><td><code>/homework</code></td><td>Homework list (class-filtered)</td><td>JWT</td><td><code>GET /homework/class/:classId</code></td></tr> <tr><td><code>/homework/new</code></td><td>Create homework form</td><td>JWT (UI-gated to teacher/admin — OQ-9)</td><td><code>POST /homework</code> (+ <code>POST /files/upload</code>)</td></tr> <tr><td><code>/homework/:id</code></td><td>Homework detail + my submission</td><td>JWT</td><td><code>GET /homework/:id</code>, <code>GET /homework/:id/submissions</code></td></tr> <tr><td><code>/homework/:id/edit</code></td><td>Edit form</td><td>JWT</td><td><code>PATCH /homework/:id</code></td></tr> <tr><td><code>/homework/:id/submissions</code></td><td>Submission list (teacher)</td><td>JWT</td><td><code>GET /homework/:id/submissions</code></td></tr> <tr><td><code>/homework/:id/submissions/:submissionId</code></td><td>Grading sheet / grade feedback</td><td>JWT</td><td><code>PATCH …/grade</code>; <code>GET /files/:id/download</code></td></tr> <tr><td><code>/homework/:id/submit</code></td><td>Submission form (student)</td><td>JWT</td><td><code>POST /homework/:id/submit</code> (+ <code>POST /files/upload</code>)</td></tr> </tbody></table> </div> <p>Deep link: <code>studylyon://homework/:id</code> (<code>00-shared/05 §4</code>).</p> <h2 id="3-screen-hierarchy-3"><a class="header" href="#3-screen-hierarchy-3">3. Screen hierarchy</a></h2> <pre class="mermaid">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/] </pre> <ul> <li>List is the hub: creation, detail, and (teacher) submissions all branch from it.</li> <li>Detail is role-shaped: student sees <strong>Submit</strong> + <strong>grade feedback</strong>; teacher sees <strong>Edit/Delete menu</strong> + <strong>Submissions</strong>; parent sees read-only (planned).</li> </ul> <h2 id="4-modal-hierarchy-module-per-00-shared05-5"><a class="header" href="#4-modal-hierarchy-module-per-00-shared05-5">4. Modal hierarchy (module, per <code>00-shared/05 §5</code>)</a></h2> <ol> <li><strong>Snackbar/toast</strong> — "Homework assigned", "Submitted", "Graded", "Copied" (attachment).</li> <li><strong>Bottom sheet</strong> — attachment preview picker, grade summary quick-view.</li> <li><strong>Dialog</strong> — delete confirm, regrade confirm ("overwrites existing grade"), submit confirm when late (client-computed flag).</li> <li><strong>Full-screen page</strong> — create/edit form (> 3 fields → page), grading sheet (list-like, full screen for batch work).</li> <li><strong>Loader</strong> — inline spinners / skeletons; never full-screen dual column.</li> </ol> <h2 id="5-screen-relationships-4"><a class="header" href="#5-screen-relationships-4">5. Screen relationships</a></h2> <ul> <li>List → detail → edit returns → detail refreshes (<code>Refresh</code>).</li> <li>Detail → submissions → grade sheet returns → submission row shows new grade.</li> <li>Student detail → submit success → detail shows "Submitted" state (server doc is the single source of truth).</li> <li>Any homework state change on server (graded elsewhere) → next screen open re-fetches; WS <code>(planned)</code> topic for homework not in <code>00-shared/07 §8</code> list — use <code>notification.new</code> + pull-to-refresh.</li> </ul> <h2 id="6-quick-actions--fab--context-menus-1"><a class="header" href="#6-quick-actions--fab--context-menus-1">6. Quick actions / FAB / context menus</a></h2> <ul> <li><strong>FAB</strong> on list: "New homework" (teacher/admin only; one FAB per screen, <code>00-shared/05 §7</code>).</li> <li><strong>Context menu</strong> on homework row: Edit, Delete (teacher/admin). On submission row (teacher): Open, Grade.</li> <li><strong>Swipe</strong> (phone): row actions Edit/Delete (≤ 2, <code>00-shared/08 §1</code>).</li> <li><strong>Keyboard (desktop):</strong> <code>N</code> new homework on list; <code>Esc</code> closes sheets/dialogs.</li> </ul> <h2 id="7-information-needs-per-screen-content-model-1"><a class="header" href="#7-information-needs-per-screen-content-model-1">7. Information needs per screen (content model)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Primary data</th><th>Secondary</th></tr></thead><tbody> <tr><td>List</td><td><code>title</code>, <code>dueDate</code>, <code>subjectId</code>, <code>status</code> (<code>homework.schema.ts:18-34</code>)</td><td>teacher name (resolved), my-submission badge</td></tr> <tr><td>Detail</td><td>full doc + attachments + description</td><td>submissions count (teacher) / my submission + grade (student)</td></tr> <tr><td>Grade sheet</td><td>submissions: <code>studentId</code>, <code>status</code>, <code>submittedAt</code>, <code>marks?</code>, <code>remarks?</code> (<code>homework-submission.schema.ts:9-31</code>)</td><td>attachment preview, late badge (client-computed)</td></tr> </tbody></table> </div> <h2 id="8-accessibility-of-ia-1"><a class="header" href="#8-accessibility-of-ia-1">8. Accessibility of IA</a></h2> <ul> <li>One <code>headlineMedium</code> per screen; list rows read <code>title, subject, due</code>; grade rows read <code>student, status, marks</code>.</li> <li>Focus order: list → filter → FAB; forms in field order (<code>08_Form_Specifications.md</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-homework-module"><a class="header" href="#05--screen-inventory-homework-module">05 — Screen Inventory (Homework Module)</a></h1> <ul> <li><a href="homework/05_Screen_Inventory.html#legend">Legend</a></li> <li><a href="homework/05_Screen_Inventory.html#1-homework-list-teacher-class-filtered-homework">1. Homework List (teacher: class-filtered) <code>/homework</code></a></li> <li><a href="homework/05_Screen_Inventory.html#2-homework-list-student-my-class-homework">2. Homework List (student: my class) <code>/homework</code></a></li> <li><a href="homework/05_Screen_Inventory.html#3-homework-detail-homeworkid">3. Homework Detail <code>/homework/:id</code></a></li> <li><a href="homework/05_Screen_Inventory.html#4-create--edit-homework-form-homeworknew-homeworkidedit">4. Create / Edit Homework Form <code>/homework/new</code>, <code>/homework/:id/edit</code></a></li> <li><a href="homework/05_Screen_Inventory.html#5-submission-form-homeworkidsubmit-or-sheet">5. Submission Form <code>/homework/:id/submit</code> (or sheet)</a></li> <li><a href="homework/05_Screen_Inventory.html#6-submissions-list-teacher-homeworkidsubmissions">6. Submissions List (teacher) <code>/homework/:id/submissions</code></a></li> <li><a href="homework/05_Screen_Inventory.html#7-grading-sheet-homeworkidsubmissionssubmissionid">7. Grading Sheet <code>/homework/:id/submissions/:submissionId</code></a></li> <li><a href="homework/05_Screen_Inventory.html#8-grade-feedback-view-student--within-homeworkid">8. Grade Feedback View (student) — within <code>/homework/:id</code></a></li> <li><a href="homework/05_Screen_Inventory.html#9-attachment-preview--download-shared-surface">9. Attachment Preview / Download (shared surface)</a></li> <li><a href="homework/05_Screen_Inventory.html#shared-components-used">Shared components used</a></li> <li><a href="homework/05_Screen_Inventory.html#analytics-events-proposed-00-shared10-8">Analytics events (proposed, 00-shared/10 §8)</a></li> <li><a href="homework/05_Screen_Inventory.html#keyboard-landscape-tablet-desktop">Keyboard, landscape, tablet, desktop</a></li> </ul> <blockquote> <p>Every screen of the Homework module, its intent, route, composition, states, permissions, platform behavior, and events. Authoritative components in <a href="homework/../00-shared/03_Component_Library.html">00-shared/03</a>; module components in <a href="homework/07_Component_Library.html">07_Component_Library.md</a>. Analytics events follow <code>{module}.{screen}.{action}</code> <code>(proposed)</code>.</p> </blockquote> <hr /> <h2 id="legend-9"><a class="header" href="#legend-9">Legend</a></h2> <p>States = idle / loading / success / empty / error(offline, 404, 409, network) / disabled / permission. Role gating is <strong>client-side UI policy</strong> (server has no homework RBAC — OQ-9).</p> <hr /> <h2 id="1-homework-list-teacher-class-filtered-homework"><a class="header" href="#1-homework-list-teacher-class-filtered-homework">1. Homework List (teacher: class-filtered) <code>/homework</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Browse homework of one class, sorted by due date desc (<code>homework.repository.ts:20</code>)</td></tr> <tr><td>Entry</td><td>Homework tab; role-default class preselected; returns to after detail/edit</td></tr> <tr><td>Exit</td><td>→ detail; → create; → edit; class switch</td></tr> <tr><td>Inputs</td><td>Class filter <code>AppDropdown</code> (from Academics module), <code>AppSearchBar</code> (client-side filter; no <code>q</code> param on API)</td></tr> <tr><td>Composition</td><td>Class selector header + <code>AppSectionHeader</code> ("Due soon" / "Past due" client groups) + homework cards + <code>AppFAB</code> "New homework" (teacher)</td></tr> <tr><td>States</td><td>loading <code>AppSkeleton(list)</code>; empty <code>AppEmptyState</code> ("No homework for this class"); error <code>AppErrorState</code> (404 class invalid → "Class not found"); offline cached + banner</td></tr> <tr><td>Source</td><td><code>GET /api/v1/homework/class/:classId</code> → <strong>array</strong> (non-paginated; no <code>meta</code> — client renders all)</td></tr> <tr><td>Row actions</td><td><code>AppMenu</code>: Edit, Delete (teacher/admin); student: none</td></tr> <tr><td>Analytics</td><td><code>homework.list.open</code>, <code>homework.list.filter_class</code>, <code>homework.list.search</code></td></tr> <tr><td>Adaptive</td><td>phone full-width cards; tablet/desktop 2-col grid + master-detail detail pane</td></tr> <tr><td>a11y</td><td>row semantics <code>button</code>, card label = title + subject + due; FAB labeled "New homework"</td></tr> </tbody></table> </div> <h2 id="2-homework-list-student-my-class-homework"><a class="header" href="#2-homework-list-student-my-class-homework">2. Homework List (student: my class) <code>/homework</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Pending/assigned homework for the student's class</td></tr> <tr><td>Entry</td><td>Homework tab (student)</td></tr> <tr><td>Differences from #1</td><td>Class fixed = student profile <code>classId</code> (Students module); no FAB; no edit/delete; card shows <strong>my submission state</strong> chip (none/submitted/graded+marks)</td></tr> <tr><td>My-state source</td><td><code>GET /homework/:id/submissions</code> filtered client-side by my <code>studentId</code> — per-card fetch only for visible cards <code>(proposed: server flag)</code>; OQ-6</td></tr> <tr><td>States</td><td>as #1; plus "Submitted" chip state</td></tr> <tr><td>Analytics</td><td><code>homework.list.open</code>, <code>homework.student.view</code></td></tr> </tbody></table> </div> <h2 id="3-homework-detail-homeworkid"><a class="header" href="#3-homework-detail-homeworkid">3. Homework Detail <code>/homework/:id</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Full homework: title, subject, class, description, attachments, due date, status, assigned date; role-shaped actions</td></tr> <tr><td>Source</td><td><code>GET /homework/:id</code> → 404 <code>RESOURCE_NOT_FOUND</code> "Homework not found." (<code>homework.service.ts:47</code>)</td></tr> <tr><td>Composition</td><td>Header (title + subject chip + status badge), meta block (class, teacher, assignedDate, dueDate via <code>AppInfoRow</code>), <code>AppMarkdownViewer</code> description, <code>AppAttachmentList</code> (download/preview), role-actions: teacher → Edit/Delete menu + "Submissions (N)"; student → Submit CTA or submission/grade block; parent → read-only</td></tr> <tr><td>Student block</td><td>if no submission: <code>AppButton</code> "Submit"; if submitted: status chip + <code>submittedAt</code>; if graded: marks + remarks + <code>gradedAt</code></td></tr> <tr><td>States</td><td>loading skeleton; 404 empty state ("This homework was removed"); offline banner</td></tr> <tr><td>Analytics</td><td><code>homework.detail.open</code></td></tr> <tr><td>Adaptive</td><td>master-detail right pane ≥ 840 dp</td></tr> </tbody></table> </div> <h2 id="4-create--edit-homework-form-homeworknew-homeworkidedit"><a class="header" href="#4-create--edit-homework-form-homeworknew-homeworkidedit">4. Create / Edit Homework Form <code>/homework/new</code>, <code>/homework/:id/edit</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Create (<code>POST /homework</code>) or edit (<code>PATCH /homework/:id</code>)</td></tr> <tr><td>Fields</td><td>title (req), description (opt), class (req, create only — immutable after), subject (req, create only), dueDate (req), attachments (opt), status (edit only: <code>active</code>/<code>closed</code>) — exact fields <code>CreateHomeworkDto</code> / <code>UpdateHomeworkDto</code> (<code>homework.dto.ts:4-58</code>)</td></tr> <tr><td>Exit</td><td>success → detail; back → draft prompt (create)</td></tr> <tr><td>States</td><td>validation per <a href="homework/08_Form_Specifications.html">08_Form_Specifications.md</a>; 400 field errors; 5xx kept-form</td></tr> <tr><td>Analytics</td><td><code>homework.create.submit</code>, <code>homework.create.success</code>, <code>homework.edit.submit</code></td></tr> </tbody></table> </div> <h2 id="5-submission-form-homeworkidsubmit-or-sheet"><a class="header" href="#5-submission-form-homeworkidsubmit-or-sheet">5. Submission Form <code>/homework/:id/submit</code> (or sheet)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>One-time student submission: remarks + optional attachments</td></tr> <tr><td>Source</td><td><code>POST /homework/:id/submit</code> (<code>SubmitHomeworkDto</code> — <code>submission.dto.ts:4-17</code>)</td></tr> <tr><td>Precondition</td><td>no existing submission for (studentId, homeworkId) — server 409 guard (<code>homework.service.ts:88-92</code>)</td></tr> <tr><td>Composition</td><td>remarks <code>AppTextField</code> (multiline), <code>AppAttachmentUploader</code> (file.upload), Submit button</td></tr> <tr><td>States</td><td>uploading (progress per file), submitting (button spinner), success → detail "Submitted"; <strong>409 → "Already submitted" info state</strong> (navigates to detail); 404 → removed</td></tr> <tr><td>Analytics</td><td><code>homework.submit.start</code>, <code>homework.submit.success</code>, <code>homework.submit.duplicate</code></td></tr> </tbody></table> </div> <h2 id="6-submissions-list-teacher-homeworkidsubmissions"><a class="header" href="#6-submissions-list-teacher-homeworkidsubmissions">6. Submissions List (teacher) <code>/homework/:id/submissions</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>All submissions of one homework for grading</td></tr> <tr><td>Source</td><td><code>GET /homework/:id/submissions</code> → array (non-paginated)</td></tr> <tr><td>Composition</td><td><code>AppSectionHeader</code> "N submitted"; rows: student name, <code>submittedAt</code>, status chip (<code>submitted</code>/<code>graded</code>), marks badge if graded, attachment icons, late badge (client-computed)</td></tr> <tr><td>Row action</td><td>tap → grading sheet (graded rows open read-only w/ regrade path)</td></tr> <tr><td>States</td><td>loading; empty (<code>AppEmptyState</code> "No submissions yet"); 404</td></tr> <tr><td>Analytics</td><td><code>homework.submissions.open</code>, <code>homework.submissions.grade_open</code></td></tr> </tbody></table> </div> <h2 id="7-grading-sheet-homeworkidsubmissionssubmissionid"><a class="header" href="#7-grading-sheet-homeworkidsubmissionssubmissionid">7. Grading Sheet <code>/homework/:id/submissions/:submissionId</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Grade one submission: marks + remarks</td></tr> <tr><td>Source</td><td><code>PATCH /homework/:id/submissions/:submissionId/grade</code> (<code>GradeSubmissionDto</code> — <code>submission.dto.ts:19-26</code>)</td></tr> <tr><td>Composition</td><td>student header, submittedAt, attachment previews, marks <code>AppTextField</code> (numeric, client-validated ≥ 0 — OQ-4), remarks field, "Grade" button; regrade mode shows existing grade + overwrite warning</td></tr> <tr><td>States</td><td>grading (button spinner); 404 "Submission not found."; 400 (client validation); success → row updated + snackbar</td></tr> <tr><td>Analytics</td><td><code>homework.grade.submit</code>, <code>homework.grade.regrade</code></td></tr> </tbody></table> </div> <h2 id="8-grade-feedback-view-student--within-homeworkid"><a class="header" href="#8-grade-feedback-view-student--within-homeworkid">8. Grade Feedback View (student) — within <code>/homework/:id</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Show marks + teacher remarks to the student</td></tr> <tr><td>Source</td><td>from <code>GET /homework/:id/submissions</code> (own row) or the submit response</td></tr> <tr><td>Composition</td><td>success-mark card (<code>marks</code>, tabular figures), <code>AppMarkdownViewer</code> remarks, <code>gradedAt</code>/<code>submittedAt</code>, attachments</td></tr> <tr><td>Analytics</td><td><code>homework.feedback.view</code></td></tr> </tbody></table> </div> <h2 id="9-attachment-preview--download-shared-surface"><a class="header" href="#9-attachment-preview--download-shared-surface">9. Attachment Preview / Download (shared surface)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Preview or download homework/submission attachments</td></tr> <tr><td>Source</td><td><code>GET /files/:id/download</code> → stream with <code>Content-Disposition</code> (<code>files.controller.ts:55-64</code>); requires <code>file.read</code></td></tr> <tr><td>Behavior</td><td>PDF/image → in-app viewer; other types → share sheet/external app; long-press menu (Save, Share)</td></tr> <tr><td>States</td><td>downloading (progress), error (404 file removed)</td></tr> </tbody></table> </div> <hr /> <h2 id="shared-components-used-11"><a class="header" href="#shared-components-used-11">Shared components used</a></h2> <p><code>AppListTile</code>, <code>AppCard</code>, <code>AppButton</code>, <code>AppTextField</code>, <code>AppDropdown</code>, <code>AppDatePicker</code>, <code>AppMarkdownViewer</code>, <code>AppAttachmentTile</code>, <code>AppBadge</code>, <code>AppChips</code>, <code>AppMenu</code>, <code>AppDialog</code>, <code>AppBottomSheet</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppErrorState</code>, <code>AppOfflineBanner</code>, <code>AppFAB</code>, <code>AppSnackbar</code>, <code>AppSectionHeader</code>, <code>AppInfoRow</code>. Module-specific: <code>HomeworkCard</code>, <code>SubmissionRow</code>, <code>LateBadge</code>, <code>AttachmentUploader</code>, <code>GradeSheetBody</code>, <code>SubmissionStatusChip</code> — defined in <a href="homework/07_Component_Library.html">07_Component_Library.md</a>.</p> <h2 id="analytics-events-proposed-00-shared10-8"><a class="header" href="#analytics-events-proposed-00-shared10-8">Analytics events (proposed, <a href="homework/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a>)</a></h2> <p><code>homework.list.{open,filter_class,search}</code>, <code>homework.create.{submit,success,failed}</code>, <code>homework.detail.open</code>, <code>homework.edit.{submit,success}</code>, <code>homework.delete.{confirm,success}</code>, <code>homework.submit.{start,success,duplicate,failed}</code>, <code>homework.grade.{open,submit,regrade}</code>, <code>homework.feedback.view</code>, <code>homework.attachment.{preview,download,failed}</code>.</p> <h2 id="keyboard-landscape-tablet-desktop-11"><a class="header" href="#keyboard-landscape-tablet-desktop-11">Keyboard, landscape, tablet, desktop</a></h2> <ul> <li>Forms: keyboard-avoidance + scroll; <code>Enter</code> submits single-field steps.</li> <li>Grade sheet on desktop: full-width split — submissions list left, grade form right.</li> <li>List: 2-column at ≥ 600 dp; 3-column ≥ 1200 dp.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-homework-module"><a class="header" href="#06--screen-specifications-homework-module">06 — Screen Specifications (Homework Module)</a></h1> <ul> <li><a href="homework/06_Screen_Specifications.html#1-homework-list-homework">1. Homework List <code>/homework</code></a></li> <li><a href="homework/06_Screen_Specifications.html#2-homework-detail-homeworkid">2. Homework Detail <code>/homework/:id</code></a></li> <li><a href="homework/06_Screen_Specifications.html#3-create-homework-form-homeworknew">3. Create Homework Form <code>/homework/new</code></a></li> <li><a href="homework/06_Screen_Specifications.html#4-submission-form-homeworkidsubmit-sheet-or-page">4. Submission Form <code>/homework/:id/submit</code> (sheet or page)</a></li> <li><a href="homework/06_Screen_Specifications.html#5-submissions-list-teacher-homeworkidsubmissions">5. Submissions List (teacher) <code>/homework/:id/submissions</code></a></li> <li><a href="homework/06_Screen_Specifications.html#6-grading-sheet-homeworkidsubmissionssubmissionid">6. Grading Sheet <code>/homework/:id/submissions/:submissionId</code></a></li> <li><a href="homework/06_Screen_Specifications.html#7-grade-feedback-view-student--detail-block-spec-in-2">7. Grade Feedback View (student) — detail block (spec in §2)</a></li> <li><a href="homework/06_Screen_Specifications.html#8-attachment-preview--share">8. Attachment Preview / Share</a></li> <li><a href="homework/06_Screen_Specifications.html#9-shared-micro-interaction--motion-notes-all-screens">9. Shared micro-interaction & motion notes (all screens)</a></li> </ul> <blockquote> <p>Production-grade specs for each Homework screen: chrome, states, components, micro-interactions, a11y, responsive, dark/light, keyboard, motion. Tokens: <code>Sp</code> = <a href="homework/../00-shared/02_Design_Tokens.html">00-shared/02 §3</a>, <code>Ty</code> = typography §2, <code>El</code> = elevation §5, <code>R</code> = radius §4, <code>Mo</code> = motion §6. All components reference <a href="homework/../00-shared/03_Component_Library.html">00-shared/03</a> and <a href="homework/07_Component_Library.html">07</a> (module).</p> </blockquote> <hr /> <h2 id="1-homework-list-homework"><a class="header" href="#1-homework-list-homework">1. Homework List <code>/homework</code></a></h2> <p><strong>Chrome:</strong> <code>AppBar</code> "Homework" (<code>titleLarge</code>, <code>El-e1</code>); class filter <code>AppDropdown</code> under AppBar; body = <code>RefreshIndicator</code> + grouped <code>ListView.builder</code> (group "Due soon" / "Past due" computed client-side vs <code>dueDate</code>); horizontal padding <code>Sp.16</code> (phone), <code>Sp.24</code> (tablet+). Teacher FAB <code>AppFAB.extended</code> "New homework" (<code>Icons.add</code>, <code>heroTag</code> unique).</p> <p><strong>Row (HomeworkCard, teacher & student):</strong></p> <pre><code>[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 </code></pre> <ul> <li>Meta from doc: <code>title</code>, <code>subjectId</code> (resolved), <code>classId</code> (resolved), <code>dueDate</code>, <code>status</code> (<code>homework.schema.ts:18-34</code>), <code>assignedDate</code>, <code>attachments.length</code>.</li> <li><strong>Late semantics (OQ-1):</strong> server never flags late. Client computes <code>isLate = dueDate.isBefore(now)</code> — card shows <code>tertiary</code> badge "Overdue · 2d" + <code>schedule</code> icon (color + icon + text, never color-only — <code>09 §9</code>). Once the server adds enforcement, render server truth only.</li> <li><strong>States:</strong> skeleton (<code>AppSkeleton(list)</code>); empty <code>AppEmptyState</code> "No homework yet" + FAB; error <code>AppErrorState</code> (404 "Class not found" if class invalid, 5xx + requestId); offline <code>AppOfflineBanner</code> + cached list (TTL 5 min, <code>06 §3.3</code>).</li> <li><strong>Pull-to-refresh:</strong> re-fetch + WS resubscribe <code>(planned)</code>.</li> <li><strong>Row menu (teacher):</strong> Edit, Delete; delete → <code>AppDialog</code> confirm ("Delete this homework? Submissions are kept on the server." — soft delete, no cascade, <code>homework.service.ts:70-81</code>) → <code>DELETE /homework/:id</code> server-confirmed, row fade-out <code>Mo.m-base</code>, snackbar "Deleted".</li> <li><strong>A11y:</strong> card = one <code>Semantics(button)</code> label <code>"{title}, {subject}, due {date}"</code>; status badges appended; focus order filter → list → FAB.</li> <li><strong>Dark/light:</strong> token-only; overdue badge <code>tertiaryContainer</code> (light) / <code>tertiary</code>-tinted container (dark) with icon+label.</li> <li><strong>Tablet/desktop:</strong> master-detail at ≥ 840 dp (list left 360–400 dp, detail right); hover tint on cards; <code>N</code> shortcut = new homework.</li> </ul> <h2 id="2-homework-detail-homeworkid"><a class="header" href="#2-homework-detail-homeworkid">2. Homework Detail <code>/homework/:id</code></a></h2> <p><strong>Chrome:</strong> AppBar title "Homework" + trailing <code>AppMenu</code> (teacher: Edit / Delete); scrollable <code>SingleChildScrollView</code>, sections spaced <code>Sp.24</code>.</p> <pre><code>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 </code></pre> <p><strong>States:</strong> loading skeleton; 404 → <code>AppEmptyState</code> "This homework was removed or is not available." (matches server <code>NotFoundException</code>, <code>homework.service.ts:47</code>); offline → banner + cached detail (<code>00-shared/06 §3.3</code> — detail views cached last-good).</p> <p><strong>Grade feedback block (student, when my submission status='graded'):</strong></p> <pre><code>Card (secondaryContainer tint) "Grade" marks displaySmall mono tabular · badge "Graded" remarks → AppMarkdownViewer gradedAt bodySmall ("Graded 2d ago") </code></pre> <p><code>marks</code> is <code>Number?</code> (<code>homework-submission.schema.ts:30-31</code>) — absent until graded; render "Pending grade" <code>AppBadge</code> when <code>status='submitted'</code> (schema enum <code>['submitted','graded']</code>, <code>homework-submission.schema.ts:24</code>).</p> <p><strong>Deep link</strong> <code>studylyon://homework/:id</code> → this screen; notification tap → same.</p> <h2 id="3-create-homework-form-homeworknew"><a class="header" href="#3-create-homework-form-homeworknew">3. Create Homework Form <code>/homework/new</code></a></h2> <p><strong>Chrome:</strong> full-screen page (<code>> 3 fields</code> rule, <code>00-shared/05 §5</code>); AppBar "New homework"</p> <ul> <li><code>Save</code> TextButton; body form list <code>Sp.16</code> gaps, <code>resizeToAvoidBottomInset: true</code>.</li> </ul> <pre><code>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) </code></pre> <p><strong>Submit flow:</strong> button "Assign homework" → loading spinner; server 201 doc returned (<code>homework.service.ts:27-43</code>); success → detail + snackbar "Homework assigned". <strong>Validation errors</strong> (400 <code>VALIDATION_ERROR</code>): field-level inline, focus first invalid (<code>submission.dto</code>/<code>homework.dto</code> decorators: <code>IsMongoId</code>, <code>IsString</code>, <code>IsDateString</code>, <code>IsOptional</code> — <code>homework.dto.ts:4-33</code>). <strong>Attachment UX:</strong> each file uploads immediately (id → chip); failures retryable per file; <strong>homework is only created after all attachments uploaded</strong> (separate requests — create must not fire with pending uploads).</p> <h2 id="4-submission-form-homeworkidsubmit-sheet-or-page"><a class="header" href="#4-submission-form-homeworkidsubmit-sheet-or-page">4. Submission Form <code>/homework/:id/submit</code> (sheet or page)</a></h2> <p><strong>Chrome:</strong> page (or full-height sheet on phone) with AppBar "Submit homework".</p> <pre><code>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" </code></pre> <p><strong>States:</strong></p> <ul> <li><code>submitting</code> → button spinner, inputs disabled, anti-double-submit (<code>00-shared/08 §6</code>).</li> <li>Success 201 → replace with success view (check icon <code>success</code>, "Submitted on {submittedAt}") → button "View homework" → detail. <code>submittedAt</code> comes from the server doc (<code>homework.service.ts:96</code>).</li> <li><strong>409 <code>DUPLICATE_RESOURCE</code> "Already submitted."</strong> (<code>homework.service.ts:92</code>) → NOT an error: navigate to detail with "Already submitted" <code>AppBanner(info)</code> — the student may have submitted from another device.</li> <li>404 → detail empty state ("removed").</li> <li>400 → field errors. 5xx → <code>AppErrorState</code> + form state preserved.</li> </ul> <p><strong>Late-submit moment (OQ-1):</strong> when <code>now > dueDate</code>, show inline <code>AppBanner(warning)</code> "Due date has passed — your teacher may accept it" <strong>but do not block</strong> (server accepts; blocking client-side would contradict the server contract).</p> <p><strong>Double-submit safety:</strong> submit is <strong>not optimistic</strong> (<code>00-shared/07 §9</code> write-once op); the button locks after first tap; the 409 handler covers retried requests (Idempotency-Key header supported pattern — <code>00-shared/07 §9</code>).</p> <h2 id="5-submissions-list-teacher-homeworkidsubmissions"><a class="header" href="#5-submissions-list-teacher-homeworkidsubmissions">5. Submissions List (teacher) <code>/homework/:id/submissions</code></a></h2> <p><strong>Chrome:</strong> AppBar "Submissions · {title}"; summary line <code>bodySmall</code> "N submitted · M graded"; <code>RefreshIndicator</code> + <code>ListView.builder</code>.</p> <p><strong>Row (SubmissionRow):</strong></p> <pre><code>[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] </code></pre> <ul> <li><strong>Late badge:</strong> client-computed <code>submittedAt > dueDate</code> → <code>AppBadge(tertiary, icon schedule)</code> "Late · 2d" (<code>AppBadge</code> + icon + text — a11y <code>09 §9</code>). Semantics: "Submitted late by 2 days".</li> <li><strong>Grouping:</strong> "Ungraded" first (status='submitted'), then "Graded" (status='graded') — no server sort (<code>findByHomework</code> returns insertion order, <code>homework-submission.repository.ts:20-24</code>); client groups. Proposed: server sort by <code>submittedAt</code>.</li> <li><strong>States:</strong> empty <code>AppEmptyState</code> "No submissions yet" + "Share the homework link" (proposed); 404 → detail-removed empty state.</li> <li><strong>Row tap:</strong> ungraded → grading sheet; graded → read-only feedback w/ "Regrade" action.</li> </ul> <h2 id="6-grading-sheet-homeworkidsubmissionssubmissionid"><a class="header" href="#6-grading-sheet-homeworkidsubmissionssubmissionid">6. Grading Sheet <code>/homework/:id/submissions/:submissionId</code></a></h2> <p><strong>Chrome:</strong> page; AppBar "Grade · {student}"; body scrollable.</p> <pre><code>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" </code></pre> <p><strong>Regrade mode</strong> (existing <code>status='graded'</code>): show current marks + remarks; button "Save new grade" + <code>AppBanner(warning)</code> "This overwrites the previous grade" (server overwrites unconditionally and emits <code>HomeworkGraded</code> again — <code>homework.service.ts:127-143</code>, OQ-2).</p> <p><strong>States:</strong> grading (button spinner, no double-submit); success → row state updates via pop result + snackbar "Grade saved"; 404 "Submission not found." (<code>homework.service.ts:126</code>) → back + refresh; 400 → field errors.</p> <p><strong>Optimistic vs server-confirmed (see <code>13</code>):</strong> grading is <strong>server-confirmed</strong> — the updated doc (<code>marks</code>, <code>remarks</code>, <code>status:'graded'</code>, <code>gradedAt</code>) is written only on 200. No local paint-before-write (side effects: event → notifications).</p> <p><strong>Batch flow (proposed):</strong> after saving a grade, "Next ungraded" chip appears → advances to the next <code>status='submitted'</code> row (client-side; list already loaded).</p> <h2 id="7-grade-feedback-view-student--detail-block-spec-in-2"><a class="header" href="#7-grade-feedback-view-student--detail-block-spec-in-2">7. Grade Feedback View (student) — detail block (spec in §2)</a></h2> <p>Reuses <code>GradeFeedbackCard</code> component; read-only; marks in <code>mono</code> tabular figures.</p> <h2 id="8-attachment-preview--share"><a class="header" href="#8-attachment-preview--share">8. Attachment Preview / Share</a></h2> <ul> <li>PDF/image preview in app (<code>PageView</code> + zoom, <code>00-shared/08 §1</code> double-tap zoom); other types → share sheet / external viewer.</li> <li>Download: <code>GET /files/:id/download</code> → buffer → save/share; error 404 → snackbar "File no longer available".</li> <li>Upload (from create/submit forms): <code>AppAttachmentUploader</code> — per-file states <code>{idle, uploading(progress), uploaded(fileId), failed(retry), removed}</code>; retry = re-POST same file (new upload each attempt — no resume; <code>00-shared/12 B7</code>).</li> </ul> <h2 id="9-shared-micro-interaction--motion-notes-all-screens"><a class="header" href="#9-shared-micro-interaction--motion-notes-all-screens">9. Shared micro-interaction & motion notes (all screens)</a></h2> <ul> <li>Route transitions <code>Mo.m-base</code>; cards <code>El-e1</code>; FAB <code>Mo.m-entrance</code> scale, hide on scroll-down (<code>00-shared/03 D</code>).</li> <li>Snackbars: success < 3 s, error ≥ 4 s.</li> <li>Status chip change (<code>submitted</code>→<code>graded</code>) animates <code>AnimatedSwitcher</code> <code>Mo.m-fast</code>.</li> <li>Keyboard: numeric <code>TextInputType.number</code> for marks; multiline for remarks; <code>Ctrl+Enter</code> submits on desktop (<code>00-shared/08 §2</code>).</li> <li>Safe areas: bottom padding under FAB/lists; sheets respect <code>MediaQuery</code>.</li> <li>Reduced motion: fades + instant only, no shake/stagger.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-homework-module"><a class="header" href="#07--component-library-homework-module">07 — Component Library (Homework Module)</a></h1> <ul> <li><a href="homework/07_Component_Library.html#a-homework-representation">A. Homework representation</a> <ul> <li><a href="homework/07_Component_Library.html#homeworkcard">HomeworkCard</a></li> <li><a href="homework/07_Component_Library.html#submissionrow">SubmissionRow</a></li> <li><a href="homework/07_Component_Library.html#latebadge-appbadge-variant">LateBadge (<code>AppBadge</code> variant)</a></li> <li><a href="homework/07_Component_Library.html#submissionstatuschip">SubmissionStatusChip</a></li> <li><a href="homework/07_Component_Library.html#gradefeedbackcard">GradeFeedbackCard</a></li> </ul> </li> <li><a href="homework/07_Component_Library.html#b-attachments">B. Attachments</a> <ul> <li><a href="homework/07_Component_Library.html#appattachmentuploader">AppAttachmentUploader</a></li> <li><a href="homework/07_Component_Library.html#appattachmentlist">AppAttachmentList</a></li> </ul> </li> <li><a href="homework/07_Component_Library.html#c-grading-surface">C. Grading surface</a> <ul> <li><a href="homework/07_Component_Library.html#gradesheetbody">GradeSheetBody</a></li> </ul> </li> <li><a href="homework/07_Component_Library.html#d-status--feedback">D. Status & feedback</a></li> <li><a href="homework/07_Component_Library.html#e-only-elements-the-api-supports">E. Only elements the API supports</a></li> <li><a href="homework/07_Component_Library.html#f-flutter-widget-map-module">F. Flutter widget map (module)</a></li> <li><a href="homework/07_Component_Library.html#g-golden-tests-base-00-shared03-g">G. Golden tests (base 00-shared/03 G)</a></li> </ul> <blockquote> <p>Module-specific components built from shared <code>App*</code> primitives (<a href="homework/../00-shared/03_Component_Library.html">00-shared/03</a>). Props, states, animation, a11y, tokens, Flutter mapping. Reuse over new — only components that don't exist in the shared library are defined here.</p> </blockquote> <hr /> <h2 id="a-homework-representation"><a class="header" href="#a-homework-representation">A. Homework representation</a></h2> <h3 id="homeworkcard"><a class="header" href="#homeworkcard">HomeworkCard</a></h3> <ul> <li>Purpose: one homework row in the list (teacher + student variants).</li> <li>Props: <code>title</code>, <code>subjectLabel</code>, <code>classLabel</code>, <code>dueDate</code>, <code>assignedDate</code>, <code>status</code> (<code>active</code>/<code>closed</code>, <code>homework.schema.ts:33-34</code>), <code>attachmentCount</code>, <code>myState?</code> (<code>none|submitted|graded|overdue</code>), <code>overdue</code>, <code>onTap</code>, <code>menu?</code> (teacher: edit/delete).</li> <li>Rendering: <code>AppCard</code> flat (<code>e-0</code>) + <code>ListTile</code>-like layout; subject <code>AppChip</code> (readonly); status <code>AppBadge</code> (active → <code>secondaryContainer</code>, closed → <code>outline</code>); my-state <code>SubmissionStatusChip</code>; due line <code>bodyMedium</code> (<code>onSurfaceVariant</code>), overdue → <code>tertiary</code> + <code>schedule</code> icon; attachment count icon <code>attachment</code>.</li> <li>States: default / selected (master-detail) / disabled (deleted in flight).</li> <li>A11y: single <code>Semantics(button)</code> — "Algebra Worksheet, Mathematics, due 15 Aug, not submitted"; menu separately labeled.</li> <li>Tokens: <code>surfaceContainerLow</code>, <code>R-md</code>, <code>El-e0/e1</code>.</li> <li>Flutter: <code>Card</code> + <code>InkWell</code> + <code>Row</code>/<code>Column</code>; date via <code>DateTime.toDisplayDate(locale)</code> (<a href="homework/../00-shared/11_Flutter_App_Architecture.html">00-shared/11 §8</a>).</li> </ul> <h3 id="submissionrow"><a class="header" href="#submissionrow">SubmissionRow</a></h3> <ul> <li>Purpose: one submission row in the teacher's submission list.</li> <li>Props: <code>studentName</code>, <code>submittedAt</code>, <code>status</code> (<code>submitted</code>/<code>graded</code>), <code>marks?</code>, <code>remarks?</code>, <code>attachmentCount</code>, <code>isLate</code> (client-computed), <code>onTap</code>, <code>onGrade?</code>.</li> <li>Rendering: <code>AppAvatar</code> (initials) leading; title = student name; subtitle = "Submitted {relative} · {attachmentCount} attachments"; trailing = <code>AppBadge</code> status (submitted → <code>tertiaryContainer</code> "Pending"; graded → <code>secondaryContainer</code> "Graded") + marks <code>AppBadge(mono)</code>; late → <code>LateBadge</code>.</li> <li>A11y: label "Student name, submitted 3 days ago, pending, late by 2 days".</li> <li>Flutter: <code>ListTile</code> in <code>AppCard</code> <code>e-0</code>.</li> </ul> <h3 id="latebadge-appbadge-variant"><a class="header" href="#latebadge-appbadge-variant">LateBadge (<code>AppBadge</code> variant)</a></h3> <ul> <li>Purpose: <code>submittedAt > dueDate</code> indicator (OQ-1 — server has no flag; client-derived).</li> <li>Props: <code>lateBy</code> (Duration), <code>compact?</code>.</li> <li>Rendering: <code>tertiary</code> text + <code>schedule</code> icon 16, chip <code>tertiaryContainer</code>; text "Late · 2d".</li> <li>A11y: always icon + text (never color-only, <a href="homework/09_User_Behaviour.html">09 §9</a>); semantics "submitted late by 2 days".</li> <li>Flutter: <code>Badge</code>/custom <code>Container</code> + <code>Icon</code>.</li> </ul> <h3 id="submissionstatuschip"><a class="header" href="#submissionstatuschip">SubmissionStatusChip</a></h3> <ul> <li>Purpose: student-facing submission state on homework cards/detail.</li> <li>Props: <code>state</code> (<code>none|submitted|graded|overdue</code>), <code>marks?</code>.</li> <li>Mapping: none → <code>AppBadge(outline)</code> "Not submitted"; submitted → <code>AppBadge(tertiaryContainer)</code> "Submitted"; graded → <code>AppBadge(secondaryContainer)</code> "Graded · 17/20"; overdue → <code>AppBadge(tertiary)</code> "Overdue".</li> <li>Flutter: <code>Chip</code> with <code>avatar</code> icon.</li> </ul> <h3 id="gradefeedbackcard"><a class="header" href="#gradefeedbackcard">GradeFeedbackCard</a></h3> <ul> <li>Purpose: student sees marks + remarks after grading.</li> <li>Props: <code>marks?</code>, <code>remarks?</code>, <code>gradedAt?</code>, <code>submittedAt</code>, <code>late</code>.</li> <li>Rendering: <code>AppCard</code> tonal (<code>secondaryContainer</code>); marks <code>displaySmall</code> <code>mono</code> tabular figures + "/{max}" config; remarks <code>AppMarkdownViewer</code>; footer "Graded {relative} · Submitted {relative}".</li> <li>A11y: label "Grade 17 out of 20. Feedback: {remarks}".</li> <li>Flutter: <code>Card.filled</code> + <code>Row</code>.</li> </ul> <h2 id="b-attachments"><a class="header" href="#b-attachments">B. Attachments</a></h2> <h3 id="appattachmentuploader"><a class="header" href="#appattachmentuploader">AppAttachmentUploader</a></h3> <ul> <li>Purpose: multi-file upload inside create/submit forms; drives <code>POST /files/upload</code> (<code>files.controller.ts:29-41</code>).</li> <li>Props: <code>files[]</code>, <code>onAdd</code>, <code>onRetry</code>, <code>onRemove</code>, <code>maxCount?</code> (client default 5), <code>maxSizeMB?</code> (client default 25 — <strong>no server limit exists</strong>, OQ-4/01; enforce client-side), <code>acceptTypes?</code> (client hint; server accepts anything).</li> <li>Per-file state machine: <code>idle → uploading(progress 0..1) → uploaded(fileId) | failed(reason)</code>; removal allowed in idle/uploaded/failed.</li> <li>Rendering: <code>AppAttachmentTile</code> (name, size, type, uploading %, retry, remove) + "Add attachment" <code>OutlinedButton</code>.</li> <li>A11y: progress announced (<code>liveRegion</code> "Uploading {name}, 45%"); remove labeled.</li> <li>Flutter: <code>ListTile</code> rows + <code>LinearProgressIndicator</code> per file; <code>file_picker</code>-style picker (verify license; wrap for swappability).</li> </ul> <h3 id="appattachmentlist"><a class="header" href="#appattachmentlist">AppAttachmentList</a></h3> <ul> <li>Purpose: read-only attachment display (homework detail, submission detail).</li> <li>Props: <code>items[{id, name, size, mimeType}]</code>, <code>onPreview</code>, <code>onDownload</code>.</li> <li>Behavior: tap → preview (PDF/image in-app) or share sheet; long-press menu (Save, Share).</li> <li>Source: file metadata comes from the <code>files</code> collection (<code>file.schema.ts:8-43</code>), fetched by id; download via <code>GET /files/:id/download</code> (<code>files.controller.ts:55-64</code>).</li> <li>Flutter: <code>AppAttachmentTile</code> list.</li> </ul> <h2 id="c-grading-surface"><a class="header" href="#c-grading-surface">C. Grading surface</a></h2> <h3 id="gradesheetbody"><a class="header" href="#gradesheetbody">GradeSheetBody</a></h3> <ul> <li>Purpose: the marks+remarks form body of the grading sheet (reused for regrade).</li> <li>Props: <code>initialMarks?</code>, <code>initialRemarks?</code>, <code>maxMarks</code> (client default 100), <code>regrade</code> (bool), <code>onSubmit(marks, remarks)</code>, <code>submitting</code>.</li> <li>Validation: required, numeric, <code>0 ≤ marks ≤ maxMarks</code> (server has no bounds — <code>submission.dto.ts:20-21</code>, OQ-4); remarks ≤ 2000 chars (client).</li> <li>Rendering: <code>AppTextField</code> numeric + <code>AppTextField</code> multiline + regrade <code>AppBanner</code>.</li> <li>Flutter: <code>Form</code> + <code>TextFormField</code> validators.</li> </ul> <h2 id="d-status--feedback-1"><a class="header" href="#d-status--feedback-1">D. Status & feedback</a></h2> <ul> <li>Reuses <code>AppBanner</code> (info: "Already submitted"; warning: late, regrade), <code>AppSnackbar</code>, <code>AppErrorState</code>, <code>AppEmptyState</code>, <code>AppOfflineBanner</code>, <code>AppSkeleton</code> from <a href="homework/../00-shared/03_Component_Library.html">00-shared/03</a> with no module changes.</li> </ul> <h2 id="e-only-elements-the-api-supports-2"><a class="header" href="#e-only-elements-the-api-supports-2">E. Only elements the API supports</a></h2> <ul> <li>Homework/submission lists are <strong>non-paginated arrays</strong> (<code>findByClass</code>, <code>findByHomework</code> return full arrays — <code>homework.repository.ts:17-21</code>, <code>homework-submission.repository.ts:20-24</code>) → no infinite scroll; render all. Pagination <code>(planned)</code> when endpoints grow <code>page/limit</code>.</li> <li>No "my submissions" bulk endpoint (OQ-6) → detail screen fetches <code>GET /homework/:id/submissions</code> for the single homework and filters by <code>studentId</code>.</li> </ul> <h2 id="f-flutter-widget-map-module-2"><a class="header" href="#f-flutter-widget-map-module-2">F. Flutter widget map (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Component</th><th>Flutter widgets</th></tr></thead><tbody> <tr><td>HomeworkCard</td><td><code>Card</code> + <code>InkWell</code> + <code>Chip</code>s + <code>Badge</code></td></tr> <tr><td>SubmissionRow</td><td><code>ListTile</code> + <code>Badge</code> + <code>MenuAnchor</code></td></tr> <tr><td>LateBadge</td><td><code>Container</code> + <code>Icon</code> + <code>Text</code></td></tr> <tr><td>SubmissionStatusChip</td><td><code>Chip</code></td></tr> <tr><td>GradeFeedbackCard</td><td><code>Card.filled</code> + <code>AppMarkdownViewer</code></td></tr> <tr><td>AppAttachmentUploader</td><td><code>Row</code> of <code>ListTile</code> + <code>LinearProgressIndicator</code></td></tr> <tr><td>AppAttachmentList</td><td><code>ListView</code> of <code>AppAttachmentTile</code></td></tr> <tr><td>GradeSheetBody</td><td><code>Form</code> + <code>TextFormField</code></td></tr> </tbody></table> </div> <h2 id="g-golden-tests-base-00-shared03-g-1"><a class="header" href="#g-golden-tests-base-00-shared03-g-1">G. Golden tests (base <a href="homework/../00-shared/03_Component_Library.html">00-shared/03 G</a>)</a></h2> <p>Every module component golden at phone/tablet/desktop + dark; required state goldens: <code>HomeworkCard</code> (submitted/graded/overdue/closed), <code>LateBadge</code>, <code>AppAttachmentUploader</code> (uploading/error/complete), <code>GradeSheetBody</code> (empty/invalid/regrade).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-homework-module"><a class="header" href="#08--form-specifications-homework-module">08 — Form Specifications (Homework Module)</a></h1> <ul> <li><a href="homework/08_Form_Specifications.html#1-create-homework-form--post-apiv1homework-homeworkdtots4-33">1. Create Homework Form — <code>POST /api/v1/homework</code> (<code>homework.dto.ts:4-33</code>)</a></li> <li><a href="homework/08_Form_Specifications.html#2-edit-homework-form--patch-apiv1homeworkid-homeworkdtots35-58">2. Edit Homework Form — <code>PATCH /api/v1/homework/:id</code> (<code>homework.dto.ts:35-58</code>)</a></li> <li><a href="homework/08_Form_Specifications.html#3-submission-form--post-apiv1homeworkidsubmit-submissiondtots4-17">3. Submission Form — <code>POST /api/v1/homework/:id/submit</code> (<code>submission.dto.ts:4-17</code>)</a></li> <li><a href="homework/08_Form_Specifications.html#4-grade-form--patch-apiv1homeworkidsubmissionssubmissionidgrade-submissiondtots19-26">4. Grade Form — <code>PATCH /api/v1/homework/:id/submissions/:submissionId/grade</code> (<code>submission.dto.ts:19-26</code>)</a></li> <li><a href="homework/08_Form_Specifications.html#form-level-rules-all">Form-level rules (all)</a></li> <li><a href="homework/08_Form_Specifications.html#client-side-error-priority-all-forms">Client-side error priority (all forms)</a></li> </ul> <blockquote> <p>Every form field-by-field. Validation mirrors class-validator decorators <strong>exactly</strong> from <code>src/modules/homework/dto/*.ts</code>; messages follow the <code>VALIDATION_ERROR</code> (400) envelope with per-field <code>details</code> (<code>http-exception.filter.ts:27-35,103-108</code>). Client validates inline then submits; server 400 shadows client.</p> </blockquote> <hr /> <h2 id="1-create-homework-form--post-apiv1homework-homeworkdtots4-33"><a class="header" href="#1-create-homework-form--post-apiv1homework-homeworkdtots4-33">1. Create Homework Form — <code>POST /api/v1/homework</code> (<code>homework.dto.ts:4-33</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Label</th><th>Keyboard</th><th>Validation (server)</th><th>Client UX</th></tr></thead><tbody> <tr><td>1</td><td><code>classId</code></td><td>Class</td><td>—</td><td><code>@IsMongoId()</code> required</td><td><code>AppDropdown</code> from Academics; required; immutable after create</td></tr> <tr><td>2</td><td><code>subjectId</code></td><td>Subject</td><td>—</td><td><code>@IsMongoId()</code> required</td><td><code>AppDropdown</code> (filtered by class where known); immutable after create</td></tr> <tr><td>3</td><td><code>teacherId</code></td><td>Teacher</td><td>—</td><td><code>@IsMongoId()</code> required</td><td><strong>hidden</strong> — filled from profile (server trusts body; OQ-9 note in <a href="homework/01_Product_Overview.html">01</a>)</td></tr> <tr><td>4</td><td><code>title</code></td><td>Title</td><td>text</td><td><code>@IsString()</code> required</td><td>maxLength 200 (client), autofocus, <code>TextInputAction.next</code></td></tr> <tr><td>5</td><td><code>description</code></td><td>Description</td><td>multiline</td><td><code>@IsOptional() @IsString()</code></td><td>4–6 line field; "Markdown supported" helper (rendered via <code>AppMarkdownViewer</code>)</td></tr> <tr><td>6</td><td><code>dueDate</code></td><td>Due date</td><td>—</td><td><code>@IsDateString()</code> required</td><td><code>AppDatePicker</code>; client min = today; format locale-aware; default time 23:59 local</td></tr> <tr><td>7</td><td><code>attachments</code></td><td>Attachments</td><td>—</td><td><code>@IsOptional()</code> (string[])</td><td><code>AppAttachmentUploader</code> (uploads happen before submit; ids sent)</td></tr> </tbody></table> </div> <p><strong>Server 400 mapping:</strong> class-validator errors arrive as <code>error: { code: 'VALIDATION_ERROR', details: [{ message }] }</code> — map to fields by trailing field name heuristics where <code>details[].message</code> lacks field ids (<code>http-exception.filter.ts:103-108</code>); always also show the banner message.</p> <p><strong>Submit:</strong> button "Assign homework" → <code>loading</code> → 201 → detail + snackbar. Errors: 5xx → <code>AppErrorState</code> keeping the form; offline → blocked, banner.</p> <h2 id="2-edit-homework-form--patch-apiv1homeworkid-homeworkdtots35-58"><a class="header" href="#2-edit-homework-form--patch-apiv1homeworkid-homeworkdtots35-58">2. Edit Homework Form — <code>PATCH /api/v1/homework/:id</code> (<code>homework.dto.ts:35-58</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Required</th><th>Server validation</th><th>Client UX</th></tr></thead><tbody> <tr><td>1</td><td><code>title</code></td><td>no</td><td><code>@IsOptional() @IsString()</code></td><td>prefilled; empty string sent → validation ok, doc updated</td></tr> <tr><td>2</td><td><code>description</code></td><td>no</td><td><code>@IsOptional() @IsString()</code></td><td>prefilled</td></tr> <tr><td>3</td><td><code>attachments</code></td><td>no</td><td><code>@IsOptional()</code></td><td>list diff; add/remove before PATCH (full replace — server <code>$set</code>s whole array, <code>homework.service.ts:57</code>)</td></tr> <tr><td>4</td><td><code>dueDate</code></td><td>no</td><td><code>@IsOptional() @IsDateString()</code></td><td>date picker; <strong>client min = today</strong> (moving a due date to the past is possible server-side; flag with helper text)</td></tr> <tr><td>5</td><td><code>status</code></td><td>no</td><td><code>@IsOptional() @IsString()</code></td><td><code>AppDropdown</code> <code>active</code>/<code>closed</code> (schema enum <code>homework.schema.ts:33</code>; invalid string → 400 enum cast error → treat as validation)</td></tr> </tbody></table> </div> <ul> <li><strong>Immutable:</strong> <code>classId</code>, <code>subjectId</code>, <code>teacherId</code> are not in the DTO — fields disabled in the edit form with note "Class/subject cannot be changed after assignment".</li> <li><strong>Submit</strong> sends only changed fields (<code>changes[]</code> echoed in <code>HomeworkUpdated</code> event payload, <code>homework.service.ts:65</code>); success → detail refresh.</li> <li>404 → empty state; 400 → field errors.</li> </ul> <h2 id="3-submission-form--post-apiv1homeworkidsubmit-submissiondtots4-17"><a class="header" href="#3-submission-form--post-apiv1homeworkidsubmit-submissiondtots4-17">3. Submission Form — <code>POST /api/v1/homework/:id/submit</code> (<code>submission.dto.ts:4-17</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Label</th><th>Keyboard</th><th>Validation (server)</th><th>Client UX</th></tr></thead><tbody> <tr><td>1</td><td><code>studentId</code></td><td>Student</td><td>—</td><td><code>@IsMongoId()</code> required</td><td>hidden — from profile; the <em>only</em> identity the server trusts (no JWT-sub binding, OQ-9/01)</td></tr> <tr><td>2</td><td><code>remarks</code></td><td>Note to teacher</td><td>multiline</td><td><code>@IsOptional() @IsString()</code></td><td>optional; placeholder "Add a note (optional)"; maxLength 2000 (client)</td></tr> <tr><td>3</td><td><code>attachments</code></td><td>Attachments</td><td>—</td><td><code>@IsOptional()</code> (string[])</td><td><code>AppAttachmentUploader</code>; at least one of remarks/attachments not required by server — <strong>allow empty submission</strong> (server accepts; client keeps a confirm)</td></tr> </tbody></table> </div> <p><strong>Flow:</strong> submit → button spinner → 201 submission doc → success view → detail. <strong>Errors:</strong></p> <ul> <li>409 <code>DUPLICATE_RESOURCE</code> "Already submitted." (<code>homework.service.ts:92</code>) → navigate to detail + <code>AppBanner(info)</code> (not an error state).</li> <li>404 → homework removed.</li> <li>400 → field errors; 5xx → keep form.</li> <li><strong>Late due date:</strong> inline <code>AppBanner(warning)</code> "The due date has passed" — submission still allowed (server accepts; OQ-1).</li> </ul> <h2 id="4-grade-form--patch-apiv1homeworkidsubmissionssubmissionidgrade-submissiondtots19-26"><a class="header" href="#4-grade-form--patch-apiv1homeworkidsubmissionssubmissionidgrade-submissiondtots19-26">4. Grade Form — <code>PATCH /api/v1/homework/:id/submissions/:submissionId/grade</code> (<code>submission.dto.ts:19-26</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Label</th><th>Keyboard</th><th>Validation (server)</th><th>Client</th></tr></thead><tbody> <tr><td>1</td><td><code>marks</code></td><td>Marks</td><td>number</td><td><strong>none</strong> — no decorator (<code>submission.dto.ts:20-21</code>)</td><td>required, numeric, <code>0 ≤ marks ≤ maxMarks</code> (client default 100, org-configurable); hint "Out of {max}"</td></tr> <tr><td>2</td><td><code>remarks</code></td><td>Feedback</td><td>multiline</td><td><code>@IsOptional() @IsString()</code></td><td>optional; maxLength 2000 (client)</td></tr> </tbody></table> </div> <ul> <li><strong>Server will accept negative/absurd marks (OQ-4)</strong> — client is the only guard today; document server-side limit addition as pending.</li> <li><strong>Regrade:</strong> if <code>status='graded'</code> already, show existing values + <code>AppBanner(warning)</code> "This overwrites the previous grade" + button "Save new grade" (server overwrites and re-emits <code>HomeworkGraded</code>, <code>homework.service.ts:127-143</code>).</li> <li>Success → snackbar "Grade saved" + row updates from server doc (<code>marks, remarks, status:'graded', gradedAt</code>).</li> <li>Errors: 404 "Submission not found." (<code>homework.service.ts:126</code>) → back+refresh; 400 → field errors; 5xx → keep typed values + retry.</li> </ul> <hr /> <h2 id="form-level-rules-all-4"><a class="header" href="#form-level-rules-all-4">Form-level rules (all)</a></h2> <ul> <li><strong>Double-submit:</strong> disabled while pending (<a href="homework/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §6</a>).</li> <li><strong>Optimistic:</strong> <strong>no optimistic writes</strong> for create/submit/grade — each is server-confirmed; the submission 409 handler is the only "local success" path (<a href="homework/../00-shared/07_API_Conventions.html">00-shared/07 §9</a>).</li> <li><strong>Undo:</strong> delete homework → snackbar "Deleted" with <strong>no UNDO</strong> (soft-delete is irreversible from the client; there is no restore endpoint) — confirm dialog instead (<a href="homework/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §5</a>). Attachment removal inside a form = UNDO via snackbar (4 s).</li> <li><strong>Abandonment:</strong> create/edit forms preserve drafts locally (<code>(proposed)</code> <code>shared_preferences</code>); back from create → "Discard draft?" dialog.</li> <li><strong>Keyboard:</strong> <code>.next</code> sequence, last <code>.done</code>; <code>Ctrl/Cmd+Enter</code> submits on desktop.</li> <li><strong>Error copy:</strong> from <code>message</code> of envelope for business 4xx only; codes for the rest (<a href="homework/../00-shared/07_API_Conventions.html">00-shared/07 §11</a>).</li> </ul> <h2 id="client-side-error-priority-all-forms"><a class="header" href="#client-side-error-priority-all-forms">Client-side error priority (all forms)</a></h2> <ol> <li>400 VALIDATION → field.</li> <li>404 RESOURCE_NOT_FOUND → empty state / refresh.</li> <li>409 DUPLICATE_RESOURCE → info state (submission) / inline (create race).</li> <li>429 RATE_LIMITED → countdown (uploads count toward <code>api</code> 100/min tier, <a href="homework/../00-shared/07_API_Conventions.html">00-shared/07 §4</a>).</li> <li>5xx → AppErrorState + requestId.</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-homework-module"><a class="header" href="#09--user-behaviour-homework-module">09 — User Behaviour (Homework Module)</a></h1> <ul> <li><a href="homework/09_User_Behaviour.html#1-expected-behaviour-server-contract-driven">1. Expected behaviour (server-contract-driven)</a></li> <li><a href="homework/09_User_Behaviour.html#2-common-mistakes--countermeasures">2. Common mistakes & countermeasures</a></li> <li><a href="homework/09_User_Behaviour.html#3-power--new-users">3. Power & new users</a></li> <li><a href="homework/09_User_Behaviour.html#4-mobile--a11y-users">4. Mobile & a11y users</a></li> <li><a href="homework/09_User_Behaviour.html#5-cross-device--conflict">5. Cross-device & conflict</a></li> <li><a href="homework/09_User_Behaviour.html#6-interrupted-sessions--retries">6. Interrupted sessions & retries</a></li> <li><a href="homework/09_User_Behaviour.html#7-abandonment">7. Abandonment</a></li> <li><a href="homework/09_User_Behaviour.html#8-frustration-points--ux-treatments-summary">8. Frustration points → UX treatments (summary)</a></li> <li><a href="homework/09_User_Behaviour.html#9-adoption-path-holistic">9. Adoption path (holistic)</a></li> </ul> <blockquote> <p>Expected behaviour (derived from server semantics + shared interaction rules), the common mistakes users make with these screens, and the UX countermeasures.</p> </blockquote> <hr /> <h2 id="1-expected-behaviour-server-contract-driven-5"><a class="header" href="#1-expected-behaviour-server-contract-driven-5">1. Expected behaviour (server-contract-driven)</a></h2> <ul> <li><strong>Submission is exactly-once.</strong> The server refuses a second submission with 409 "Already submitted." (<code>homework.service.ts:88-92</code>) and the schema enforces it with a unique index (<code>homework-submission.schema.ts:37-40</code>). The UI must treat "already submitted" as <em>done</em>, not as an error — a student who submits from two devices, or double-taps, must land on an informative state.</li> <li><strong>Late submission is accepted.</strong> No due-date check exists in <code>submit()</code> (<code>homework.service.ts:83-112</code>) — the UI must never hard-block a submit, only inform (OQ-1). The late <strong>badge is client-computed</strong> until the server provides a flag.</li> <li><strong>Grading overwrites.</strong> Re-grading replaces marks/remarks and resets <code>gradedAt</code> (<code>homework.service.ts:127-134</code>). Teachers should be warned before overwriting; students should see the newest grade only.</li> <li><strong>Homework deletion hides the homework</strong> (soft-delete, <code>base.repository.ts:68-74</code>) but <strong>keeps submissions</strong> — after delete, the homework detail 404s; teachers should be told submissions are retained server-side.</li> <li><strong>Update is partial.</strong> Only <code>title/description/attachments/dueDate/status</code> change (<code>homework.dto.ts:35-58</code>) — a teacher who expects to move homework to another class will fail; the UI disables those fields.</li> <li><strong>Lists are class-scoped and due-date-sorted</strong> (<code>homework.repository.ts:19-20</code>) — the "due soon" grouping the UI shows matches the sort.</li> <li><strong>No role enforcement on the server</strong> (OQ-9): any JWT can create/grade. The client gates by role; when perms land, gates stay.</li> </ul> <h2 id="2-common-mistakes--countermeasures-5"><a class="header" href="#2-common-mistakes--countermeasures-5">2. Common mistakes & countermeasures</a></h2> <div class="table-wrapper"><table><thead><tr><th>Mistake</th><th>Related</th><th>UX countermeasure</th></tr></thead><tbody> <tr><td>Double-tap "Submit" → 409</td><td>submit</td><td>disable button on first tap; map 409 → "Already submitted" info banner, never red error</td></tr> <tr><td>Submitting from two devices</td><td>submit</td><td>the 409 path above; submission state always re-read from server on screen open</td></tr> <tr><td>Teacher grades the wrong student</td><td>grade</td><td>grade sheet shows student avatar+name header and the homework title; require explicit row selection</td></tr> <tr><td>Typing marks like "20/25"</td><td>grade</td><td>numeric-only keyboard + input formatter (digits only); "Out of {max}" hint</td></tr> <tr><td>Setting a due date in the past</td><td>create</td><td>date picker <code>min = today</code>; edit form warns if past date selected</td></tr> <tr><td>Expecting to move homework to another class</td><td>edit</td><td>fields disabled + helper "Class/subject cannot be changed"</td></tr> <tr><td>Deleting homework thinking it vanishes submissions</td><td>delete</td><td>confirm dialog copy: "Students' submissions are kept."</td></tr> <tr><td>Losing typed grade on network error</td><td>grade</td><td>form state preserved on 5xx; retry offered; never navigate away on failure</td></tr> <tr><td>Upload stuck mid-flight (network loss)</td><td>attachments</td><td>per-file <code>failed</code> state + retry; snackbar guidance; no silent corruption (<a href="homework/10_Interaction_Specification.html">10 §2</a>)</td></tr> <tr><td>Closing app mid-upload</td><td>attachments</td><td>pending uploads shown on return <code>(proposed)</code>; uploads not resumed (B7)</td></tr> </tbody></table> </div> <h2 id="3-power--new-users-5"><a class="header" href="#3-power--new-users-5">3. Power & new users</a></h2> <ul> <li><strong>Power teacher:</strong> keyboard <code>N</code> (new homework), batch grading flow "Next ungraded" <code>(proposed)</code>; pastes marks lists (tab-separated → client fills rows, <code>(proposed)</code>).</li> <li><strong>New student:</strong> first run — "Homework" tab shows empty state + explanation "Assignments from your teachers will appear here".</li> </ul> <h2 id="4-mobile--a11y-users-5"><a class="header" href="#4-mobile--a11y-users-5">4. Mobile & a11y users</a></h2> <ul> <li>One-thumb: primary CTA full-width bottom-ish; FAB reachable.</li> <li>TalkBack/VoiceOver: full submit + grade flows walkable; upload progress announced; late badge read with text, not color.</li> <li>Dynamic type 200%: cards reflow, <code>maxLines</code> only on labels.</li> <li>Reduced motion: no shake/stagger; status changes = fade.</li> </ul> <h2 id="5-cross-device--conflict-1"><a class="header" href="#5-cross-device--conflict-1">5. Cross-device & conflict</a></h2> <ul> <li>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).</li> <li>Two teachers grade the same submission concurrently: last write wins (server <code>updateById</code> <code>$set</code>, <code>homework.service.ts:127</code>); the second grader's sheet shows the current grade on load (re-fetch before save if stale — compare <code>gradedAt</code>).</li> </ul> <h2 id="6-interrupted-sessions--retries-2"><a class="header" href="#6-interrupted-sessions--retries-2">6. Interrupted sessions & retries</a></h2> <ul> <li>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 <code>(proposed)</code>.</li> <li>Upload interrupted: per-file retry (new upload); no resume (B7).</li> <li>429 (uploads/rapid actions): countdown, no auto-retry (<a href="homework/../00-shared/07_API_Conventions.html">00-shared/07 §4</a>).</li> <li>Session expiry mid-flow: <code>sessionExpired</code> overlay → login → return to the same screen (state preserved where safe, <a href="homework/10_Interaction_Specification.html">10 §3</a>).</li> </ul> <h2 id="7-abandonment-2"><a class="header" href="#7-abandonment-2">7. Abandonment</a></h2> <ul> <li>Create form abandoned → draft kept locally (client memo, <code>(proposed)</code>); "Discard?" prompt on back with content.</li> <li>Submit form abandoned → draft (remarks + picked files) kept; badge on the card "Draft" <code>(proposed)</code>.</li> <li>Grade sheet abandoned mid-entry → typed values kept until sheet closed deliberately.</li> </ul> <h2 id="8-frustration-points--ux-treatments-summary-1"><a class="header" href="#8-frustration-points--ux-treatments-summary-1">8. Frustration points → UX treatments (summary)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Frustration</th><th>Treatment</th></tr></thead><tbody> <tr><td>"Already submitted" shown as red error</td><td>Re-map to info banner + disable submit</td></tr> <tr><td>Can't tell if my homework is late</td><td>client-computed <code>LateBadge</code> with icon+text</td></tr> <tr><td>No "my homework" list (OQ-6)</td><td>resolve classId from profile; cache it (TTL 24 h)</td></tr> <tr><td>Marks has no server bounds (OQ-4)</td><td>client validation + "Out of {max}" hint; flag server fix</td></tr> <tr><td>Grading overwrites silently (OQ-2)</td><td>regrade confirm + overwrite banner</td></tr> <tr><td>Homework deleted → submissions kept (no cascade)</td><td>explain in delete confirm; teacher can still see submissions? (no — list 404s with homework; document in OQ)</td></tr> </tbody></table> </div> <h2 id="9-adoption-path-holistic-2"><a class="header" href="#9-adoption-path-holistic-2">9. Adoption path (holistic)</a></h2> <p>P0 (client): list → detail → create → submit → grade → feedback, all server-confirmed. P1: late badges, regrade UX, upload progress polish, drafts. P2 <code>(planned)</code>: <code>GET /homework</code> student endpoint (PLAN.md:67), parent read view (<code>IMPLEMENTATION_PLAN.md:227</code>), bulk assign, plagiarism check, homework emails (<code>IMPLEMENTATION_PLAN.md</code> Phase 4), WS homework topic, push notifications (<a href="homework/../00-shared/12_Assumptions_&_Open_Questions.html">00-shared/12 B3</a>).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-homework-module"><a class="header" href="#10--interaction-specification-homework-module">10 — Interaction Specification (Homework Module)</a></h1> <ul> <li><a href="homework/10_Interaction_Specification.html#1-phone--touch">1. Phone / touch</a></li> <li><a href="homework/10_Interaction_Specification.html#2-keyboard--desktop--mouse">2. Keyboard / desktop / mouse</a></li> <li><a href="homework/10_Interaction_Specification.html#3-haptics-mobile">3. Haptics (mobile)</a></li> <li><a href="homework/10_Interaction_Specification.html#4-motion-language-module-extension-of-08-4">4. Motion language (module extension of 08 §4)</a></li> <li><a href="homework/10_Interaction_Specification.html#5-micro-interactions-catalogue">5. Micro-interactions catalogue</a></li> <li><a href="homework/10_Interaction_Specification.html#6-frustration-controls-shared-08-6--module">6. Frustration controls (shared 08 §6 + module)</a></li> <li><a href="homework/10_Interaction_Specification.html#7-special-deep-link--clipboard-interactions">7. Special: deep-link & clipboard interactions</a></li> <li><a href="homework/10_Interaction_Specification.html#8-validation-of-interaction-to-server-semantics">8. Validation of interaction to server semantics</a></li> </ul> <blockquote> <p>Module-specific interactions layered on shared <a href="homework/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>. Covers every input modality on phone/tablet/desktop for list, forms, grading, attachments.</p> </blockquote> <hr /> <h2 id="1-phone--touch-5"><a class="header" href="#1-phone--touch-5">1. Phone / touch</a></h2> <div class="table-wrapper"><table><thead><tr><th>Gesture</th><th>Behaviour</th><th>Where</th></tr></thead><tbody> <tr><td>Tap</td><td>open card, submit, grade, preview attachment</td><td>all</td></tr> <tr><td>Tap on submit/grade CTA</td><td>submitting-aware disabled re-tap</td><td>submit/grade forms</td></tr> <tr><td>Long-press</td><td><code>AppMenu</code> on homework rows (Edit/Delete); attachment menu (Save/Share)</td><td>lists, attachments</td></tr> <tr><td>Double-tap</td><td>zoom in attachment preview (PDF/image)</td><td>preview only (<a href="homework/08_Form_Specifications.html">08 §1</a>)</td></tr> <tr><td>Swipe left</td><td>homework row quick actions (Edit, Delete) — ≤ 2 (<a href="homework/08_Form_Specifications.html">08 §1</a>)</td><td>teacher list</td></tr> <tr><td>Pull down</td><td>refresh list / submissions (<code>RefreshIndicator</code>)</td><td>all scrollables</td></tr> <tr><td>Hardware back</td><td>pop form/sheet → previous screen; form with content → discard prompt</td><td>all</td></tr> <tr><td>Edge swipe</td><td>shell drawer</td><td>app shell</td></tr> </tbody></table> </div> <h2 id="2-keyboard--desktop--mouse-5"><a class="header" href="#2-keyboard--desktop--mouse-5">2. Keyboard / desktop / mouse</a></h2> <div class="table-wrapper"><table><thead><tr><th>Input</th><th>Behaviour</th></tr></thead><tbody> <tr><td>Tab</td><td>logical form order (class → subject → title → description → dueDate → attachments → CTA); focus ring <code>primary</code></td></tr> <tr><td>Enter</td><td>submits single-field contexts; <code>Ctrl+Enter</code> submits forms from text areas (remarks/description)</td></tr> <tr><td>Esc</td><td>closes sheet/dialog/menu; cancels selection; focus returns to trigger</td></tr> <tr><td>N</td><td>new homework (list screen, teacher)</td></tr> <tr><td>Ctrl/Cmd+K</td><td>global search (shell)</td></tr> <tr><td>Arrows</td><td>dropdown navigation (class/subject/status)</td></tr> <tr><td>Right-click</td><td>context menu = long-press menu</td></tr> <tr><td>Hover</td><td><code>m-fast</code> tint on cards/rows; tooltip after 500 ms</td></tr> <tr><td>Focus</td><td>visible ring everywhere; logical traversal (<a href="homework/09_User_Behaviour.html">09 §6</a>)</td></tr> </tbody></table> </div> <h2 id="3-haptics-mobile-6"><a class="header" href="#3-haptics-mobile-6">3. Haptics (mobile)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Haptic</th></tr></thead><tbody> <tr><td>Submit success</td><td><code>lightImpact</code></td></tr> <tr><td>Grade saved</td><td><code>lightImpact</code></td></tr> <tr><td>409 duplicate (already submitted)</td><td><code>selectionClick</code> (informational)</td></tr> <tr><td>Delete confirm</td><td><code>heavyImpact</code></td></tr> <tr><td>Upload complete (per file)</td><td><code>selectionClick</code></td></tr> <tr><td>Upload failed</td><td><code>mediumImpact</code></td></tr> <tr><td>Rule</td><td>off when system haptics off; none on desktop (<a href="homework/08_Form_Specifications.html">08 §3</a>)</td></tr> </tbody></table> </div> <h2 id="4-motion-language-module-extension-of-08-4-4"><a class="header" href="#4-motion-language-module-extension-of-08-4-4">4. Motion language (module extension of <a href="homework/08_Form_Specifications.html">08 §4</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Transition</th><th>Duration/curve</th><th>Used by</th></tr></thead><tbody> <tr><td>List → detail</td><td><code>m-base</code> slide (+ <code>Hero</code> on card title <code>m-slow</code>)</td><td>detail open</td></tr> <tr><td>Form open</td><td><code>m-base</code> slide-up</td><td>create/edit/submit</td></tr> <tr><td>Status chip change</td><td><code>AnimatedSwitcher</code> fade <code>m-fast</code></td><td>submitted→graded</td></tr> <tr><td>Row removal after delete</td><td><code>m-base</code> fade/slide</td><td>list</td></tr> <tr><td>Card entrance</td><td><code>m-entrance</code> stagger 40 ms</td><td>list load</td></tr> <tr><td>Upload progress bar</td><td>determinate <code>m-instant</code> updates</td><td>attachment uploader</td></tr> <tr><td>Late badge appear</td><td><code>m-fast</code> pop</td><td>computed on load</td></tr> </tbody></table> </div> <p>Reduced-motion: fades + instant only; no stagger, no hero, no shake.</p> <h2 id="5-micro-interactions-catalogue-3"><a class="header" href="#5-micro-interactions-catalogue-3">5. Micro-interactions catalogue</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Detail</th></tr></thead><tbody> <tr><td>Submit pressed</td><td>button loading swap <code>m-base</code>, double-tap locked, haptic on success</td></tr> <tr><td>409 already-submitted</td><td>form swaps to success-ish state + info banner; no red flash</td></tr> <tr><td>Grade saved</td><td>snackbar "Grade saved" + row re-animates (marks badge pop <code>m-fast</code>)</td></tr> <tr><td>Attachment uploaded</td><td>chip icon morph file→check <code>m-fast</code>; progress 100% → <code>selectionClick</code></td></tr> <tr><td>Attachment failed</td><td>red ring + <code>failed</code> chip + Retry button; error persists until acted</td></tr> <tr><td>Delete homework</td><td>confirm dialog → server confirm → row fade-out; snackbar "Homework deleted" (no undo — no restore endpoint)</td></tr> <tr><td>Regrade confirm</td><td>warning banner inside sheet + dialog if a previous grade exists</td></tr> </tbody></table> </div> <h2 id="6-frustration-controls-shared-08-6--module-4"><a class="header" href="#6-frustration-controls-shared-08-6--module-4">6. Frustration controls (shared <a href="homework/08_Form_Specifications.html">08 §6</a> + module)</a></h2> <ul> <li>All presses respond < 100 ms with <code>m-fast</code> ripple.</li> <li>Every network action shows pending state (button spinner, per-file progress, skeleton).</li> <li>Submit/grade buttons disabled while pending — anti-double-submit.</li> <li>Uploads never fail silently: per-file error + retry.</li> <li>Errors persist until corrected/dismissed (persistent banner for 409/regrade warnings).</li> <li>No auto-retry on 429; countdown shown.</li> </ul> <h2 id="7-special-deep-link--clipboard-interactions-2"><a class="header" href="#7-special-deep-link--clipboard-interactions-2">7. Special: deep-link & clipboard interactions</a></h2> <ul> <li><code>studylyon://homework/:id</code> → detail; notification tap (graded/submitted) → detail.</li> <li>Attachment preview: copy text-selection in PDF; share via platform sheet.</li> <li>Marks entry: paste a number is accepted; formatter strips non-digits.</li> </ul> <h2 id="8-validation-of-interaction-to-server-semantics-5"><a class="header" href="#8-validation-of-interaction-to-server-semantics-5">8. Validation of interaction to server semantics</a></h2> <ul> <li>Never block submit client-side on <code>dueDate</code> (server accepts late — OQ-1).</li> <li>Never allow a second submission in UI (409 is the boundary; UI treats it as done).</li> <li>Grade feedback always rendered from the server doc (<code>marks?</code> may be absent while <code>status='submitted'</code> — <code>homework-submission.schema.ts:24,30-31</code>).</li> <li>Delete = soft delete; the row must disappear from lists (isDeleted filter, <code>base.repository.ts:20-30</code>) but the client must not assume the file records are gone.</li> <li>No optimistic mutations anywhere in the module (create/submit/grade/delete all server-confirmed — <a href="homework/../00-shared/07_API_Conventions.html">00-shared/07 §9</a>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-homework-module"><a class="header" href="#11--design-system-mapping-homework-module">11 — Design System Mapping (Homework Module)</a></h1> <ul> <li><a href="homework/11_Design_System_Mapping.html#1-theme-recipes-module">1. Theme recipes (module)</a></li> <li><a href="homework/11_Design_System_Mapping.html#2-widget--token">2. Widget → token</a></li> <li><a href="homework/11_Design_System_Mapping.html#3-typography-mapping">3. Typography mapping</a></li> <li><a href="homework/11_Design_System_Mapping.html#4-iconography">4. Iconography</a></li> <li><a href="homework/11_Design_System_Mapping.html#5-motion-tokens-values-00-shared02-6">5. Motion tokens (values 00-shared/02 §6)</a></li> <li><a href="homework/11_Design_System_Mapping.html#6-contrast--dark-parity">6. Contrast & dark parity</a></li> <li><a href="homework/11_Design_System_Mapping.html#7-breakpoint-behaviour">7. Breakpoint behaviour</a></li> <li><a href="homework/11_Design_System_Mapping.html#8-theming-rules-enforced">8. Theming rules (enforced)</a></li> </ul> <blockquote> <p>Homework module → Material 3 / Flutter / token mapping, extending <a href="homework/../00-shared/04_Design_System_Mapping.html">00-shared/04</a>. Every color, type, spacing, elevation, radius, icon, motion reference traces to <a href="homework/../00-shared/02_Design_Tokens.html">00-shared/02_Design_Tokens.md</a>.</p> </blockquote> <hr /> <h2 id="1-theme-recipes-module-5"><a class="header" href="#1-theme-recipes-module-5">1. Theme recipes (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Surface</th><th>Theme</th><th>Exception</th></tr></thead><tbody> <tr><td>Lists & cards</td><td><code>surface</code> / <code>surfaceContainerLow</code>, <code>e-0/e-1</code></td><td>selected (master-detail) → <code>primaryContainer</code></td></tr> <tr><td>Status chips</td><td><code>secondaryContainer</code> (active/graded), <code>tertiaryContainer</code> (pending/late), <code>outline</code> (closed/not submitted)</td><td>—</td></tr> <tr><td>Grade feedback card</td><td><code>Card.filled</code> <code>secondaryContainer</code></td><td>marks in <code>onSecondaryContainer</code></td></tr> <tr><td>Warning surfaces (late, regrade, past due)</td><td><code>tertiaryContainer</code></td><td>icon + text, never color-only</td></tr> <tr><td>Destructive (delete/regrade confirm)</td><td><code>error</code> / <code>errorContainer</code></td><td><code>AppDialog</code> destructive action</td></tr> </tbody></table> </div> <h2 id="2-widget--token-5"><a class="header" href="#2-widget--token-5">2. Widget → token</a></h2> <div class="table-wrapper"><table><thead><tr><th>Component</th><th>M3 / Flutter</th><th>Tokens</th><th>Elev</th><th>Radius</th></tr></thead><tbody> <tr><td>HomeworkCard</td><td><code>Card</code></td><td><code>surfaceContainerLow</code></td><td><code>e-0/e-1</code></td><td><code>r-md</code></td></tr> <tr><td>Subject chip</td><td><code>Chip</code></td><td><code>surfaceVariant</code></td><td>—</td><td><code>r-sm</code></td></tr> <tr><td>Status badge active</td><td><code>Badge</code></td><td><code>secondaryContainer</code></td><td>—</td><td><code>r-sm</code></td></tr> <tr><td>Status badge closed</td><td><code>Badge</code></td><td><code>outline</code></td><td>—</td><td><code>r-sm</code></td></tr> <tr><td>LateBadge</td><td><code>Badge</code> + icon</td><td><code>tertiaryContainer</code> / <code>tertiary</code></td><td>—</td><td><code>r-sm</code></td></tr> <tr><td>SubmissionRow</td><td><code>Card</code> + <code>ListTile</code></td><td><code>surfaceContainerLow</code></td><td><code>e-0</code></td><td><code>r-md</code></td></tr> <tr><td>GradeFeedbackCard</td><td><code>Card.filled</code></td><td><code>secondaryContainer</code></td><td><code>e-1</code></td><td><code>r-lg</code></td></tr> <tr><td>Create/Edit/Submit form fields</td><td><code>TextFormField</code></td><td>fill <code>surfaceVariant</code>, border <code>outline</code>, focus <code>primary</code></td><td>—</td><td><code>r-md</code></td></tr> <tr><td>Primary CTA (submit/grade)</td><td><code>FilledButton</code></td><td><code>primary</code>/<code>onPrimary</code></td><td>—</td><td><code>r-md</code></td></tr> <tr><td>Secondary (submissions, add attachment)</td><td><code>OutlinedButton</code></td><td><code>outline</code>/<code>onSurface</code></td><td>—</td><td><code>r-md</code></td></tr> <tr><td>FAB "New homework"</td><td><code>FloatingActionButton.extended</code></td><td><code>primaryContainer</code></td><td><code>e-3</code></td><td><code>r-full</code></td></tr> <tr><td>Delete/regrade confirm</td><td><code>AppDialog</code></td><td><code>surfaceContainerHigh</code>, destructive <code>error</code></td><td><code>e-4</code></td><td><code>r-lg</code></td></tr> <tr><td>Attachment upload tile</td><td><code>ListTile</code> + <code>LinearProgressIndicator</code></td><td><code>surfaceVariant</code> track, <code>primary</code> progress</td><td>—</td><td><code>r-md</code></td></tr> <tr><td>Snackbar</td><td><code>SnackBar</code></td><td><code>inverseSurface</code></td><td><code>e-5</code></td><td><code>r-md</code></td></tr> <tr><td>Offline banner</td><td><code>AppOfflineBanner</code></td><td><code>tertiaryContainer</code></td><td><code>e-1</code></td><td>—</td></tr> </tbody></table> </div> <h2 id="3-typography-mapping-7"><a class="header" href="#3-typography-mapping-7">3. Typography mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Token</th><th>Screen element</th></tr></thead><tbody> <tr><td><code>headlineMedium</code></td><td>screen titles (Homework list, detail header)</td></tr> <tr><td><code>titleLarge</code></td><td>AppBar titles, create-form header</td></tr> <tr><td><code>titleMedium</code></td><td>card titles, submission row names, section headers</td></tr> <tr><td><code>bodyLarge</code></td><td>homework description fallback (pre-markdown)</td></tr> <tr><td><code>bodyMedium</code></td><td>meta lines (class, teacher, due), form labels</td></tr> <tr><td><code>bodySmall</code></td><td>timestamps, "Submitted 3d ago", helper text</td></tr> <tr><td><code>labelLarge</code></td><td>buttons, chips</td></tr> <tr><td><code>labelMedium</code></td><td>badges ("Late · 2d", "Graded")</td></tr> <tr><td><code>displaySmall</code></td><td>marks in GradeFeedbackCard</td></tr> <tr><td><code>mono</code></td><td>marks, attachment sizes (<code>tabularFigures()</code>)</td></tr> </tbody></table> </div> <h2 id="4-iconography-7"><a class="header" href="#4-iconography-7">4. Iconography</a></h2> <p>Material Symbols outlined: <code>assignment</code>, <code>assignment_turned_in</code>, <code>schedule</code>, <code>event</code>, <code>attach_file</code>, <code>upload_file</code>, <code>download</code>, <code>description</code>, <code>picture_as_pdf</code>, <code>edit</code>, <code>delete</code>, <code>check_circle</code>, <code>warning</code>, <code>error</code>, <code>refresh</code>, <code>grade</code>, <code>person</code>, <code>school</code>. Status icons use filled variants (submitted check, graded star).</p> <h2 id="5-motion-tokens-values-00-shared02-6-5"><a class="header" href="#5-motion-tokens-values-00-shared02-6-5">5. Motion tokens (values <a href="homework/../00-shared/02_Design_Tokens.html">00-shared/02 §6</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Purpose</th><th>Token</th><th>Applied to</th></tr></thead><tbody> <tr><td>List→detail transition</td><td><code>m-base</code></td><td>navigation</td></tr> <tr><td>Hero on card→detail title</td><td><code>m-slow</code></td><td>detail open</td></tr> <tr><td>Status chip morph</td><td><code>m-fast</code></td><td>submitted→graded</td></tr> <tr><td>Upload progress updates</td><td><code>m-instant</code></td><td>determinate bars</td></tr> <tr><td>Card entrance stagger</td><td><code>m-entrance</code> (40 ms)</td><td>list load</td></tr> <tr><td>Badge pop (marks appear)</td><td><code>m-fast</code></td><td>grade result</td></tr> <tr><td>Row removal (delete)</td><td><code>m-base</code></td><td>list</td></tr> </tbody></table> </div> <p>Rules: reduced-motion substitutes fades/instants (<a href="homework/08_Form_Specifications.html">08 §4</a>).</p> <h2 id="6-contrast--dark-parity-5"><a class="header" href="#6-contrast--dark-parity-5">6. Contrast & dark parity</a></h2> <ul> <li>Body text ≥ 4.5:1; interactive chrome ≥ 3:1 (both modes).</li> <li>Dark: <code>secondaryContainer</code> dark variant (chromatic roles TBD by designer, <a href="homework/02_User_Personas.html">02 §1.4</a>); late badge <code>warning</code> text on <code>warningContainer</code> verified for contrast.</li> <li>No sole-color status: every badge pairs icon + text (<a href="homework/09_User_Behaviour.html">09 §9</a>).</li> <li>Marks read as text, not color (<code>tabularFigures</code>).</li> </ul> <h2 id="7-breakpoint-behaviour-5"><a class="header" href="#7-breakpoint-behaviour-5">7. Breakpoint behaviour</a></h2> <div class="table-wrapper"><table><thead><tr><th>Class</th><th>List</th><th>Detail/Forms</th></tr></thead><tbody> <tr><td>phone <600</td><td>single column, FAB, swipe actions</td><td>push navigation, full-screen forms</td></tr> <tr><td>600–839</td><td>2-col grid</td><td>master-detail</td></tr> <tr><td>≥1200</td><td>3-col grid or list+detail panes</td><td>grade sheet splits: submissions list left, form right</td></tr> </tbody></table> </div> <h2 id="8-theming-rules-enforced-1"><a class="header" href="#8-theming-rules-enforced-1">8. Theming rules (enforced)</a></h2> <ol> <li>No literal <code>Color</code>/padding/duration in widget code — tokens only (<a href="homework/../00-shared/04_Design_System_Mapping.html">00-shared/04 §7</a>).</li> <li>Tenant branding overrides <code>primary</code> only; badges derive from roles, not branding.</li> <li>Dark mode is a first-class theme (badges re-tinted, contrast re-verified).</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-homework-module"><a class="header" href="#12--api-mapping-homework-module">12 — API Mapping (Homework Module)</a></h1> <ul> <li><a href="homework/12_API_Mapping.html#0-module-wide-request-envelope--client-policy">0. Module-wide request envelope & client policy</a></li> <li><a href="homework/12_API_Mapping.html#screen-homework-list-teacherstudent--get-homeworkclassclassid">Screen: Homework list (teacher/student) — <code>GET /homework/class/:classId</code></a></li> <li><a href="homework/12_API_Mapping.html#screen-homework-detail--get-homeworkid">Screen: Homework detail — <code>GET /homework/:id</code></a></li> <li><a href="homework/12_API_Mapping.html#screen-create-homework--post-homework">Screen: Create homework — <code>POST /homework</code></a></li> <li><a href="homework/12_API_Mapping.html#screen-edit-homework--patch-homeworkid">Screen: Edit homework — <code>PATCH /homework/:id</code></a></li> <li><a href="homework/12_API_Mapping.html#screen-delete-homework--delete-homeworkid">Screen: Delete homework — <code>DELETE /homework/:id</code></a></li> <li><a href="homework/12_API_Mapping.html#screen-submit-homework--post-homeworkidsubmit">Screen: Submit homework — <code>POST /homework/:id/submit</code></a></li> <li><a href="homework/12_API_Mapping.html#screen-submissions-list-teacher--get-homeworkidsubmissions">Screen: Submissions list (teacher) — <code>GET /homework/:id/submissions</code></a></li> <li><a href="homework/12_API_Mapping.html#screen-grade-submission--patch-homeworkidsubmissionssubmissionidgrade">Screen: Grade submission — <code>PATCH /homework/:id/submissions/:submissionId/grade</code></a></li> <li><a href="homework/12_API_Mapping.html#screen-attachments--filesmodule">Screen: Attachments — FilesModule</a></li> <li><a href="homework/12_API_Mapping.html#loading--streaming--realtime">Loading / streaming / realtime</a></li> <li><a href="homework/12_API_Mapping.html#client-side-error-mapping-table-module">Client-side error mapping table (module)</a></li> <li><a href="homework/12_API_Mapping.html#optimistic--undo">Optimistic / undo</a></li> <li><a href="homework/12_API_Mapping.html#notifications-surface-for-the-notification-badgedetail">Notifications surface (for the notification badge/detail)</a></li> </ul> <blockquote> <p>Exact wire contract for every screen → endpoint. Base <code>/api/v1</code>; envelope per <a href="homework/../00-shared/07_API_Conventions.html">00-shared/07</a>. All endpoints from <code>src/modules/homework/homework.controller.ts</code>; business rules from <code>homework.service.ts</code>; attachments from <code>files.controller.ts</code>. Guard: <code>@UseGuards(JwtAuthGuard)</code> (<code>homework.controller.ts:19</code>) — <strong>no RBAC metadata</strong> (OQ-9). Tenant from JWT only; <strong>never in body</strong> (tenant is injected by <code>BaseRepository</code>, <code>base.repository.ts:24-29</code>).</p> </blockquote> <hr /> <h2 id="0-module-wide-request-envelope--client-policy-8"><a class="header" href="#0-module-wide-request-envelope--client-policy-8">0. Module-wide request envelope & client policy</a></h2> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Contract</th></tr></thead><tbody> <tr><td>Base</td><td><code>https://api.<domain>/api/v1</code></td></tr> <tr><td>Headers</td><td><code>Authorization: Bearer <accessToken></code>; <code>x-request-id</code>; <code>Content-Type: application/json</code>; multipart for <code>/files/upload</code></td></tr> <tr><td>Response</td><td><code>{success, message:"OK", data, meta?, timestamp, requestId}</code> (<code>response-envelope.interceptor.ts:49-52</code>)</td></tr> <tr><td>Error</td><td><code>{success:false, message, error:{code, details?}, timestamp, requestId}</code> (<code>http-exception.filter.ts:73-81</code>)</td></tr> <tr><td>Pagination</td><td>homework/submissions are <strong>non-paginated arrays</strong> (no <code>meta</code>) — render all</td></tr> <tr><td>Caching</td><td>client list cache TTL 5 min; detail no cache (<a href="homework/../00-shared/06_State_Management.html">00-shared/06 §3.3</a>)</td></tr> <tr><td>Offline</td><td>reads cached; writes blocked (create/submit/grade) except attachment upload retry</td></tr> <tr><td>Retry</td><td>backoff on 5xx/network; <strong>no auto-retry</strong> on 429 (<code>api</code> tier 100/min)</td></tr> </tbody></table> </div> <hr /> <h2 id="screen-homework-list-teacherstudent--get-homeworkclassclassid"><a class="header" href="#screen-homework-list-teacherstudent--get-homeworkclassclassid">Screen: Homework list (teacher/student) — <code>GET /homework/class/:classId</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>GET /api/v1/homework/class/:classId</code></td></tr> <tr><td>Params</td><td><code>classId</code> (MongoId)</td></tr> <tr><td>Success</td><td>200 <code>data: [HomeworkDoc…]</code> sorted <code>dueDate</code> desc (<code>homework.repository.ts:20</code>)</td></tr> <tr><td>Source</td><td><code>homework.controller.ts:25-27</code></td></tr> <tr><td>Errors</td><td>404/400 invalid id (CastError → 400 <code>VALIDATION_ERROR</code>, <code>http-exception.filter.ts:47-55</code>); 5xx</td></tr> <tr><td>Student variant</td><td>client resolves own <code>classId</code> from profile (PLAN.md:67 endpoint missing — OQ-6)</td></tr> <tr><td>Pagination</td><td>none (full array)</td></tr> </tbody></table> </div> <p><strong>HomeworkDoc shape</strong> (<code>homework.schema.ts:8-35</code> + BaseSchema): <code>_id, tenantId, teacherId, classId, subjectId, title, description?, attachments[], assignedDate, dueDate, status: 'active'|'closed', createdAt, updatedAt, isDeleted…</code></p> <hr /> <h2 id="screen-homework-detail--get-homeworkid"><a class="header" href="#screen-homework-detail--get-homeworkid">Screen: Homework detail — <code>GET /homework/:id</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>GET /api/v1/homework/:id</code></td></tr> <tr><td>Success</td><td>200 <code>data: HomeworkDoc</code></td></tr> <tr><td>Errors</td><td>404 <code>RESOURCE_NOT_FOUND</code> "Homework not found." (<code>homework.service.ts:47</code>)</td></tr> </tbody></table> </div> <hr /> <h2 id="screen-create-homework--post-homework"><a class="header" href="#screen-create-homework--post-homework">Screen: Create homework — <code>POST /homework</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>POST /api/v1/homework</code> (<code>homework.controller.ts:22-24</code>)</td></tr> <tr><td>Body</td><td><code>{teacherId, classId, subjectId, title, description?, attachments?: string[], dueDate}</code> (<code>homework.dto.ts:4-33</code>; <code>dueDate</code> ISO date string)</td></tr> <tr><td>Success</td><td>201 <code>data: HomeworkDoc</code> — server sets <code>assignedDate: now</code>, <code>status: 'active'</code> (<code>homework.service.ts:30-32</code>)</td></tr> <tr><td>Side effect</td><td>emits <code>HomeworkCreated {homeworkId, classId}</code> → <code>in-app</code> queue (<code>homework.service.ts:34-41</code>, <code>event-queue-map.ts:22</code>)</td></tr> <tr><td>Errors</td><td>400 <code>VALIDATION_ERROR</code> (decorators: <code>IsMongoId</code>, <code>IsString</code>, <code>IsDateString</code>); 5xx</td></tr> </tbody></table> </div> <hr /> <h2 id="screen-edit-homework--patch-homeworkid"><a class="header" href="#screen-edit-homework--patch-homeworkid">Screen: Edit homework — <code>PATCH /homework/:id</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>PATCH /api/v1/homework/:id</code> (<code>homework.controller.ts:31-36</code>)</td></tr> <tr><td>Body</td><td>any subset of <code>{title, description, attachments, dueDate, status}</code> (<code>homework.dto.ts:35-58</code>) — <strong>classId/subjectId/teacherId immutable</strong></td></tr> <tr><td>Success</td><td>200 <code>data: HomeworkDoc</code> (<code>$set</code>, version+1 via <code>updateById</code>, <code>base.repository.ts:57-66</code>)</td></tr> <tr><td>Side effect</td><td>emits <code>HomeworkUpdated {homeworkId, changes[]}</code> → <code>in-app</code> (<code>homework.service.ts:59-67</code>, <code>event-queue-map.ts:23</code>)</td></tr> <tr><td>Errors</td><td>404 "Homework not found." (<code>homework.service.ts:56-58</code>); 400 invalid <code>status</code> enum value</td></tr> </tbody></table> </div> <hr /> <h2 id="screen-delete-homework--delete-homeworkid"><a class="header" href="#screen-delete-homework--delete-homeworkid">Screen: Delete homework — <code>DELETE /homework/:id</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>DELETE /api/v1/homework/:id</code> (<code>homework.controller.ts:37-39</code>)</td></tr> <tr><td>Success</td><td>200 <code>{message:"OK"}</code> — <strong>soft delete</strong> (<code>homework.service.ts:70-81</code>, <code>base.repository.ts:68-74</code>); submissions retained (no cascade)</td></tr> <tr><td>Side effect</td><td>emits <code>HomeworkDeleted {homeworkId}</code> → <code>audit-write</code> queue (<code>event-queue-map.ts:26</code>)</td></tr> <tr><td>Errors</td><td>404 "Homework not found." (<code>homework.service.ts:71-72</code>)</td></tr> </tbody></table> </div> <hr /> <h2 id="screen-submit-homework--post-homeworkidsubmit"><a class="header" href="#screen-submit-homework--post-homeworkidsubmit">Screen: Submit homework — <code>POST /homework/:id/submit</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>POST /api/v1/homework/:id/submit</code> (<code>homework.controller.ts:40-45</code>)</td></tr> <tr><td>Body</td><td><code>{studentId, remarks?, attachments?: string[]}</code> (<code>submission.dto.ts:4-17</code>)</td></tr> <tr><td>Success</td><td>201 <code>data: SubmissionDoc</code> — server sets <code>submittedAt: now</code>, <code>status: 'submitted'</code> (<code>homework.service.ts:93-98</code>)</td></tr> <tr><td>Side effect</td><td>emits <code>HomeworkSubmitted {homeworkId, studentId, submissionId}</code> → <code>in-app</code> (<code>homework.service.ts:99-110</code>, <code>event-queue-map.ts:24</code>)</td></tr> <tr><td>Errors</td><td>404 "Homework not found." (<code>homework.service.ts:87</code>); <strong>409 <code>DUPLICATE_RESOURCE</code> "Already submitted."</strong> (<code>homework.service.ts:92</code>) + unique index (<code>homework-submission.schema.ts:37-40</code>); 400</td></tr> <tr><td>Note</td><td><strong>No due-date enforcement</strong> — late accepted (OQ-1)</td></tr> </tbody></table> </div> <p><strong>SubmissionDoc shape</strong> (<code>homework-submission.schema.ts:8-32</code>): <code>_id, tenantId, homeworkId, studentId, attachments[], remarks?, submittedAt, status: 'submitted'|'graded', gradedAt?, marks?</code></p> <hr /> <h2 id="screen-submissions-list-teacher--get-homeworkidsubmissions"><a class="header" href="#screen-submissions-list-teacher--get-homeworkidsubmissions">Screen: Submissions list (teacher) — <code>GET /homework/:id/submissions</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>GET /api/v1/homework/:id/submissions</code> (<code>homework.controller.ts:46-48</code>)</td></tr> <tr><td>Success</td><td>200 <code>data: [SubmissionDoc…]</code> insertion order (<code>homework-submission.repository.ts:20-24</code>)</td></tr> <tr><td>Errors</td><td>400 invalid id; 5xx</td></tr> </tbody></table> </div> <hr /> <h2 id="screen-grade-submission--patch-homeworkidsubmissionssubmissionidgrade"><a class="header" href="#screen-grade-submission--patch-homeworkidsubmissionssubmissionidgrade">Screen: Grade submission — <code>PATCH /homework/:id/submissions/:submissionId/grade</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>PATCH /api/v1/homework/:id/submissions/:submissionId/grade</code> (<code>homework.controller.ts:49-55</code>)</td></tr> <tr><td>Body</td><td><code>{marks, remarks?}</code> (<code>submission.dto.ts:19-26</code>) — <strong>marks has no validation decorator</strong> (OQ-4)</td></tr> <tr><td>Success</td><td>200 <code>data: SubmissionDoc</code> — sets <code>marks, remarks, status:'graded', gradedAt: now</code> (<code>homework.service.ts:127-134</code>)</td></tr> <tr><td>Side effect</td><td>emits <code>HomeworkGraded {homeworkId, submissionId, marks}</code> → <code>in-app</code> (<code>homework.service.ts:136-143</code>, <code>event-queue-map.ts:25</code>)</td></tr> <tr><td>Errors</td><td>404 "Submission not found." (<code>homework.service.ts:126</code>); 400</td></tr> <tr><td>Regrade</td><td>repeatable — overwrites + re-emits (OQ-2)</td></tr> </tbody></table> </div> <hr /> <h2 id="screen-attachments--filesmodule"><a class="header" href="#screen-attachments--filesmodule">Screen: Attachments — FilesModule</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Upload</strong></td><td><code>POST /api/v1/files/upload</code> multipart <code>file</code> field, <code>@Permissions('file.upload')</code> (<code>files.controller.ts:29-41</code>); returns <code>FileRecord</code> doc (<code>file.schema.ts:8-43</code>) incl. <code>_id</code> → put id in <code>attachments[]</code></td></tr> <tr><td><strong>Download</strong></td><td><code>GET /api/v1/files/:id/download</code> <code>file.read</code> → stream (<code>files.controller.ts:55-64</code>)</td></tr> <tr><td><strong>Meta</strong></td><td><code>GET /api/v1/files/:id</code> <code>file.read</code> (<code>files.controller.ts:49-53</code>)</td></tr> <tr><td><strong>Delete</strong></td><td><code>DELETE /api/v1/files/:id</code> <code>file.delete</code> (<code>files.controller.ts:66-71</code>) — <strong>not</strong> called by homework flows (attachment removal = homework PATCH replacing the array)</td></tr> <tr><td>Limits</td><td><strong>none enforced server-side</strong> (size/mime) — client enforces (OQ-4/01)</td></tr> </tbody></table> </div> <hr /> <h2 id="loading--streaming--realtime-4"><a class="header" href="#loading--streaming--realtime-4">Loading / streaming / realtime</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Loading</th><th>Streaming</th><th>Realtime</th></tr></thead><tbody> <tr><td>list</td><td><code>AppSkeleton</code></td><td>—</td><td><code>(planned)</code> WS topic; re-fetch on focus</td></tr> <tr><td>detail</td><td>skeleton</td><td>—</td><td>re-fetch on focus (grade may have landed)</td></tr> <tr><td>create/edit</td><td>button spinner</td><td>—</td><td>—</td></tr> <tr><td>submit</td><td>button spinner + per-file upload progress</td><td>upload stream</td><td>—</td></tr> <tr><td>grade</td><td>button spinner</td><td>—</td><td>—</td></tr> </tbody></table> </div> <h2 id="client-side-error-mapping-table-module-3"><a class="header" href="#client-side-error-mapping-table-module-3">Client-side error mapping table (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>code</th><th>UI</th></tr></thead><tbody> <tr><td>submit</td><td>409 <code>DUPLICATE_RESOURCE</code></td><td>"Already submitted" info state (not error)</td></tr> <tr><td>any detail</td><td>404 <code>RESOURCE_NOT_FOUND</code></td><td>empty state "removed"</td></tr> <tr><td>grade</td><td>404</td><td>back + refresh list</td></tr> <tr><td>create/edit</td><td>400 <code>VALIDATION_ERROR</code></td><td>field-level errors</td></tr> <tr><td>any</td><td>401 → refresh → fail</td><td><code>sessionExpired</code></td></tr> <tr><td>any</td><td>429 <code>RATE_LIMITED</code></td><td>countdown, no retry</td></tr> <tr><td>any</td><td>5xx</td><td>generic + requestId, retry</td></tr> </tbody></table> </div> <h2 id="optimistic--undo-4"><a class="header" href="#optimistic--undo-4">Optimistic / undo</a></h2> <ul> <li><strong>No optimistic mutations</strong> — create/submit/grade/delete are all server-confirmed (side effects: events + notifications; <a href="homework/../00-shared/07_API_Conventions.html">00-shared/07 §9</a>).</li> <li>Undo: only in-form attachment removal (local). Delete has confirm dialog, no undo (soft-delete but no restore endpoint).</li> </ul> <h2 id="notifications-surface-for-the-notification-badgedetail"><a class="header" href="#notifications-surface-for-the-notification-badgedetail">Notifications surface (for the notification badge/detail)</a></h2> <p><code>GET /api/v1/notifications</code>, <code>GET /notifications/unread-count</code>, <code>PATCH /notifications/:id/read</code>, <code>PATCH /notifications/read-all</code> (<code>notifications.controller.ts:21-47</code>). Homework event types are routed (<code>event-queue-map.ts:22-25</code>) but <strong>fail enum validation today</strong> (<code>notification.schema.ts:7-12</code>, <code>inapp.worker.ts:46-53</code>) — OQ-8; badge counts homework activity once the enum is extended.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-homework-module"><a class="header" href="#13--state-management-homework-module">13 — State Management (Homework Module)</a></h1> <ul> <li><a href="homework/13_State_Management.html#1-homeworklistcubit-list-screen-both-roles">1. HomeworkListCubit (list screen, both roles)</a></li> <li><a href="homework/13_State_Management.html#2-homeworkdetailcubit">2. HomeworkDetailCubit</a></li> <li><a href="homework/13_State_Management.html#3-homeworkformcubit-create--edit">3. HomeworkFormCubit (create + edit)</a></li> <li><a href="homework/13_State_Management.html#4-submitcubit-submission-form">4. SubmitCubit (submission form)</a></li> <li><a href="homework/13_State_Management.html#5-submissionscubit-teacher-list">5. SubmissionsCubit (teacher list)</a></li> <li><a href="homework/13_State_Management.html#6-gradecubit-grading-sheet">6. GradeCubit (grading sheet)</a></li> <li><a href="homework/13_State_Management.html#7-attachment-upload-shared-mixin">7. Attachment upload (shared mixin)</a></li> <li><a href="homework/13_State_Management.html#8-realtime--cross-cutting-interplay">8. Realtime & cross-cutting interplay</a></li> <li><a href="homework/13_State_Management.html#9-testing-hooks-00-shared06-6">9. Testing hooks (<code>00-shared/06 §6</code>)</a></li> </ul> <blockquote> <p>Per-screen Cubits (Flutter/bloc; proposal, <a href="homework/../00-shared/06_State_Management.html">00-shared/06</a>) backed by <code>HomeworkRepository</code> (dio) calling the endpoints in <a href="homework/12_API_Mapping.html">12_API_Mapping.md</a>. Module-wide rules: <strong>no optimistic mutations</strong>, server-confirmed writes only; submission is a write-once op with a 409 handler; grading is optimistic-free (side effects: events → notifications).</p> </blockquote> <hr /> <h2 id="1-homeworklistcubit-list-screen-both-roles"><a class="header" href="#1-homeworklistcubit-list-screen-both-roles">1. HomeworkListCubit (list screen, both roles)</a></h2> <pre class="mermaid">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) </pre> <ul> <li><strong>State:</strong> <code>{status, items[], classId, role}</code>.</li> <li><code>items</code> = <code>HomeworkSummary[]</code> (doc → model: <code>id, teacherId, classId, subjectId, title, description, attachments, assignedDate, dueDate, status</code>).</li> <li><strong>Student variant:</strong> <code>classId</code> from <code>StudentProfileCubit</code> (Students module, cached 24 h); emits <code>myState</code> per card lazily via per-card <code>GET /homework/:id/submissions</code> filter by own <code>studentId</code> <code>(proposed)</code> — OQ-6.</li> <li><strong>Caching:</strong> list cache key <code>hw:list:{tenant}:{classId}</code>, TTL 5 min, stale-while-revalidate (<code>00-shared/06 §3.3</code>); <code>RefreshIndicator</code> bypasses.</li> <li>Events: <code>Load</code>, <code>Refresh</code>, <code>ChangeClass(classId)</code>, <code>Retry</code>, <code>Delete(id)</code> → <code>{status}</code>. Delete is server-confirmed; on 200 remove from <code>items</code>.</li> </ul> <h2 id="2-homeworkdetailcubit"><a class="header" href="#2-homeworkdetailcubit">2. HomeworkDetailCubit</a></h2> <ul> <li><strong>State:</strong> <code>{status, homework?, mySubmission? (student), submissionsCount? (teacher)}</code>.</li> <li>Load: <code>GET /homework/:id</code> → doc; teacher additionally fetches <code>GET /homework/:id/submissions</code> (count). Student fetches submissions and filters own row (<code>studentId</code> from profile).</li> <li>Events: <code>Load(id)</code>, <code>Refresh</code>, <code>EditDone</code> (re-fetch after PATCH), <code>SubmitDone</code> (replace <code>mySubmission</code> with server doc).</li> <li><strong>Late flag</strong> derived: <code>submittedAt > dueDate</code> (client-computed; OQ-1).</li> <li>No cache (volatile); re-fetch on focus (grade may have landed elsewhere).</li> </ul> <h2 id="3-homeworkformcubit-create--edit"><a class="header" href="#3-homeworkformcubit-create--edit">3. HomeworkFormCubit (create + edit)</a></h2> <ul> <li><strong>State:</strong> <code>{mode, form{title, description, classId, subjectId, dueDate, status?}, attachmentUploads: Map<fileKey, UploadState>, status: idle|submitting|error(field?) }</code>.</li> <li><code>UploadState</code>: <code>{idle, uploading(progress), uploaded(fileId), failed}</code> — one per file; only <code>uploaded</code> ids enter <code>attachments[]</code> on submit.</li> <li>Create submit → <code>POST /homework</code> → success → clear draft → navigate detail.</li> <li>Edit submit → <code>PATCH /homework/:id</code> → refresh detail.</li> <li>Errors: 400 → <code>fieldErrors</code> (map <code>details[].message</code> → field); 5xx → <code>error</code> (form preserved). Draft persisted locally on abandon <code>(proposed)</code>.</li> <li><strong>No optimistic writes</strong> anywhere in this cubit.</li> </ul> <h2 id="4-submitcubit-submission-form"><a class="header" href="#4-submitcubit-submission-form">4. SubmitCubit (submission form)</a></h2> <pre class="mermaid">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) </pre> <ul> <li><strong>Events:</strong> <code>AddFile</code>, <code>RetryFile</code>, <code>RemoveFile</code>, <code>Submit(remarks, attachments)</code>.</li> <li><strong>Key decision:</strong> <code>duplicate</code> is a <em>terminal success-like</em> state — navigate to detail with info banner (<code>homework.service.ts:92</code> semantics).</li> <li>Late-warning computed from <code>homework.dueDate</code> when <code>now > dueDate</code> (banner, never blocks).</li> <li>No optimistic write; <code>submittedAt</code> from server doc (<code>homework.service.ts:96</code>).</li> </ul> <h2 id="5-submissionscubit-teacher-list"><a class="header" href="#5-submissionscubit-teacher-list">5. SubmissionsCubit (teacher list)</a></h2> <ul> <li><strong>State:</strong> <code>{status, items[], homeworkTitle, grouped{ungraded[], graded[]}}</code>.</li> <li>Load: <code>GET /homework/:id/submissions</code> → map docs (<code>studentId, submittedAt, status, marks?, remarks?, attachments, gradedAt?</code>); client groups ungraded (<code>status='submitted'</code>) / graded (<code>status='graded'</code>) and computes <code>isLate</code>.</li> <li>Events: <code>Load</code>, <code>Refresh</code>, <code>GradeDone(submissionId, updatedDoc)</code> → replace item + regroup.</li> <li>No pagination (array).</li> </ul> <h2 id="6-gradecubit-grading-sheet"><a class="header" href="#6-gradecubit-grading-sheet">6. GradeCubit (grading sheet)</a></h2> <ul> <li><strong>State:</strong> <code>{status: idle|grading|done|error, current: SubmissionGradeModel?, form{marks, remarks}, isRegrade}</code>.</li> <li><code>isRegrade = current.status == 'graded'</code> (schema enum <code>homework-submission.schema.ts:24</code>).</li> <li>Submit → <code>PATCH …/grade</code> → 200 doc → <code>done</code> → pop with result → <code>SubmissionsCubit</code> applies (<code>GradeDone</code>).</li> <li><strong>Optimistic vs server-confirmed:</strong> grading is <strong>server-confirmed by design</strong> — the row's <code>marks/status/gradedAt</code> come only from the 200 payload; no local paint-before- write (event <code>HomeworkGraded</code> has side effects: notifications). Rationale: <code>00-shared/07 §9</code> write-with-side-effects rule.</li> <li>Errors: 404 → <code>gone</code> (pop + refresh); 400 → field; 5xx → keep form.</li> </ul> <h2 id="7-attachment-upload-shared-mixin"><a class="header" href="#7-attachment-upload-shared-mixin">7. Attachment upload (shared mixin)</a></h2> <pre class="mermaid">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[] </pre> <ul> <li>Progress via dio <code>onSendProgress</code>; <code>uploading</code> count gates the Submit/Assign button (<code>submitEnabled = no uploading && no failed</code>).</li> </ul> <h2 id="8-realtime--cross-cutting-interplay"><a class="header" href="#8-realtime--cross-cutting-interplay">8. Realtime & cross-cutting interplay</a></h2> <ul> <li><strong>WS:</strong> no homework topic in <code>00-shared/07 §8</code> — subscribe to <code>notification.new</code> <code>(planned)</code>; on reconnect re-fetch current screen (<code>00-shared/06 §3.4</code>).</li> <li><code>ConnectivityCubit</code>: offline → list from cache + banner; submit/grade/create blocked with guidance (no offline write queue defined for homework writes — only uploads retry).</li> <li><code>AuthCubit</code> session expiry mid-flow → re-login → screen re-loads (state preserved where safe).</li> </ul> <h2 id="9-testing-hooks-00-shared06-6-1"><a class="header" href="#9-testing-hooks-00-shared06-6-1">9. Testing hooks (<code>00-shared/06 §6</code>)</a></h2> <ul> <li>Unit: <code>SubmitCubit</code> 409→duplicate transition; <code>GradeCubit</code> regrade branch; <code>LateBadge</code> derivation (timezone-safe); upload state machine.</li> <li>Widget: list loading/error/empty; submit states incl. duplicate banner; grade sheet error keeps values; regrade warning visible.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-homework-module"><a class="header" href="#14--qa-checklist-homework-module">14 — QA Checklist (Homework Module)</a></h1> <ul> <li><a href="homework/14_QA_Checklist.html#1-functional-server-contract-mirrors">1. Functional (server-contract mirrors)</a></li> <li><a href="homework/14_QA_Checklist.html#2-visual--layout">2. Visual / layout</a></li> <li><a href="homework/14_QA_Checklist.html#3-accessibility-00-shared09">3. Accessibility (00-shared/09)</a></li> <li><a href="homework/14_QA_Checklist.html#4-performance">4. Performance</a></li> <li><a href="homework/14_QA_Checklist.html#5-offline--network-00-shared10-2">5. Offline & network (00-shared/10 §2)</a></li> <li><a href="homework/14_QA_Checklist.html#6-tablet--desktop">6. Tablet / desktop</a></li> <li><a href="homework/14_QA_Checklist.html#7-localization">7. Localization</a></li> <li><a href="homework/14_QA_Checklist.html#8-permissions">8. Permissions</a></li> <li><a href="homework/14_QA_Checklist.html#9-dark-mode">9. Dark mode</a></li> <li><a href="homework/14_QA_Checklist.html#10-animations">10. Animations</a></li> <li><a href="homework/14_QA_Checklist.html#11-security">11. Security</a></li> <li><a href="homework/14_QA_Checklist.html#12-server-backed-edge-mirrors">12. Server-backed edge mirrors</a></li> <li><a href="homework/14_QA_Checklist.html#qa-scripts">QA scripts</a></li> </ul> <blockquote> <p>Module QA specific to framework + shared <a href="homework/../00-shared/10_QA_Baseline.html">00-shared/10</a>. Every check derives from a specific backend behavior so failure analysis is direct.</p> </blockquote> <hr /> <h2 id="1-functional-server-contract-mirrors-1"><a class="header" href="#1-functional-server-contract-mirrors-1">1. Functional (server-contract mirrors)</a></h2> <ul> <li><input disabled="" type="checkbox"/> Create homework with all fields → 201 doc with <code>status:'active'</code>, <code>assignedDate</code> set (<code>homework.service.ts:30-32</code>); e2e baseline <code>test/p1-operations.e2e-spec.ts:285-301</code>.</li> <li><input disabled="" type="checkbox"/> Create without <code>title</code>/<code>dueDate</code>/non-MongoId refs → 400 <code>VALIDATION_ERROR</code> (<code>homework.dto.ts:4-33</code>).</li> <li><input disabled="" type="checkbox"/> Class list returns array sorted <code>dueDate</code> desc (<code>homework.repository.ts:20</code>).</li> <li><input disabled="" type="checkbox"/> Detail: known id → 200; unknown → 404 "Homework not found." (<code>homework.service.ts:47</code>).</li> <li><input disabled="" type="checkbox"/> Update: only <code>title/description/attachments/dueDate/status</code> accepted; <code>classId</code>/<code>subjectId</code>/<code>teacherId</code> in PATCH body are <strong>ignored</strong> (not in DTO, <code>homework.dto.ts:35-58</code>) — verify no crash.</li> <li><input disabled="" type="checkbox"/> Delete → 200; the doc disappears from class list (soft delete + <code>isDeleted</code> filter, <code>base.repository.ts:68-74</code>); submissions remain in DB (no cascade).</li> <li><input disabled="" type="checkbox"/> <strong>Due-date/timezone:</strong> create with <code>dueDate</code> ISO incl. timezone offset; list shows correct local date. Submit <strong>after</strong> dueDate → 201 (server accepts — OQ-1); client late badge shows. Submit before due → no late badge.</li> <li><input disabled="" type="checkbox"/> <strong>Duplicate submission:</strong> second <code>POST /homework/:id/submit</code> for same (studentId, homeworkId) → 409 <code>DUPLICATE_RESOURCE</code> "Already submitted." (<code>homework.service.ts:92</code>); also covered by unique index (<code>homework-submission.schema.ts:37-40</code>) — verify direct DB insert is rejected.</li> <li><input disabled="" type="checkbox"/> <strong>Resubmission rules:</strong> no resubmit path exists — after 409 the UI must show the info state, never allow editing the submission (OQ-1 note).</li> <li><input disabled="" type="checkbox"/> <strong>Regrading:</strong> grade twice → 200 both times; second call overwrites marks, resets <code>gradedAt</code>, emits second <code>HomeworkGraded</code> (<code>homework.service.ts:127-143</code>); UI shows regrade warning.</li> <li><input disabled="" type="checkbox"/> Grade unknown submission → 404 "Submission not found." (<code>homework.service.ts:126</code>).</li> <li><input disabled="" type="checkbox"/> <strong>Marks edge:</strong> server accepts <code>-5</code> and <code>9999</code> (no decorator, <code>submission.dto.ts:20-21</code> — OQ-4); client must reject/limit; verify client is the only guard today.</li> <li><input disabled="" type="checkbox"/> <code>status:'closed'</code> homework: submit and grade still succeed server-side (no enforcement — OQ-3); UI communicates closure but doesn't claim blocking.</li> <li><input disabled="" type="checkbox"/> <strong>Attachment limits:</strong> <code>POST /files/upload</code> has no size/mime limit in code (<code>files.controller.ts:29-41</code>) — client enforces (max 25 MB, doc types); verify oversized file → client-side rejection message.</li> <li><input disabled="" type="checkbox"/> Attachment download returns stream with correct <code>Content-Disposition</code> (<code>files.controller.ts:55-64</code>); deleted file → 404 → snackbar.</li> <li><input disabled="" type="checkbox"/> <strong>In-app notification for homework events:</strong> <code>event-queue-map.ts:22-25</code> routes exist; <strong>known gap:</strong> <code>NotificationType</code> enum lacks the event types (<code>notification.schema.ts:7-12</code>) → notification create fails validation (<code>inapp.worker.ts:46-53</code>). QA: assert queue job lands; mark notification persistence as <code>(planned)</code> until enum fix (OQ-8).</li> </ul> <h2 id="2-visual--layout-3"><a class="header" href="#2-visual--layout-3">2. Visual / layout</a></h2> <ul> <li><input disabled="" type="checkbox"/> Cards/badges at 200% text scale no clipping (title maxLines 2, meta wraps).</li> <li><input disabled="" type="checkbox"/> Dark-light parity: status chips, late badge, grade card, upload progress.</li> <li><input disabled="" type="checkbox"/> Marks in <code>mono</code> tabular figures align in lists.</li> <li><input disabled="" type="checkbox"/> Long titles + long file names wrap gracefully; <code>SelectableText</code> on detail.</li> </ul> <h2 id="3-accessibility-00-shared09-3"><a class="header" href="#3-accessibility-00-shared09-3">3. Accessibility (<a href="homework/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>)</a></h2> <ul> <li><input disabled="" type="checkbox"/> TalkBack: list → detail → submit flow; grade flow; attachment upload announced ("Uploading Algebra.pdf, 45%").</li> <li><input disabled="" type="checkbox"/> VoiceOver separately; NVDA on web/desktop.</li> <li><input disabled="" type="checkbox"/> Keyboard-only: create form tab order, <code>Ctrl+Enter</code> submit, <code>Esc</code> closes sheets.</li> <li><input disabled="" type="checkbox"/> Late badge readable as text, not color-only.</li> <li><input disabled="" type="checkbox"/> Errors announced on focus; focus to first invalid field on submit.</li> <li><input disabled="" type="checkbox"/> Contrast 4.5:1 body / 3:1 chrome both modes.</li> </ul> <h2 id="4-performance-3"><a class="header" href="#4-performance-3">4. Performance</a></h2> <ul> <li><input disabled="" type="checkbox"/> List first frame < 300 ms cached; network content ≤ 2 s (<a href="homework/../00-shared/10_QA_Baseline.html">00-shared/10 §1</a>).</li> <li><input disabled="" type="checkbox"/> No duplicate parallel requests: detail (teacher) fetches homework + submissions in one <code>Future.wait</code>; student per-card submissions fetch coalesced <code>(proposed: single endpoint)</code>.</li> <li><input disabled="" type="checkbox"/> Upload of 25 MB file doesn't jank UI (progress updates throttled, <code>m-instant</code>).</li> </ul> <h2 id="5-offline--network-00-shared10-2"><a class="header" href="#5-offline--network-00-shared10-2">5. Offline & network (<a href="homework/../00-shared/10_QA_Baseline.html">00-shared/10 §2</a>)</a></h2> <ul> <li><input disabled="" type="checkbox"/> List renders cached + offline banner; pull-to-refresh blocked gracefully.</li> <li><input disabled="" type="checkbox"/> Submit/grade/create offline → blocked with guidance; draft preserved.</li> <li><input disabled="" type="checkbox"/> Network loss mid-upload → per-file pending/retry, no silent corruption.</li> <li><input disabled="" type="checkbox"/> Network loss after submit request sent but before response → on retry, 409 path shows "Already submitted" (never duplicate server records).</li> <li><input disabled="" type="checkbox"/> 429 (rapid actions/uploads) → countdown, no auto-retry.</li> </ul> <h2 id="6-tablet--desktop-3"><a class="header" href="#6-tablet--desktop-3">6. Tablet / desktop</a></h2> <ul> <li><input disabled="" type="checkbox"/> Master-detail list+detail ≥ 840 dp; grade sheet split view ≥ 1200 dp.</li> <li><input disabled="" type="checkbox"/> Hover states on cards/rows; right-click menu == long-press menu.</li> <li><input disabled="" type="checkbox"/> <code>N</code> shortcut (new homework) on list; <code>Tab</code> traversal in grade sheet.</li> </ul> <h2 id="7-localization-3"><a class="header" href="#7-localization-3">7. Localization</a></h2> <ul> <li><input disabled="" type="checkbox"/> All strings via i18n keys (<code>homework.*</code>); date/time via <code>Intl</code> locale; due-date formatting respects org locale/timezone.</li> <li><input disabled="" type="checkbox"/> Long German/Hindi/Spanish smoke strings don't break badges/cards.</li> </ul> <h2 id="8-permissions-3"><a class="header" href="#8-permissions-3">8. Permissions</a></h2> <ul> <li><input disabled="" type="checkbox"/> Homework endpoints currently JWT-only (<code>homework.controller.ts:19</code>) — verify cross-role calls succeed server-side; client role-gates UI (OQ-9).</li> <li><input disabled="" type="checkbox"/> <code>file.upload</code>/<code>file.read</code> required on files endpoints (<code>files.controller.ts:30,44</code>): a JWT without these perms gets 403 <code>PERMISSION_DENIED</code> → upload disabled in UI.</li> <li><input disabled="" type="checkbox"/> When <code>homework.*</code> perms land (blueprint Homework.md:66-71), re-run matrix: create/update/delete/submit/grade gating.</li> </ul> <h2 id="9-dark-mode-3"><a class="header" href="#9-dark-mode-3">9. Dark mode</a></h2> <ul> <li><input disabled="" type="checkbox"/> Full visual pass: badges, grade card, upload tiles, dialogs; contrast re-checked.</li> </ul> <h2 id="10-animations-3"><a class="header" href="#10-animations-3">10. Animations</a></h2> <ul> <li><input disabled="" type="checkbox"/> Tokens only; reduced-motion = fades/instants; no shake on duplicate-409.</li> </ul> <h2 id="11-security-2"><a class="header" href="#11-security-2">11. Security</a></h2> <ul> <li><input disabled="" type="checkbox"/> No homework/student data in logs/analytics beyond event payloads (already minimal: ids only, <code>homework.service.ts:40,65,79,105-109,142</code>).</li> <li><input disabled="" type="checkbox"/> Uploads never auto-preview executable types; download opens via share sheet.</li> <li><input disabled="" type="checkbox"/> <code>studentId</code> in 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).</li> </ul> <h2 id="12-server-backed-edge-mirrors-3"><a class="header" href="#12-server-backed-edge-mirrors-3">12. Server-backed edge mirrors</a></h2> <ul> <li><input disabled="" type="checkbox"/> 404 after homework deleted → empty state "removed" (detail + submissions).</li> <li><input disabled="" type="checkbox"/> CastError on malformed id → 400 <code>VALIDATION_ERROR</code> "Invalid resource identifier." (<code>http-exception.filter.ts:47-55,91-92</code>).</li> <li><input disabled="" type="checkbox"/> Double-tap submit → single server record + one <code>HomeworkSubmitted</code> job (queue idempotency <code>queue-bridge.service.ts:44-46</code>).</li> </ul> <hr /> <h2 id="qa-scripts-6"><a class="header" href="#qa-scripts-6">QA scripts</a></h2> <ul> <li>Golden per component (light/dark × phone/tablet/desktop × key states).</li> <li>E2E (device cloud P0): teacher creates w/ attachment → student submits (with file) → teacher grades → student sees grade → duplicate submit shows info state.</li> <li>Upload matrix: 0 B file, 25 MB boundary, wrong mime, mid-flight network kill, retry.</li> <li>Notification pipeline: enqueue <code>HomeworkGraded</code> → assert in-app job; monitor enum gap (OQ-8).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-homework-module"><a class="header" href="#15--flutter-implementation-guide-homework-module">15 — Flutter Implementation Guide (Homework Module)</a></h1> <ul> <li><a href="homework/15_Flutter_Implementation_Guide.html#1-folder-structure">1. Folder structure</a></li> <li><a href="homework/15_Flutter_Implementation_Guide.html#2-dependencies">2. Dependencies</a></li> <li><a href="homework/15_Flutter_Implementation_Guide.html#3-cubits">3. Cubits</a></li> <li><a href="homework/15_Flutter_Implementation_Guide.html#4-homeworkrepository">4. HomeworkRepository</a></li> <li><a href="homework/15_Flutter_Implementation_Guide.html#5-navigation">5. Navigation</a></li> <li><a href="homework/15_Flutter_Implementation_Guide.html#6-upload-progress-ui-appattachmentuploader">6. Upload progress UI (<code>AppAttachmentUploader</code>)</a></li> <li><a href="homework/15_Flutter_Implementation_Guide.html#7-markdown-description-rendering">7. Markdown description rendering</a></li> <li><a href="homework/15_Flutter_Implementation_Guide.html#8-late-badge-derivation-timezone-safe">8. Late badge derivation (timezone-safe)</a></li> <li><a href="homework/15_Flutter_Implementation_Guide.html#9-theme">9. Theme</a></li> <li><a href="homework/15_Flutter_Implementation_Guide.html#10-localization-keys">10. Localization keys</a></li> <li><a href="homework/15_Flutter_Implementation_Guide.html#11-testing">11. Testing</a></li> <li><a href="homework/15_Flutter_Implementation_Guide.html#12-performance">12. Performance</a></li> <li><a href="homework/15_Flutter_Implementation_Guide.html#13-proposals-flagged-to-the-team">13. Proposals flagged to the team</a></li> </ul> <blockquote> <p>How to build the Homework feature in the Flutter client on top of <a href="homework/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a>. Forward-looking spec; no client repo exists yet.</p> </blockquote> <hr /> <h2 id="1-folder-structure-7"><a class="header" href="#1-folder-structure-7">1. Folder structure</a></h2> <pre><code>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 </code></pre> <h2 id="2-dependencies-6"><a class="header" href="#2-dependencies-6">2. Dependencies</a></h2> <p><code>flutter_bloc</code>, <code>dio</code> (<code>AppDio</code> — 120 s timeout on uploads, <code>00-shared/11 §5</code>), <code>go_router</code>, <code>get_it</code>, <code>intl</code>, <code>flutter_markdown</code> (description + remarks rendering, sanitize links — <code>00-shared/03 E</code>), <code>cached_network_image</code> (avatar only; file previews download-then-render), <code>file_picker</code>/<code>image_picker</code> (pick attachments; <strong>verify license</strong> — wrap in <code>AttachmentPicker</code> so swappable), <code>path_provider</code> (temp previews). No PDF/office viewer lib in P0 — external/share-sheet fallback (verify later).</p> <h2 id="3-cubits-5"><a class="header" href="#3-cubits-5">3. Cubits</a></h2> <p>Per <code>13_State_Management.md</code>: <code>HomeworkListCubit</code>, <code>HomeworkDetailCubit</code>, <code>HomeworkFormCubit</code>, <code>SubmitCubit</code>, <code>SubmissionsCubit</code>, <code>GradeCubit</code>. All pure-Dart, repositories injected; UI never calls dio directly.</p> <h2 id="4-homeworkrepository"><a class="header" href="#4-homeworkrepository">4. HomeworkRepository</a></h2> <pre><code class="language-dart">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 } </code></pre> <p><code>FileRepository.upload(File, onProgress)</code> → <code>HomeworkFile</code> (id) via <code>POST /files/upload</code> (<code>file.upload</code> perm); <code>download(id)</code> → bytes via <code>GET /files/:id/download</code> (<code>file.read</code>). DTO mapping per <code>00-shared/11 §4</code>; envelope parsing in <code>AppDio</code> error interceptor.</p> <h2 id="5-navigation-3"><a class="header" href="#5-navigation-3">5. Navigation</a></h2> <p>go_router routes (under the <code>/homework</code> shell branch, <code>00-shared/05 §2</code>): <code>/homework</code> (list), <code>/homework/new</code>, <code>/homework/:id</code>, <code>/homework/:id/edit</code>, <code>/homework/:id/submit</code>, <code>/homework/:id/submissions</code>, <code>/homework/:id/submissions/:submissionId</code>. Guards: <code>authGuard</code>; role-gated <strong>route meta</strong> <code>canCreate</code>/<code>canGrade</code> — hidden at navigation level (FAB, menu) and enforced by <code>permissionGuard('homework.create')</code> style checks once server perms land (OQ-9). Deep link: <code>studylyon://homework/:id</code> → detail (root <code>Uri</code> table in <code>00-shared/11 §6</code>).</p> <h2 id="6-upload-progress-ui-appattachmentuploader"><a class="header" href="#6-upload-progress-ui-appattachmentuploader">6. Upload progress UI (<code>AppAttachmentUploader</code>)</a></h2> <ul> <li><code>dio</code> <code>onSendProgress</code> → <code>UploadState.uploading(progress)</code> → <code>LinearProgressIndicator</code> determinate; throttle widget rebuilds (updates ≤ 60/s via <code>ValueNotifier<double></code>).</li> <li>States per file: <code>idle → uploading → uploaded(fileId) | failed(reason)</code>; retry re-POSTs (no resume — <code>00-shared/12 B7</code>).</li> <li>Gate: Submit/Assign disabled while <code>uploading > 0 || failed > 0</code> (failed shows inline error + Retry, never silent).</li> <li>a11y: <code>Semantics(liveRegion: true, label: "Uploading {name}, {percent}%")</code>.</li> <li>25 MB client cap; error copy "File too large (max 25 MB)" (server has no limit — OQ-4/01; update when server adds one).</li> <li>Offline mid-upload → file keeps <code>failed(network)</code>; on reconnect auto-retry <code>(proposed)</code>.</li> </ul> <h2 id="7-markdown-description-rendering"><a class="header" href="#7-markdown-description-rendering">7. Markdown description rendering</a></h2> <ul> <li><code>AppMarkdownViewer(data: homework.description)</code> — <code>flutter_markdown</code> wrapped (<code>00-shared/03 E</code>): sanitize links (allow http/https only), <code>selectable: true</code>, compact style for remarks, full style for description; empty → hide block.</li> <li>Server stores plain text (<code>description: String?</code>, <code>homework.schema.ts:21-22</code>) — no backend rendering; client-side only.</li> </ul> <h2 id="8-late-badge-derivation-timezone-safe"><a class="header" href="#8-late-badge-derivation-timezone-safe">8. Late badge derivation (timezone-safe)</a></h2> <ul> <li><code>isLate(DateTime dueDate, DateTime submittedAt)</code> compared in <strong>tenant timezone</strong> (from org config; default device). Compute at render time, never cache the boolean in the model (due date is server UTC ISO — parse with <code>DateTime.parse(...).toLocal()</code> via <code>AppDateTime</code> util, <code>00-shared/11 §8</code>).</li> <li>Server has no late flag (OQ-1) — badge is derived UI; when the server adds <code>isLate</code>/overdue scheduler, prefer server value.</li> </ul> <h2 id="9-theme-1"><a class="header" href="#9-theme-1">9. Theme</a></h2> <p><code>AppTheme.light()/dark()</code> unchanged; module adds no tokens — all surfaces use role colors (<code>11_Design_System_Mapping.md</code>). <code>AppSpacing</code>/<code>AppRadius</code>/<code>AppMotion</code> constants only.</p> <h2 id="10-localization-keys"><a class="header" href="#10-localization-keys">10. Localization keys</a></h2> <p><code>homework.list.title</code>, <code>homework.list.empty</code>, <code>homework.create.title</code>, <code>homework.submit.title</code>, <code>homework.submit.already_submitted</code>, <code>homework.grade.title</code>, <code>homework.grade.regrade_warning</code>, <code>homework.late.badge</code> ("Late · {d}d"), <code>homework.status.*</code> (active/closed/submitted/graded), <code>homework.attachment.*</code> (uploading/too_large/retry), error-code fallbacks per <code>00-shared/07 §11</code>.</p> <h2 id="11-testing"><a class="header" href="#11-testing">11. Testing</a></h2> <ul> <li><strong>Unit:</strong> <code>SubmitCubit</code> 409→duplicate; <code>GradeCubit</code> regrade; <code>lateBadge</code> timezone (UTC boundary cases); DTO↔model mappers; upload state machine.</li> <li><strong>Widget:</strong> list loading/error/empty; detail role variants (student/teacher); submit duplicate banner; grade sheet error keeps values; uploader states.</li> <li><strong>Golden:</strong> <code>HomeworkCard</code>, <code>SubmissionRow</code>, <code>LateBadge</code>, <code>AttachmentUploader</code>, <code>GradeSheetBody</code> (light/dark × 3 sizes).</li> <li><strong>Integration:</strong> teacher create → student submit → grade → feedback; duplicate submit.</li> <li><strong>E2E (P0):</strong> full loop with real upload on device cloud (<code>14_QA_Checklist.md</code>).</li> </ul> <h2 id="12-performance"><a class="header" href="#12-performance">12. Performance</a></h2> <ul> <li><code>ListView.builder</code> for lists; <code>RepaintBoundary</code> around markdown/PDF previews.</li> <li>Grade sheet: <code>Future.wait</code> homework+submissions on open (single skeleton).</li> <li>Upload progress via <code>ValueNotifier</code>, not setState.</li> <li><code>const</code> constructors; cache student profile classId.</li> </ul> <h2 id="13-proposals-flagged-to-the-team"><a class="header" href="#13-proposals-flagged-to-the-team">13. Proposals flagged to the team</a></h2> <ol> <li>When server adds <code>GET /homework</code> (PLAN.md:67 student endpoint), drop the per-card submissions hack (OQ-6).</li> <li>When <code>homework.*</code> permissions land, wire route/FAB/menu gating to real perms (OQ-9).</li> <li>When <code>NotificationType</code> enum accepts homework events (OQ-8), enable notification deep-link navigation <code>studylyon://homework/:id</code>.</li> <li>When parent view lands (<code>IMPLEMENTATION_PLAN.md:227</code>), add read-only parent mode.</li> <li>Analytics wiring waits shared <code>AnalyticsService</code> (<code>00-shared/10 §8</code>).</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="01---product-overview-houses-module"><a class="header" href="#01---product-overview-houses-module">01 - Product Overview (Houses Module)</a></h1> <ul> <li><a href="houses/01_Product_Overview.html#1-purpose">1. Purpose</a></li> <li><a href="houses/01_Product_Overview.html#2-business-goals">2. Business goals</a></li> <li><a href="houses/01_Product_Overview.html#3-user-goals">3. User goals</a></li> <li><a href="houses/01_Product_Overview.html#4-scope">4. Scope</a> <ul> <li><a href="houses/01_Product_Overview.html#41-in-scope-implemented-backend">4.1 In scope (implemented backend)</a></li> <li><a href="houses/01_Product_Overview.html#42-planned-not-in-source-absent-from-implementation_planmd">4.2 Planned (not in source; absent from IMPLEMENTATION_PLAN.md)</a></li> <li><a href="houses/01_Product_Overview.html#43-forward-looking-client-roadmap">4.3 Forward-looking (client roadmap)</a></li> <li><a href="houses/01_Product_Overview.html#44-proposed-analytics">4.4 Proposed (analytics)</a></li> </ul> </li> <li><a href="houses/01_Product_Overview.html#5-non-goals-this-version">5. Non-goals (this version)</a></li> </ul> <blockquote> <p>StudyLyon - multi-tenant ERP / School Management API. This package designs the <strong>Houses module client</strong> (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, permissions and wire contracts are derived directly from <code>src/modules/houses/**</code>, <code>src/modules/students/**</code>, <code>src/modules/rbac/permissions.constants.ts</code>, <code>docs/IMPLEMENTATION_PLAN.md</code> and the shared spec in <code>design-docs/00-shared/</code>. No feature is invented - anything not present in source is flagged <code>(planned)</code> / <code>(proposed)</code> / <code>(forward-looking)</code>.</p> </blockquote> <p>Heads-up: per the PRD, the mobile client is <strong>out of Phase 1</strong> scope (<code>PRODUCT_REQUIREMENTS_DOCUMENT.md:144</code>, flagged in <a href="houses/../00-shared/12_Assumptions_&_Open_Questions.html">00-shared/12</a> 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.</p> <hr /> <h2 id="1-purpose-13"><a class="header" href="#1-purpose-13">1. Purpose</a></h2> <p>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:</p> <ul> <li><strong>House CRUD</strong> - name, code, color, motto (no mascot / house master / points yet).</li> <li><strong>Student assignment</strong> - a student points at a house via <code>houseId</code>; there is no house-side assignment endpoint (see §3.2).</li> <li><strong>Tenant-scoped, soft-deletable</strong> documents with a unique <code>{tenantId, code}</code> index.</li> </ul> <div class="table-wrapper"><table><thead><tr><th>Responsibility</th><th>Source</th></tr></thead><tbody> <tr><td>House CRUD + soft delete</td><td><code>houses.controller.ts:24-52</code>, <code>houses.service.ts:18-54</code></td></tr> <tr><td>Unique code guard (409)</td><td><code>houses.service.ts:19-21</code>; index <code>house.schema.ts:23</code></td></tr> <tr><td>Tenant scoping + soft-delete on every query</td><td><code>base.repository.ts</code> (via <code>house.repository.ts:9-15</code>)</td></tr> <tr><td>JWT guard on all endpoints</td><td><code>houses.controller.ts:19-20</code></td></tr> <tr><td>Student → house reference</td><td><code>student.schema.ts:41-42</code></td></tr> <tr><td>House assignment DTO fields</td><td><code>create-student.dto.ts:39-42</code>, <code>update-student.dto.ts:45-48</code></td></tr> <tr><td>RBAC permissions</td><td><code>permissions.constants.ts:46-49</code></td></tr> </tbody></table> </div> <h2 id="2-business-goals-12"><a class="header" href="#2-business-goals-12">2. Business goals</a></h2> <div class="table-wrapper"><table><thead><tr><th>Goal</th><th>Measure</th></tr></thead><tbody> <tr><td>No duplicate house codes per tenant</td><td>unique <code>{tenantId, code}</code> index + service 409 (<code>house.schema.ts:23</code>, <code>houses.service.ts:19-21</code>)</td></tr> <tr><td>Every house identifiable by color</td><td><code>color</code> field optional (<code>house.schema.ts:15-16</code>) - no uniqueness enforced</td></tr> <tr><td>Cross-tenant isolation</td><td>every query tenant-scoped via <code>BaseRepository.scopedFilter</code></td></tr> <tr><td>Houses survive deletion, not vanish</td><td>soft delete only (<code>houses.service.ts:51-54</code>, <code>base.schema.ts:19-27</code>)</td></tr> </tbody></table> </div> <h2 id="3-user-goals-10"><a class="header" href="#3-user-goals-10">3. User goals</a></h2> <ul> <li><strong>School / house admin:</strong> create and maintain the house roster (name, code, color, motto); keep the palette unambiguous so inter-house UI stays readable.</li> <li><strong>Registrar:</strong> assign a house to a student at admission (<code>houseId</code> in <code>create-student.dto.ts:39-42</code>) and reassign later (<code>update-student.dto.ts:45-48</code>).</li> <li><strong>Teacher / student / parent:</strong> (forward-looking) see which house a student belongs to and per-house member lists - no dedicated read surface exists in source today.</li> </ul> <h2 id="4-scope-1"><a class="header" href="#4-scope-1">4. Scope</a></h2> <h3 id="41-in-scope-implemented-backend-1"><a class="header" href="#41-in-scope-implemented-backend-1">4.1 In scope (implemented backend)</a></h3> <p>House CRUD: create (<code>POST /houses</code>), paginated list (<code>GET /houses?page=1&limit=20</code>), get by id (<code>GET /houses/:id</code>), update (<code>PATCH /houses/:id</code>), soft delete (<code>DELETE /houses/:id</code>) - <code>houses.controller.ts:24-52</code>. All JWT-guarded, tenant-scoped. Student-side assignment via <code>POST /students</code> / <code>PATCH /students/:id</code> (<code>student.controller.ts:38, 56</code>).</p> <h3 id="42-planned-not-in-source-absent-from-implementation_planmd"><a class="header" href="#42-planned-not-in-source-absent-from-implementation_planmd">4.2 Planned (not in source; absent from IMPLEMENTATION_PLAN.md)</a></h3> <p><code>IMPLEMENTATION_PLAN.md</code> contains <strong>no houses entries</strong>; the following are product gaps this package flags <code>(planned)</code>:</p> <ul> <li>House member list endpoint / <code>GET /students?houseId=</code> filter (see <a href="houses/09_User_Behaviour.html">09</a> §G1).</li> <li>Delete guard - deleting a house does not check assigned students (<code>houses.service.ts:51-54</code>; <code>student.schema.ts:41-42</code> dangling ref).</li> <li>House mascot, house master (staff lead), points ledger - none in <code>house.schema.ts:9-19</code>.</li> <li>Domain events (<code>HouseCreated</code>, <code>StudentHouseAssigned</code>) - houses service emits none (contrast <code>student.service.ts:79-91</code>).</li> </ul> <h3 id="43-forward-looking-client-roadmap-1"><a class="header" href="#43-forward-looking-client-roadmap-1">4.3 Forward-looking (client roadmap)</a></h3> <p>Push notifications on house events (inter-house results) and QR-based member check-in <code>(forward-looking)</code> - no contract exists anywhere in source.</p> <h3 id="44-proposed-analytics-1"><a class="header" href="#44-proposed-analytics-1">4.4 Proposed (analytics)</a></h3> <p>Screen analytics events <code>houses.*.*</code> per <a href="houses/../00-shared/10_QA_Baseline.html">00-shared/10</a> §8 - <code>(proposed)</code>.</p> <h2 id="5-non-goals-this-version-1"><a class="header" href="#5-non-goals-this-version-1">5. Non-goals (this version)</a></h2> <ul> <li>House points / inter-house leaderboard (no field or endpoint).</li> <li>Bulk import of houses (Phase 3 bulk import, <code>IMPLEMENTATION_PLAN.md:172</code>).</li> <li>Membership management inside the houses API - assignment is exclusively a student concern (<code>student.schema.ts:41-42</code>).</li> <li>Color uniqueness enforcement - duplicates are legal at the API level (QA item, <a href="houses/14_QA_Checklist.html">14</a> §2).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="02---user-personas-houses-module"><a class="header" href="#02---user-personas-houses-module">02 - User Personas (Houses Module)</a></h1> <ul> <li><a href="houses/02_User_Personas.html#1-house-admin-school-admin--sports--culture-lead">1. House Admin (school admin / sports & culture lead)</a></li> <li><a href="houses/02_User_Personas.html#2-registrar--admissions-officer">2. Registrar / Admissions Officer</a></li> <li><a href="houses/02_User_Personas.html#3-teacher--staff-house-tutor-forward-looking">3. Teacher / Staff (house tutor, forward-looking)</a></li> <li><a href="houses/02_User_Personas.html#4-student-forward-looking">4. Student (forward-looking)</a></li> <li><a href="houses/02_User_Personas.html#5-parent-forward-looking">5. Parent (forward-looking)</a></li> <li><a href="houses/02_User_Personas.html#persona-role-matrix">Persona-role matrix</a></li> </ul> <blockquote> <p>Who uses the Houses module, what they need and what the backend actually gives them. Personas map to the screens in <a href="houses/05_Screen_Inventory.html">05</a> and the journeys in <a href="houses/03_User_Journey.html">03</a>. Roles are illustrative; the only enforcement today is JWT auth (<code>houses.controller.ts:19-20</code>)</p> <ul> <li>RBAC guards are not yet wired (<code>AGENTS.md</code>), even though <code>houses.*</code> permissions exist (<code>permissions.constants.ts:46-49</code>).</li> </ul> </blockquote> <hr /> <h2 id="1-house-admin-school-admin--sports--culture-lead"><a class="header" href="#1-house-admin-school-admin--sports--culture-lead">1. House Admin (school admin / sports & culture lead)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Goal</td><td>Maintain the house roster: name, code, color, motto; keep colors distinct for inter-house UI</td></tr> <tr><td>Frequency</td><td>Setup season (start of year), occasional edits</td></tr> <tr><td>Permissions</td><td><code>houses.read/create/update/delete</code> (<code>permissions.constants.ts:46-49</code>)</td></tr> <tr><td>Core screens</td><td>House list, house editor (<a href="houses/05_Screen_Inventory.html">05</a> §1-3)</td></tr> <tr><td>Frustrations</td><td>No mascot / house-master / points fields exist yet - must wait <code>(planned)</code>; duplicate-name protection absent (<code>houses.service.ts:19-21</code> checks only <code>code</code>)</td></tr> <tr><td>Success metric</td><td>Full house roster created in one session; no duplicate codes</td></tr> </tbody></table> </div> <h2 id="2-registrar--admissions-officer"><a class="header" href="#2-registrar--admissions-officer">2. Registrar / Admissions Officer</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Goal</td><td>Assign a house to a student at admission; reassign after transfers or house changes</td></tr> <tr><td>Frequency</td><td>Daily during admissions</td></tr> <tr><td>Permissions</td><td><code>student.create/update</code> + <code>houses.read</code> (<code>permissions.constants.ts:25-28, 46</code>)</td></tr> <tr><td>Core screens</td><td>Student create/update forms with a house picker (student module); house detail read-only (<a href="houses/05_Screen_Inventory.html">05</a> §2)</td></tr> <tr><td>Mechanism</td><td><code>houseId</code> field on <code>create-student.dto.ts:39-42</code> / <code>update-student.dto.ts:45-48</code>; <strong>no dedicated assign endpoint</strong></td></tr> <tr><td>Frustrations</td><td>No bulk assignment; no per-house member list in the houses API; transfer flow ignores house (<code>student.service.ts:185-192</code> keeps <code>houseId</code>)</td></tr> </tbody></table> </div> <h2 id="3-teacher--staff-house-tutor-forward-looking"><a class="header" href="#3-teacher--staff-house-tutor-forward-looking">3. Teacher / Staff (house tutor, forward-looking)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Goal</td><td>See which house students belong to; view member rosters; run inter-house events</td></tr> <tr><td>Frequency</td><td>Event weeks</td></tr> <tr><td>Permissions</td><td><code>houses.read</code> + <code>student.read</code></td></tr> <tr><td>Core screens</td><td>House detail with members (<a href="houses/05_Screen_Inventory.html">05</a> §2) - member list is a client-side join today, <code>(planned)</code> server support (<a href="houses/01_Product_Overview.html">01</a> §4.2)</td></tr> <tr><td>Frustrations</td><td>No points ledger, no house-master field <code>(planned)</code></td></tr> </tbody></table> </div> <h2 id="4-student-forward-looking"><a class="header" href="#4-student-forward-looking">4. Student (forward-looking)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Goal</td><td>Know their house, its color/motto; see house announcements and points</td></tr> <tr><td>Frequency</td><td>Event weeks</td></tr> <tr><td>Core screens</td><td>My-house view (client-only feature, no backend contract yet)</td></tr> <tr><td>Notes</td><td>QR check-in and push results <code>(forward-looking)</code>, <a href="houses/01_Product_Overview.html">01</a> §4.3</td></tr> </tbody></table> </div> <h2 id="5-parent-forward-looking"><a class="header" href="#5-parent-forward-looking">5. Parent (forward-looking)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Goal</td><td>See the child's house and inter-house participation</td></tr> <tr><td>Frequency</td><td>Occasional</td></tr> <tr><td>Notes</td><td>Read-only; reuses student record <code>houseId</code> (<code>student.schema.ts:41-42</code>)</td></tr> </tbody></table> </div> <hr /> <h2 id="persona-role-matrix"><a class="header" href="#persona-role-matrix">Persona-role matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th>Persona</th><th><code>houses.read</code></th><th><code>houses.create</code></th><th><code>houses.update</code></th><th><code>houses.delete</code></th><th>student assignment</th></tr></thead><tbody> <tr><td>House admin</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td><td>via student form</td></tr> <tr><td>Registrar</td><td>✅</td><td>❌ (typical)</td><td>❌</td><td>❌</td><td>✅ <code>student.update</code></td></tr> <tr><td>Teacher</td><td>✅</td><td>❌</td><td>❌</td><td>❌</td><td>❌</td></tr> <tr><td>Student / Parent</td><td>✅ (forward-looking read)</td><td>❌</td><td>❌</td><td>❌</td><td>❌</td></tr> </tbody></table> </div> <p>Client gates UI on these permissions (<code>permissions.constants.ts:46-49</code>); server-side RBAC enforcement remains a <code>(planned)</code> audit item (<code>IMPLEMENTATION_PLAN.md:239</code>).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="03---user-journeys-houses-module"><a class="header" href="#03---user-journeys-houses-module">03 - User Journeys (Houses Module)</a></h1> <ul> <li><a href="houses/03_User_Journey.html#journey-1---set-up-the-house-system-house-admin">Journey 1 - Set up the house system (house admin)</a></li> <li><a href="houses/03_User_Journey.html#journey-2---assign-houses-to-new-students-registrar">Journey 2 - Assign houses to new students (registrar)</a></li> <li><a href="houses/03_User_Journey.html#journey-3---run-inter-house-event-teacher-forward-looking">Journey 3 - Run inter-house event (teacher, forward-looking)</a></li> <li><a href="houses/03_User_Journey.html#journey-4---retire-a-house-house-admin">Journey 4 - Retire a house (house admin)</a></li> <li><a href="houses/03_User_Journey.html#journey-5---find-a-students-house-parentstudent-forward-looking">Journey 5 - Find a student's house (parent/student, forward-looking)</a></li> <li><a href="houses/03_User_Journey.html#journey-map">Journey map</a></li> </ul> <blockquote> <p>End-to-end journeys through the Houses module, mapped to exact endpoints. Each journey lists the screens (<a href="houses/05_Screen_Inventory.html">05</a>), the API calls (<a href="houses/12_API_Mapping.html">12</a>) and the state flow (<a href="houses/13_State_Management.html">13</a>). Anything that requires a capability the backend does not expose is flagged <code>(planned)</code> / <code>(forward-looking)</code>.</p> </blockquote> <hr /> <h2 id="journey-1---set-up-the-house-system-house-admin"><a class="header" href="#journey-1---set-up-the-house-system-house-admin">Journey 1 - Set up the house system (house admin)</a></h2> <p><strong>Trigger:</strong> New academic year; school wants 4 color-coded houses.</p> <ol> <li>Open Houses list (<a href="houses/05_Screen_Inventory.html">05</a> §1) → <code>HouseListCubit.load()</code> → <code>GET /houses?page=1&limit=20</code> (<code>houses.controller.ts:30-34</code>). Empty state.</li> <li>Tap <strong>New house</strong> → editor sheet (<a href="houses/05_Screen_Inventory.html">05</a> §3) → fill name, code, color, motto.</li> <li>Submit → <code>POST /houses</code> (<code>houses.controller.ts:24-28</code>). Duplicate code → 409 <code>House code "X" already exists.</code> (<code>houses.service.ts:19-21</code>) shown inline.</li> <li>Repeat for 3 more houses; list refreshes (natural <code>createdAt</code> order - backend applies no sort, <code>houses.service.ts:30</code>).</li> <li>Edit a color next day → <code>PATCH /houses/:id</code> (<code>houses.controller.ts:42-46</code>). Note: PATCH reuses <code>CreateHouseDto</code> - <code>name</code> and <code>code</code> are required in the body (<code>create-house.dto.ts:4-11</code>), so the client must send the full set.</li> </ol> <p><strong>Exit criteria:</strong> 4 houses visible with distinct color cards.</p> <h2 id="journey-2---assign-houses-to-new-students-registrar"><a class="header" href="#journey-2---assign-houses-to-new-students-registrar">Journey 2 - Assign houses to new students (registrar)</a></h2> <p><strong>Trigger:</strong> Admission batch arrives; each student needs a house.</p> <ol> <li>Create student with <code>houseId</code> → <code>POST /students</code> (<code>student.controller.ts:38</code>); field <code>houseId</code> optional <code>IsMongoId</code> (<code>create-student.dto.ts:39-42</code>).</li> <li>Verify assignment: open student → house color shown (client resolves the house via cached list from <code>GET /houses</code>, <a href="houses/13_State_Management.html">13</a> §6).</li> <li>Wrong house? Update student → <code>PATCH /students/:id</code> with <code>houseId</code> (<code>student.controller.ts:56</code>, <code>update-student.dto.ts:45-48</code>).</li> </ol> <p><strong>Exit criteria:</strong> student record shows the house; house detail member count changes after refetch (<code>(planned)</code> server filter - today the client filters the students list, <a href="houses/09_User_Behaviour.html">09</a> §G1).</p> <h2 id="journey-3---run-inter-house-event-teacher-forward-looking"><a class="header" href="#journey-3---run-inter-house-event-teacher-forward-looking">Journey 3 - Run inter-house event (teacher, forward-looking)</a></h2> <p><strong>Trigger:</strong> Sports day; teacher needs house rosters and wants to see points.</p> <ol> <li>Open house detail (<a href="houses/05_Screen_Inventory.html">05</a> §2) → <code>GET /houses/:id</code> (<code>houses.controller.ts:36-40</code>) <ul> <li>members (client join today, <code>(planned)</code> <code>GET /houses/:id/members</code>).</li> </ul> </li> <li>See per-house member counts; plan events.</li> <li>Points & leaderboard: <strong>not available</strong> - no field/endpoint <code>(planned)</code>; QR check-in <code>(forward-looking)</code>.</li> </ol> <p><strong>Exit criteria:</strong> roster correct; gap logged to backend backlog.</p> <h2 id="journey-4---retire-a-house-house-admin"><a class="header" href="#journey-4---retire-a-house-house-admin">Journey 4 - Retire a house (house admin)</a></h2> <p><strong>Trigger:</strong> House discontinued mid-year.</p> <ol> <li>Open detail → Delete → confirm dialog.</li> <li><code>DELETE /houses/:id</code> → soft delete (<code>houses.service.ts:51-54</code>, <code>base.schema.ts:19-27</code>).</li> <li><strong>Guard check:</strong> backend does NOT check assigned students (no 409) - students keep a dangling <code>houseId</code> (<code>student.schema.ts:41-42</code>) and the house vanishes from lists. Client must warn before delete and offer to clear <code>houseId</code> on members <code>(planned)</code> server-side cascade; QA coverage in <a href="houses/14_QA_Checklist.html">14</a> §4.</li> </ol> <p><strong>Exit criteria:</strong> house gone from list; admin notified of the reassignment debt.</p> <h2 id="journey-5---find-a-students-house-parentstudent-forward-looking"><a class="header" href="#journey-5---find-a-students-house-parentstudent-forward-looking">Journey 5 - Find a student's house (parent/student, forward-looking)</a></h2> <p><strong>Trigger:</strong> Parent wants to see child's house.</p> <ol> <li>Child profile → house card (from <code>student.houseId</code> → <code>GET /houses/:id</code>).</li> <li>Tap → house detail with color, motto, members (read-only).</li> </ol> <p><strong>Exit criteria:</strong> correct house shown; no write access.</p> <hr /> <h2 id="journey-map"><a class="header" href="#journey-map">Journey map</a></h2> <pre class="mermaid">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 </pre> <div style="break-before: page; page-break-before: always;"></div><h1 id="04---information-architecture-houses-module"><a class="header" href="#04---information-architecture-houses-module">04 - Information Architecture (Houses Module)</a></h1> <ul> <li><a href="houses/04_Information_Architecture.html#1-navigation-tree">1. Navigation tree</a></li> <li><a href="houses/04_Information_Architecture.html#2-entity-map">2. Entity map</a></li> <li><a href="houses/04_Information_Architecture.html#3-object-model-client">3. Object model (client)</a></li> <li><a href="houses/04_Information_Architecture.html#4-cross-module-relations">4. Cross-module relations</a></li> <li><a href="houses/04_Information_Architecture.html#5-content-hierarchy-house-detail">5. Content hierarchy (house detail)</a></li> <li><a href="houses/04_Information_Architecture.html#6-ia-gaps-flagged">6. IA gaps (flagged)</a></li> </ul> <blockquote> <p>Where Houses sits in the app information architecture. Global IA rules live in <a href="houses/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a> (Global Information Architecture); this file defines the Houses subtree only. Server truth: <code>houses.controller.ts:17-20</code> (<code>/houses</code> root, JWT-guarded, <code>ApiTags('houses')</code>).</p> </blockquote> <hr /> <h2 id="1-navigation-tree"><a class="header" href="#1-navigation-tree">1. Navigation tree</a></h2> <pre><code>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) </code></pre> <ul> <li>House create/edit is a <strong>bottom sheet (phone) / dialog (tablet)</strong>, consistent with <code>00-shared/03</code> - forms are never routes (see <code>15</code> §7).</li> <li>House detail is the only deep-linkable page: <code>/houses/:id</code>.</li> </ul> <h2 id="2-entity-map"><a class="header" href="#2-entity-map">2. Entity map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Entity</th><th>Fields</th><th>Source</th></tr></thead><tbody> <tr><td>House</td><td><code>name*</code>, <code>code*</code>, <code>color?</code>, <code>motto?</code> + BaseSchema (<code>tenantId</code>, <code>createdBy/updatedBy</code>, <code>isDeleted/deletedAt/deletedBy</code>, <code>version</code>, <code>createdAt/updatedAt</code>)</td><td><code>house.schema.ts:9-19</code>, <code>base.schema.ts:8-34</code></td></tr> <tr><td>Student (relation only)</td><td><code>houseId?</code> → <code>ref: 'House'</code> (ObjectId, optional)</td><td><code>student.schema.ts:41-42</code></td></tr> <tr><td>Pagination meta</td><td><code>page, limit, totalItems, totalPages, hasNext, hasPrevious</code></td><td><code>pagination-query.dto.ts:32-39</code></td></tr> </tbody></table> </div> <h2 id="3-object-model-client"><a class="header" href="#3-object-model-client">3. Object model (client)</a></h2> <pre><code class="language-dart">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) } </code></pre> <p>Never send <code>tenantId</code> / <code>isDeleted</code> / <code>version</code> in requests - server-owned (<code>base.schema.ts:10-11, 20-21</code>, <code>AGENTS.md</code>).</p> <h2 id="4-cross-module-relations"><a class="header" href="#4-cross-module-relations">4. Cross-module relations</a></h2> <div class="table-wrapper"><table><thead><tr><th>Relation</th><th>Mechanism</th><th>Source</th></tr></thead><tbody> <tr><td>House ← Student</td><td><code>student.houseId</code> ObjectId ref (no <code>populate</code> in students service)</td><td><code>student.schema.ts:41-42</code></td></tr> <tr><td>House → members</td><td><strong>None server-side.</strong> Client joins <code>GET /students</code> (filtered) with house list; <code>(planned)</code> filter/members endpoint</td><td><code>09</code> §G1, <code>12</code> §8</td></tr> <tr><td>House → staff (house master)</td><td><strong>None.</strong> Field <code>(planned)</code></td><td><code>01</code> §4.2</td></tr> <tr><td>House → events (points)</td><td><strong>None.</strong> <code>(planned)</code> / <code>(forward-looking)</code></td><td><code>01</code> §4.2-4.3</td></tr> </tbody></table> </div> <h2 id="5-content-hierarchy-house-detail"><a class="header" href="#5-content-hierarchy-house-detail">5. Content hierarchy (house detail)</a></h2> <ol> <li><strong>Identity block</strong> - color banner, name, code chip, motto.</li> <li><strong>Members</strong> - count + list (client-joined today; <code>(planned)</code> <code>GET /houses/:id/members</code>).</li> <li><strong>Meta</strong> - createdAt / updatedAt / version (admin-only, small print).</li> </ol> <h2 id="6-ia-gaps-flagged"><a class="header" href="#6-ia-gaps-flagged">6. IA gaps (flagged)</a></h2> <ul> <li>Members live under Students IA but are displayed under Houses - needs a server bridge (<code>(planned)</code> houseId filter on students list).</li> <li>No search/filter on the houses list (<code>q</code>/<code>sort</code> exist in <code>PaginationQueryDto</code> but houses list ignores them - <code>houses.service.ts:30</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05---screen-inventory-houses-module"><a class="header" href="#05---screen-inventory-houses-module">05 - Screen Inventory (Houses Module)</a></h1> <ul> <li><a href="houses/05_Screen_Inventory.html#1-house-list-screen-houses">1. House List Screen (<code>/houses</code>)</a></li> <li><a href="houses/05_Screen_Inventory.html#2-house-detail-screen-housesid">2. House Detail Screen (<code>/houses/:id</code>)</a></li> <li><a href="houses/05_Screen_Inventory.html#3-house-editor-bottom-sheet--dialog">3. House Editor (bottom sheet / dialog)</a></li> <li><a href="houses/05_Screen_Inventory.html#4-member-assignment-surfaces-student-module-cross-reference">4. Member assignment surfaces (student module, cross-reference)</a></li> <li><a href="houses/05_Screen_Inventory.html#shared-components-used">Shared components used</a></li> <li><a href="houses/05_Screen_Inventory.html#analytics-events-proposed">Analytics events (proposed)</a></li> <li><a href="houses/05_Screen_Inventory.html#keyboard-landscape-tablet-desktop">Keyboard, landscape, tablet, desktop</a></li> </ul> <blockquote> <p>Every screen of the Houses module, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in <a href="houses/../00-shared/03_Component_Library.html">00-shared/03</a>; this file enumerates which ones each screen uses with module specifics. States = idle / loading / success / empty / error(offline, conflict) / disabled / permission. Analytics events follow <code>{module}.{screen}.{action}</code> (proposed; SDK open - <a href="houses/../00-shared/10_QA_Baseline.html">00-shared/10</a> §8).</p> </blockquote> <hr /> <h2 id="1-house-list-screen-houses"><a class="header" href="#1-house-list-screen-houses">1. House List Screen (<code>/houses</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Browse all houses as color-coded cards</td></tr> <tr><td>Entry</td><td>Settings → School setup → Houses; deep link <code>/houses</code></td></tr> <tr><td>Exit</td><td>tap card → <code>/houses/:id</code>; "New house" → editor sheet; swipe-to-refresh</td></tr> <tr><td>Source</td><td><code>GET /houses?page=1&limit=20</code> (<code>houses.controller.ts:30-34</code>)</td></tr> <tr><td>Widgets</td><td><code>AppListPage</code> shell, <code>HouseColorCard</code> grid (2-col phone / 3-col tablet), <code>FAB</code> "New house", <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppOfflineBanner</code>, <code>AppPagination</code> footer</td></tr> <tr><td>States</td><td>loading skeletons; empty ("No houses yet - create your first"); error (offline banner + retry); conflict (409 inline toast from create)</td></tr> <tr><td>Row actions</td><td>card tap → detail; <code>AppMenu</code> (ellipsis) → Edit / Delete on card (admin)</td></tr> <tr><td>Permission</td><td><code>houses.read</code> list; <code>houses.create</code> FAB; <code>houses.update/delete</code> menu (<code>permissions.constants.ts:46-49</code>)</td></tr> <tr><td>Analytics</td><td><code>houses.list.open</code>, <code>houses.list.load</code>, <code>houses.list.error</code> <code>(proposed)</code></td></tr> <tr><td>Notes</td><td>No server sort - display in <code>createdAt</code> order; color shown from <code>color</code> field, fallback default token (<a href="houses/11_Design_System_Mapping.html">11</a> §3)</td></tr> </tbody></table> </div> <h2 id="2-house-detail-screen-housesid"><a class="header" href="#2-house-detail-screen-housesid">2. House Detail Screen (<code>/houses/:id</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Full house identity + member roster</td></tr> <tr><td>Entry</td><td>house list card tap</td></tr> <tr><td>Exit</td><td>back; Edit → editor sheet; Delete → confirm; member tap → student detail (cross-module)</td></tr> <tr><td>Source</td><td><code>GET /houses/:id</code> (<code>houses.controller.ts:36-40</code>); members from students list (client join, <code>(planned)</code> <code>GET /houses/:id/members</code>)</td></tr> <tr><td>Widgets</td><td><code>HouseHeader</code> (color banner + name + code chip + motto), <code>MemberTile</code> list w/ count header, <code>AppListTile</code>, <code>AppMenu</code> (Edit/Delete), <code>AppSkeleton</code>, <code>AppEmptyState</code> ("No members")</td></tr> <tr><td>States</td><td>loading; 404 ("House not found" → back + snackbar); members loading/empty/error (independent per-section state, <a href="houses/13_State_Management.html">13</a> §4)</td></tr> <tr><td>Permission</td><td><code>houses.read</code>; edit/delete menu gated on <code>houses.update/delete</code></td></tr> <tr><td>Analytics</td><td><code>houses.detail.open</code>, <code>houses.detail.members.load</code> <code>(proposed)</code></td></tr> <tr><td>Notes</td><td>Member section shows server-unsupported join: <strong>must</strong> render count as "unverified" until <code>(planned)</code> endpoint lands (<a href="houses/09_User_Behaviour.html">09</a> §G1); delete warns about members (<a href="houses/14_QA_Checklist.html">14</a> §4)</td></tr> </tbody></table> </div> <h2 id="3-house-editor-bottom-sheet--dialog"><a class="header" href="#3-house-editor-bottom-sheet--dialog">3. House Editor (bottom sheet / dialog)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Create or edit a house (single form, <a href="houses/08_Form_Specifications.html">08</a>)</td></tr> <tr><td>Entry</td><td>FAB (create) or Edit menu (update); never a route (<a href="houses/../00-shared/03_Component_Library.html">00-shared/03</a>)</td></tr> <tr><td>Exit</td><td>Save → success → list/detail refresh; Cancel/dismiss → discard (confirm if dirty)</td></tr> <tr><td>Source</td><td><code>POST /houses</code> (<code>houses.controller.ts:24-28</code>) / <code>PATCH /houses/:id</code> (<code>:42-46</code>)</td></tr> <tr><td>Widgets</td><td><code>AppFormField</code> name/code, <code>ColorPickerField</code>, <code>AppTextField</code> motto, <code>AppButton</code> submit, <code>AppBottomSheet</code>/<code>AppDialog</code></td></tr> <tr><td>States</td><td>idle; submitting (button spinner); <strong>409 conflict inline on code</strong>; 404 on update (house deleted meanwhile); offline → mutation blocked (<a href="houses/../00-shared/10_QA_Baseline.html">00-shared/10</a> §2)</td></tr> <tr><td>Permission</td><td><code>houses.create</code> / <code>houses.update</code></td></tr> <tr><td>Analytics</td><td><code>houses.editor.open</code>, <code>houses.editor.submit</code>, <code>houses.editor.conflict</code> <code>(proposed)</code></td></tr> <tr><td>Notes</td><td><strong>PATCH quirk:</strong> body DTO is <code>CreateHouseDto</code> - name+code required on edit too (<code>create-house.dto.ts:4-11</code>); client prefills and submits full set (<a href="houses/03_User_Journey.html">03</a> J1 step 5)</td></tr> </tbody></table> </div> <h2 id="4-member-assignment-surfaces-student-module-cross-reference"><a class="header" href="#4-member-assignment-surfaces-student-module-cross-reference">4. Member assignment surfaces (student module, cross-reference)</a></h2> <p>Assignment is done in <strong>Students</strong>, not Houses (<code>student.schema.ts:41-42</code>):</p> <ul> <li>Student create form: house picker (optional, <code>create-student.dto.ts:39-42</code>).</li> <li>Student edit: house picker → <code>PATCH /students/:id</code> (<code>update-student.dto.ts:45-48</code>).</li> <li>No bulk-assign screen exists <code>(planned)</code> (<a href="houses/03_User_Journey.html">03</a> J2 frustration).</li> </ul> <hr /> <h2 id="shared-components-used-12"><a class="header" href="#shared-components-used-12">Shared components used</a></h2> <p><code>AppTextField</code>, <code>AppButton</code>, <code>AppSnackbar</code>, <code>AppCard</code>, <code>AppListTile</code>, <code>AppBottomSheet</code>, <code>AppDialog</code>, <code>AppMenu</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppOfflineBanner</code>, <code>AppFAB</code>, <code>AppChips</code>, <code>AppBadge</code>, <code>AppAvatar</code>, <code>AppPagination</code>, <code>AppListPage</code>. Module-specific: <code>HouseColorCard</code>, <code>HouseHeader</code>, <code>MemberTile</code>, <code>ColorPickerField</code> - defined in <a href="houses/07_Component_Library.html">07_Component_Library.md</a>.</p> <h2 id="analytics-events-proposed-9"><a class="header" href="#analytics-events-proposed-9">Analytics events (proposed)</a></h2> <p><code>houses.list.{open,load,error}</code>, <code>houses.detail.{open,members_load}</code>, <code>houses.editor.{open,submit,conflict,delete}</code> - all <code>(proposed)</code>.</p> <h2 id="keyboard-landscape-tablet-desktop-12"><a class="header" href="#keyboard-landscape-tablet-desktop-12">Keyboard, landscape, tablet, desktop</a></h2> <ul> <li>Editor sheet scrolls with keyboard (<code>resizeToAvoidBottomInset</code>); color picker wraps on landscape.</li> <li>Tablet/desktop: master-detail list/detail; editor as dialog; color cards 3+ per row.</li> <li>List supports mouse hover highlight; detail members section max-width 720 dp.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="06---screen-specifications-houses-module"><a class="header" href="#06---screen-specifications-houses-module">06 - Screen Specifications (Houses Module)</a></h1> <ul> <li><a href="houses/06_Screen_Specifications.html#1-house-list-screen-houses">1. House List Screen (<code>/houses</code>)</a> <ul> <li><a href="houses/06_Screen_Specifications.html#11-layout-phone">1.1 Layout (phone)</a></li> <li><a href="houses/06_Screen_Specifications.html#12-data-binding">1.2 Data binding</a></li> <li><a href="houses/06_Screen_Specifications.html#13-states">1.3 States</a></li> <li><a href="houses/06_Screen_Specifications.html#14-interactions">1.4 Interactions</a></li> <li><a href="houses/06_Screen_Specifications.html#15-motion--a11y">1.5 Motion & a11y</a></li> </ul> </li> <li><a href="houses/06_Screen_Specifications.html#2-house-detail-screen-housesid">2. House Detail Screen (<code>/houses/:id</code>)</a> <ul> <li><a href="houses/06_Screen_Specifications.html#21-layout">2.1 Layout</a></li> <li><a href="houses/06_Screen_Specifications.html#22-data-binding">2.2 Data binding</a></li> <li><a href="houses/06_Screen_Specifications.html#23-states">2.3 States</a></li> <li><a href="houses/06_Screen_Specifications.html#24-interactions">2.4 Interactions</a></li> <li><a href="houses/06_Screen_Specifications.html#25-a11y--motion">2.5 a11y & motion</a></li> </ul> </li> <li><a href="houses/06_Screen_Specifications.html#3-house-editor-bottom-sheet--dialog">3. House Editor (bottom sheet / dialog)</a> <ul> <li><a href="houses/06_Screen_Specifications.html#31-layout-create-mode">3.1 Layout (create mode)</a></li> <li><a href="houses/06_Screen_Specifications.html#32-data-binding">3.2 Data binding</a></li> <li><a href="houses/06_Screen_Specifications.html#33-states">3.3 States</a></li> <li><a href="houses/06_Screen_Specifications.html#34-interactions">3.4 Interactions</a></li> <li><a href="houses/06_Screen_Specifications.html#35-motion--a11y">3.5 Motion & a11y</a></li> </ul> </li> <li><a href="houses/06_Screen_Specifications.html#4-student-side-house-picker-cross-module-reused-by-students-forms">4. Student-side house picker (cross-module, reused by Students forms)</a></li> <li><a href="houses/06_Screen_Specifications.html#5-permissions-summary-per-screen">5. Permissions summary per screen</a></li> </ul> <blockquote> <p>Full per-screen specification: layout, states, widgets, data binding, interactions, permissions, a11y and motion. Authoritative patterns in <a href="houses/../00-shared/03_Component_Library.html">00-shared/03</a> (Component Library), <a href="houses/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a> (Interaction & Motion), <a href="houses/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a> (Accessibility Baseline). Widgets marked <code>App*</code> come from <a href="houses/../00-shared/03_Component_Library.html">00-shared/03</a>; module widgets from <a href="houses/07_Component_Library.html">07_Component_Library.md</a>.</p> </blockquote> <hr /> <h2 id="1-house-list-screen-houses-1"><a class="header" href="#1-house-list-screen-houses-1">1. House List Screen (<code>/houses</code>)</a></h2> <h3 id="11-layout-phone"><a class="header" href="#11-layout-phone">1.1 Layout (phone)</a></h3> <pre><code>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] </code></pre> <p>Tablet/desktop: 3-column grid, max content width 960 dp, master-detail optional.</p> <h3 id="12-data-binding"><a class="header" href="#12-data-binding">1.2 Data binding</a></h3> <div class="table-wrapper"><table><thead><tr><th>Element</th><th>Source</th><th>Mapping</th></tr></thead><tbody> <tr><td>List</td><td><code>GET /houses?page=1&limit=20</code> → <code>data: House[]</code>, <code>meta</code></td><td><code>HouseListCubit</code> (<code>13</code> §2)</td></tr> <tr><td>Card</td><td><code>House.name</code>, <code>code</code>, <code>color</code>, <code>motto</code></td><td><code>house.schema.ts:9-19</code></td></tr> <tr><td>Pagination</td><td><code>meta.totalItems/totalPages/hasNext/hasPrevious</code></td><td><code>pagination-query.dto.ts:32-39</code></td></tr> </tbody></table> </div> <p>No server sort (<code>houses.service.ts:30</code> finds with <code>{}</code>); client renders <code>createdAt</code> asc (creation order). <code>q</code>/<code>sort</code> query params exist in <code>PaginationQueryDto</code> (<code>pagination-query.dto.ts:21-29</code>) but the houses list ignores them - no search box.</p> <h3 id="13-states-3"><a class="header" href="#13-states-3">1.3 States</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Render</th><th>Trigger</th></tr></thead><tbody> <tr><td>Initial</td><td>skeletons x4</td><td>mount</td></tr> <tr><td>Loading</td><td>skeletons (page>1: footer spinner)</td><td>page change, pull-to-refresh</td></tr> <tr><td>Success</td><td>grid + footer</td><td>200 envelope</td></tr> <tr><td>Empty</td><td><code>AppEmptyState</code> "No houses yet. Create your first house."</td><td><code>data.length == 0</code></td></tr> <tr><td>Error</td><td><code>AppOfflineBanner</code> + retry button; stale grid kept</td><td>network 401/500/offline</td></tr> <tr><td>Permission</td><td>empty + "contact admin" copy (no FAB)</td><td>no <code>houses.read</code>/<code>houses.create</code></td></tr> </tbody></table> </div> <h3 id="14-interactions-1"><a class="header" href="#14-interactions-1">1.4 Interactions</a></h3> <ul> <li>Tap card → <code>/houses/:id</code> (push).</li> <li>FAB → editor sheet (create mode, <code>05</code> §3).</li> <li>Pull-to-refresh → refetch page 1.</li> <li>Card menu (ellipsis, admin): Edit → editor (update mode); Delete → confirm dialog (<code>14</code> §4 warning about members).</li> </ul> <h3 id="15-motion--a11y"><a class="header" href="#15-motion--a11y">1.5 Motion & a11y</a></h3> <ul> <li>Card appear: stagger <code>m-fast</code> fade-up (≤4 rows) per <code>00-shared/08</code>.</li> <li>Color cards: color must never be the <em>only</em> differentiator - name text always present (WCAG AA contrast for swatch badges, <code>00-shared/09</code>).</li> <li>Skeleton < 200 ms perceived; content < 2 s on network (<code>00-shared/10</code> §1).</li> </ul> <hr /> <h2 id="2-house-detail-screen-housesid-1"><a class="header" href="#2-house-detail-screen-housesid-1">2. House Detail Screen (<code>/houses/:id</code>)</a></h2> <h3 id="21-layout-1"><a class="header" href="#21-layout-1">2.1 Layout</a></h3> <pre><code>[ 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) </code></pre> <h3 id="22-data-binding"><a class="header" href="#22-data-binding">2.2 Data binding</a></h3> <div class="table-wrapper"><table><thead><tr><th>Section</th><th>Source</th></tr></thead><tbody> <tr><td>Identity</td><td><code>GET /houses/:id</code> → <code>HouseDocument</code> (<code>houses.service.ts:36-40</code>; 404 <code>House not found.</code> <code>:38</code>)</td></tr> <tr><td>Members</td><td><strong>Client join</strong> <code>(planned)</code>: <code>GET /students?page&limit</code> (<code>student.controller.ts:41-43</code>) then filter <code>houseId == id</code> client-side. Server filter / members endpoint <code>(planned)</code> (<code>09</code> §G1)</td></tr> <tr><td>Meta</td><td><code>base.schema.ts:13-34</code> (<code>createdAt</code>, <code>updatedAt</code>, <code>version</code>)</td></tr> </tbody></table> </div> <h3 id="23-states"><a class="header" href="#23-states">2.3 States</a></h3> <div class="table-wrapper"><table><thead><tr><th>Section</th><th>State</th><th>Render</th></tr></thead><tbody> <tr><td>Header</td><td>Loading / 404 / Success</td><td>skeletons / "House not found" + pop / banner</td></tr> <tr><td>Members</td><td>Loading / Empty / Loaded / Error</td><td>3 skeleton tiles / "No members yet" / count + tiles / inline retry</td></tr> <tr><td>Members</td><td><strong>Unverified count</strong></td><td>if joined client-side, show <code>~</code> prefix + tooltip "server-side member count planned"</td></tr> <tr><td>Whole</td><td>Offline</td><td>banner; detail cached from list when available (<code>13</code> §6)</td></tr> </tbody></table> </div> <h3 id="24-interactions"><a class="header" href="#24-interactions">2.4 Interactions</a></h3> <ul> <li>Edit → editor sheet prefilled (update mode).</li> <li>Delete → <code>AppDialog</code> confirm; on confirm: warning list (member count), then <code>DELETE /houses/:id</code> (<code>houses.service.ts:51-54</code>). <strong>No 409 expected</strong> - backend has no dependency guard; client warns pre-emptively (<code>14</code> §4).</li> <li>Member tap → student detail (cross-module navigation via <code>/students/:id</code>).</li> <li>Pull-to-refresh → refetch house + members.</li> </ul> <h3 id="25-a11y--motion"><a class="header" href="#25-a11y--motion">2.5 a11y & motion</a></h3> <ul> <li>Banner color: name/motto must pass contrast over arbitrary <code>color</code> - compute on tokenization (<code>11</code> §3), fall back to dark scrim.</li> <li>Delete dialog: destructive button styled <code>error</code>; focus trapped (<code>00-shared/09</code>).</li> <li>Section transitions <code>m-base</code> fade; no parallax on color banner (motion budget).</li> </ul> <hr /> <h2 id="3-house-editor-bottom-sheet--dialog-1"><a class="header" href="#3-house-editor-bottom-sheet--dialog-1">3. House Editor (bottom sheet / dialog)</a></h2> <h3 id="31-layout-create-mode"><a class="header" href="#31-layout-create-mode">3.1 Layout (create mode)</a></h3> <pre><code>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) </code></pre> <p>Update mode: title "Edit House", button "Save changes", fields prefilled.</p> <h3 id="32-data-binding"><a class="header" href="#32-data-binding">3.2 Data binding</a></h3> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>DTO</th><th>Rule (source)</th></tr></thead><tbody> <tr><td>name</td><td><code>CreateHouseDto.name</code></td><td>required <code>IsString</code> (<code>create-house.dto.ts:5-7</code>); trimmed server-side (<code>house.schema.ts:9-10</code>)</td></tr> <tr><td>code</td><td><code>CreateHouseDto.code</code></td><td>required <code>IsString</code> (<code>create-house.dto.ts:9-11</code>); trimmed (<code>house.schema.ts:12-13</code>); unique per tenant (<code>house.schema.ts:23</code>)</td></tr> <tr><td>color</td><td><code>CreateHouseDto.color</code></td><td>optional <code>IsOptional IsString</code> (<code>create-house.dto.ts:13-16</code>; <code>house.schema.ts:15-16</code>)</td></tr> <tr><td>motto</td><td><code>CreateHouseDto.motto</code></td><td>optional <code>IsOptional IsString</code> (<code>create-house.dto.ts:18-21</code>; <code>house.schema.ts:18-19</code>)</td></tr> </tbody></table> </div> <p>Create → <code>POST /houses</code> (<code>houses.controller.ts:24-28</code>); Update → <code>PATCH /houses/:id</code> (<code>houses.controller.ts:42-46</code>). <strong>PATCH sends the full DTO</strong> (name+code required in body even for edits) - see <code>03</code> J1 step 5, <code>08</code> §4.</p> <h3 id="33-states"><a class="header" href="#33-states">3.3 States</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Render</th></tr></thead><tbody> <tr><td>Idle</td><td>form enabled, button enabled</td></tr> <tr><td>Submitting</td><td>button spinner, fields disabled (single-flight)</td></tr> <tr><td>Conflict (409)</td><td>inline error under <strong>code</strong> field, verbatim <code>House code "X" already exists.</code> (<code>houses.service.ts:19-21</code>); focus field</td></tr> <tr><td>Update 404</td><td>snackbar "House not found" + pop sheet; list refetches</td></tr> <tr><td>Offline</td><td>form blocked, <code>AppOfflineBanner</code> (<code>00-shared/10</code> §2)</td></tr> </tbody></table> </div> <h3 id="34-interactions"><a class="header" href="#34-interactions">3.4 Interactions</a></h3> <ul> <li>Enter submits (name+code filled).</li> <li>Code field: suggest uppercase normalization; do <strong>not</strong> enforce - server trims only.</li> <li>Cancel with dirty form → discard confirm dialog (motion <code>m-fast</code>).</li> <li>Color picker: 8 preset swatches (design tokens <code>11</code> §3) + custom hex with contrast hint against white text.</li> </ul> <h3 id="35-motion--a11y"><a class="header" href="#35-motion--a11y">3.5 Motion & a11y</a></h3> <ul> <li>Sheet slide-up <code>m-base</code>; field errors shake <code>m-fast</code> (<code>00-shared/08</code>).</li> <li>Labels linked to fields; error messages in live regions (<code>00-shared/09</code>).</li> <li>Custom hex input validates <code>#RRGGBB</code>; invalid → field error, not block.</li> </ul> <hr /> <h2 id="4-student-side-house-picker-cross-module-reused-by-students-forms"><a class="header" href="#4-student-side-house-picker-cross-module-reused-by-students-forms">4. Student-side house picker (cross-module, reused by Students forms)</a></h2> <p>Not a houses screen - the field contract it depends on:</p> <ul> <li>optional <code>houseId</code> (<code>IsMongoId</code>) on <code>create-student.dto.ts:39-42</code> and <code>update-student.dto.ts:45-48</code>.</li> <li>Picker options from <code>GET /houses</code> (house list cubit cache, <code>13</code> §6).</li> <li>"None" option to clear a house (send empty/omit <code>houseId</code>).</li> </ul> <hr /> <h2 id="5-permissions-summary-per-screen"><a class="header" href="#5-permissions-summary-per-screen">5. Permissions summary per screen</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Read</th><th>Create</th><th>Update</th><th>Delete</th></tr></thead><tbody> <tr><td>House list</td><td><code>houses.read</code></td><td><code>houses.create</code> (FAB)</td><td>-</td><td><code>houses.delete</code> (menu)</td></tr> <tr><td>House detail</td><td><code>houses.read</code></td><td>-</td><td><code>houses.update</code> (menu)</td><td><code>houses.delete</code> (menu)</td></tr> <tr><td>House editor</td><td>-</td><td><code>houses.create</code></td><td><code>houses.update</code></td><td>-</td></tr> </tbody></table> </div> <p>Source: <code>permissions.constants.ts:46-49</code>. Server RBAC not wired yet (<code>AGENTS.md</code> - "Auth (JWT/RBAC guards) ... not yet implemented") - client gating only.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="07---component-library-houses-module"><a class="header" href="#07---component-library-houses-module">07 - Component Library (Houses Module)</a></h1> <ul> <li><a href="houses/07_Component_Library.html#1-housecolorcard-list-grid-card">1. <code>HouseColorCard</code> (list grid card)</a></li> <li><a href="houses/07_Component_Library.html#2-househeader-detail-banner">2. <code>HouseHeader</code> (detail banner)</a></li> <li><a href="houses/07_Component_Library.html#3-membertile-member-row">3. <code>MemberTile</code> (member row)</a></li> <li><a href="houses/07_Component_Library.html#4-colorpickerfield-editor-field">4. <code>ColorPickerField</code> (editor field)</a></li> <li><a href="houses/07_Component_Library.html#5-housecountbadge-members-count">5. <code>HouseCountBadge</code> (members count)</a></li> <li><a href="houses/07_Component_Library.html#6-conflictfielderror-shared-module-configured">6. <code>ConflictFieldError</code> (shared, module-configured)</a></li> <li><a href="houses/07_Component_Library.html#7-deletehousedialog-confirm">7. <code>DeleteHouseDialog</code> (confirm)</a></li> <li><a href="houses/07_Component_Library.html#8-composition-recipes">8. Composition recipes</a></li> <li><a href="houses/07_Component_Library.html#9-component-inventory-vs-shared">9. Component inventory vs. shared</a></li> </ul> <blockquote> <p>Module-specific widgets on top of the shared library in <a href="houses/../00-shared/03_Component_Library.html">00-shared/03</a> (Component Library) and tokens in <a href="houses/../00-shared/02_Design_Tokens.html">00-shared/02</a> (Design Tokens). Shared primitives reused here (not redefined): <code>AppListPage</code>, <code>AppCard</code>, <code>AppListTile</code>, <code>AppButton</code>, <code>AppTextField</code>, <code>AppBottomSheet</code>, <code>AppDialog</code>, <code>AppMenu</code>, <code>AppFAB</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppOfflineBanner</code>, <code>AppSnackbar</code>, <code>AppPagination</code>, <code>AppChips</code>, <code>AppBadge</code>, <code>AppAvatar</code>.</p> </blockquote> <hr /> <h2 id="1-housecolorcard-list-grid-card"><a class="header" href="#1-housecolorcard-list-grid-card">1. <code>HouseColorCard</code> (list grid card)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Prop</th><th>Type</th><th>Default</th></tr></thead><tbody> <tr><td><code>house</code></td><td><code>House</code></td><td>-</td></tr> <tr><td><code>onTap</code></td><td><code>VoidCallback?</code></td><td>-</td></tr> <tr><td><code>onEdit</code> / <code>onDelete</code></td><td><code>VoidCallback?</code></td><td>null (hidden when null)</td></tr> <tr><td><code>selectedColor</code></td><td><code>Color</code></td><td>resolved via <code>HouseColorResolver</code> (<a href="houses/11_Design_System_Mapping.html">11</a> §3)</td></tr> </tbody></table> </div> <ul> <li>Layout: 16 dp swatch strip on top (height 56), name (titleMedium), code chip + motto 1-line ellipsis (bodySmall muted).</li> <li>States: default / pressed (elevation+ <code>m-fast</code>) / loading skeleton variant.</li> <li>a11y: semantics label <code>"House <name>, code <code>"</code>; never color-only.</li> <li>Gesture: tap → detail; <code>AppMenu</code> (ellipsis top-right, admin only).</li> </ul> <h2 id="2-househeader-detail-banner"><a class="header" href="#2-househeader-detail-banner">2. <code>HouseHeader</code> (detail banner)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Prop</th><th>Type</th></tr></thead><tbody> <tr><td><code>house</code></td><td><code>House</code></td></tr> <tr><td><code>actions</code></td><td><code>List<Widget></code> (menu)</td></tr> </tbody></table> </div> <ul> <li>Full-width banner painted with resolved house color; dark scrim for contrast (<a href="houses/06_Screen_Specifications.html">06</a> §2.5); name displayLarge white, code chip <code>onPrimary</code>, motto italic.</li> <li>Skeleton variant height 180.</li> <li>a11y: banner is decorative; name/code/motto are real text nodes.</li> </ul> <h2 id="3-membertile-member-row"><a class="header" href="#3-membertile-member-row">3. <code>MemberTile</code> (member row)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Prop</th><th>Type</th></tr></thead><tbody> <tr><td><code>admissionNumber</code></td><td><code>String</code></td></tr> <tr><td><code>name</code> (from joined student record, <code>(planned)</code> fuller payload)</td><td><code>String?</code></td></tr> <tr><td><code>onTap</code></td><td><code>VoidCallback?</code></td></tr> </tbody></table> </div> <ul> <li><code>AppAvatar</code> with initials (deterministic hue from admission number - not house color, keeps house swatch meaningful), leading badge <code>AppBadge</code> "member".</li> <li>Row shows admissionNumber; name only when the students payload provides it (today the list returns full student docs - <code>student.schema.ts:16-63</code> - so name must be derived from the linked user, which the houses flow does not resolve; render admissionNumber-first <code>(planned)</code> name enrichment).</li> <li>Empty state variant handled by parent (<a href="houses/06_Screen_Specifications.html">06</a> §2.3).</li> </ul> <h2 id="4-colorpickerfield-editor-field"><a class="header" href="#4-colorpickerfield-editor-field">4. <code>ColorPickerField</code> (editor field)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Prop</th><th>Type</th></tr></thead><tbody> <tr><td><code>value</code></td><td><code>String?</code> (hex <code>#RRGGBB</code> as stored, <code>house.schema.ts:15-16</code>)</td></tr> <tr><td><code>onChanged</code></td><td><code>ValueChanged<String?></code></td></tr> <tr><td><code>presets</code></td><td><code>List<ColorToken></code> from <a href="houses/11_Design_System_Mapping.html">11</a> §3</td></tr> </tbody></table> </div> <ul> <li>Renders 8 preset swatch buttons (selected ring) + custom hex <code>AppTextField</code> (validates <code>#RRGGBB</code>).</li> <li>Contrast hint: shows pass/warn against white text (<a href="houses/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>).</li> <li>Semantics: each swatch <code>Semantics(button, label: "color <name>")</code>.</li> </ul> <h2 id="5-housecountbadge-members-count"><a class="header" href="#5-housecountbadge-members-count">5. <code>HouseCountBadge</code> (members count)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Prop</th><th>Type</th></tr></thead><tbody> <tr><td><code>count</code></td><td><code>int?</code> (null = unverified)</td></tr> <tr><td><code>unverified</code></td><td><code>bool</code></td></tr> </tbody></table> </div> <ul> <li>Verified (server count, <code>(planned)</code> endpoint): <code>AppBadge</code> <code>"<n> members"</code>.</li> <li>Unverified (client join today): <code>"~<n> members"</code> + tooltip "server-side member count planned". Prevents presenting an estimated count as authoritative (<a href="houses/06_Screen_Specifications.html">06</a> §2.3).</li> </ul> <h2 id="6-conflictfielderror-shared-module-configured"><a class="header" href="#6-conflictfielderror-shared-module-configured">6. <code>ConflictFieldError</code> (shared, module-configured)</a></h2> <ul> <li>Reused from <a href="houses/../00-shared/03_Component_Library.html">00-shared/03</a>; rendered under the <strong>code</strong> field with server message verbatim: <code>House code "X" already exists.</code> (<code>houses.service.ts:19-21</code>).</li> <li>a11y: live-region announcement; focus moves to offending field.</li> </ul> <h2 id="7-deletehousedialog-confirm"><a class="header" href="#7-deletehousedialog-confirm">7. <code>DeleteHouseDialog</code> (confirm)</a></h2> <ul> <li>Extends <code>AppDialog</code>: title "Delete house?", body lists consequences: <ul> <li>Soft delete (<code>houses.service.ts:51-54</code>) - house disappears from lists.</li> <li><strong>Members keep <code>houseId</code></strong> - dangling refs (<code>student.schema.ts:41-42</code>); no server guard/cascade today <code>(planned)</code>.</li> </ul> </li> <li>Destructive action styled <code>error</code>, confirm text "Delete".</li> <li>a11y: focus trap, destructive-first reading order (<a href="houses/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>).</li> </ul> <h2 id="8-composition-recipes"><a class="header" href="#8-composition-recipes">8. Composition recipes</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Composition</th></tr></thead><tbody> <tr><td>House list (<a href="houses/05_Screen_Inventory.html">05</a> §1)</td><td><code>AppListPage</code> + <code>HouseColorCard</code> grid + <code>AppPagination</code> + <code>AppFAB</code></td></tr> <tr><td>House detail (<a href="houses/05_Screen_Inventory.html">05</a> §2)</td><td><code>HouseHeader</code> + members header (<code>HouseCountBadge</code>) + <code>MemberTile</code> list</td></tr> <tr><td>House editor (<a href="houses/05_Screen_Inventory.html">05</a> §3)</td><td><code>AppBottomSheet</code>/<code>AppDialog</code> + <code>AppTextField</code> x3 + <code>ColorPickerField</code> + <code>AppButton</code> + <code>ConflictFieldError</code></td></tr> </tbody></table> </div> <h2 id="9-component-inventory-vs-shared"><a class="header" href="#9-component-inventory-vs-shared">9. Component inventory vs. shared</a></h2> <div class="table-wrapper"><table><thead><tr><th>Widget</th><th>Owned by</th><th>Why not shared</th></tr></thead><tbody> <tr><td><code>HouseColorCard</code></td><td>houses</td><td>house-specific data shape + swatch layout</td></tr> <tr><td><code>HouseHeader</code></td><td>houses</td><td>banner composition</td></tr> <tr><td><code>MemberTile</code></td><td>houses (student data rendered here)</td><td>cross-module display concern</td></tr> <tr><td><code>ColorPickerField</code></td><td>houses</td><td>free-string color semantics (server stores any string)</td></tr> <tr><td><code>HouseCountBadge</code></td><td>houses</td><td>verified/unverified duality</td></tr> <tr><td><code>DeleteHouseDialog</code></td><td>houses</td><td>delete semantics without server guard</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="08---form-specifications-houses-module"><a class="header" href="#08---form-specifications-houses-module">08 - Form Specifications (Houses Module)</a></h1> <ul> <li><a href="houses/08_Form_Specifications.html#1-form-map">1. Form map</a></li> <li><a href="houses/08_Form_Specifications.html#2-client-validation-mirror-of-dto">2. Client validation (mirror of DTO)</a></li> <li><a href="houses/08_Form_Specifications.html#3-conflict-handling-the-critical-path">3. Conflict handling (the critical path)</a></li> <li><a href="houses/08_Form_Specifications.html#4-update-mode-quirk-patch-semantics">4. Update-mode quirk (PATCH semantics)</a></li> <li><a href="houses/08_Form_Specifications.html#5-submission-flow">5. Submission flow</a></li> <li><a href="houses/08_Form_Specifications.html#6-idempotency-note">6. Idempotency note</a></li> <li><a href="houses/08_Form_Specifications.html#7-a11y--motion">7. a11y & motion</a></li> </ul> <blockquote> <p>The house form (create + edit share one form, <a href="houses/05_Screen_Inventory.html">05</a> §3 / <a href="houses/06_Screen_Specifications.html">06</a> §3). Field rules are a client mirror of <code>CreateHouseDto</code> (<code>create-house.dto.ts:4-22</code>). Form behavior conventions from <a href="houses/../00-shared/03_Component_Library.html">00-shared/03</a> and validation patterns from <a href="houses/../00-shared/07_API_Conventions.html">00-shared/07</a>.</p> </blockquote> <hr /> <h2 id="1-form-map-1"><a class="header" href="#1-form-map-1">1. Form map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Widget</th><th>Required</th><th>DTO rule</th><th>Server behavior</th></tr></thead><tbody> <tr><td>Name</td><td><code>AppTextField</code></td><td>✅</td><td><code>@IsString()</code> (<code>create-house.dto.ts:5-7</code>)</td><td>trimmed (<code>house.schema.ts:9-10</code>)</td></tr> <tr><td>Code</td><td><code>AppTextField</code></td><td>✅</td><td><code>@IsString()</code> (<code>create-house.dto.ts:9-11</code>)</td><td>trimmed (<code>house.schema.ts:12-13</code>); <strong>unique per tenant</strong> (<code>house.schema.ts:23</code>)</td></tr> <tr><td>Color</td><td><code>ColorPickerField</code></td><td>❌</td><td><code>@IsOptional() @IsString()</code> (<code>create-house.dto.ts:13-16</code>)</td><td>stored verbatim, any string (<code>house.schema.ts:15-16</code>)</td></tr> <tr><td>Motto</td><td><code>AppTextField</code> (multiline)</td><td>❌</td><td><code>@IsOptional() @IsString()</code> (<code>create-house.dto.ts:18-21</code>)</td><td>stored verbatim (<code>house.schema.ts:18-19</code>)</td></tr> </tbody></table> </div> <h2 id="2-client-validation-mirror-of-dto"><a class="header" href="#2-client-validation-mirror-of-dto">2. Client validation (mirror of DTO)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Client rule</th><th>Error copy</th></tr></thead><tbody> <tr><td>Name</td><td>non-empty after trim</td><td>"Name is required."</td></tr> <tr><td>Code</td><td>non-empty after trim</td><td>"Code is required."</td></tr> <tr><td>Color</td><td><code>#RRGGBB</code> or preset token</td><td>"Use a hex color like #BA1A1A."</td></tr> <tr><td>Motto</td><td>≤ 120 chars (soft cap, not server-enforced)</td><td>-</td></tr> </tbody></table> </div> <ul> <li>Server returns 400 for DTO violations (class-validator) - client mirrors first; any server 400 maps to the field by <code>message</code>/<code>property</code> (<a href="houses/../00-shared/07_API_Conventions.html">00-shared/07</a> §4).</li> </ul> <h2 id="3-conflict-handling-the-critical-path"><a class="header" href="#3-conflict-handling-the-critical-path">3. Conflict handling (the critical path)</a></h2> <ul> <li>Duplicate code on <strong>create</strong> → 409 <code>House code "X" already exists.</code> (<code>houses.service.ts:19-21</code>). Rendered by <code>ConflictFieldError</code> under Code (<a href="houses/07_Component_Library.html">07</a> §6), verbatim; focus moves to Code.</li> <li>Duplicate code on <strong>update</strong> → same service path? No: <code>update</code> does <strong>not</strong> re-check duplicates (<code>houses.service.ts:42-49</code>) - it relies on the unique index <code>{tenantId, code}</code> (<code>house.schema.ts:23</code>). MongoDB <code>E11000</code> surfaces as a generic server error; <strong>client must still render 409-style copy</strong> from the API error mapping (<a href="houses/../00-shared/07_API_Conventions.html">00-shared/07</a> §4) - QA item <a href="houses/14_QA_Checklist.html">14</a> §2.5.</li> <li>Duplicate <strong>name</strong> or <strong>color</strong> → allowed by design (no check, no index); client may warn softly ("Another house uses this color") but must not block.</li> </ul> <h2 id="4-update-mode-quirk-patch-semantics"><a class="header" href="#4-update-mode-quirk-patch-semantics">4. Update-mode quirk (PATCH semantics)</a></h2> <p><code>PATCH /houses/:id</code> reuses <code>CreateHouseDto</code> as the body type (<code>houses.controller.ts:44</code>); name + code are required in the request body even for edits. The form therefore:</p> <ul> <li>Prefills all four fields from the loaded house.</li> <li>Submits the <strong>complete</strong> field set on Save (never partial).</li> <li>Documents this in code: <code>// houses: PATCH body = CreateHouseDto (full set), see houses.controller.ts:44</code>.</li> </ul> <p>If the API later gets a true partial DTO this behavior changes - keep it in one submission mapper (<code>HouseFormCubit.toDto()</code>).</p> <h2 id="5-submission-flow"><a class="header" href="#5-submission-flow">5. Submission flow</a></h2> <pre><code class="language-dart">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) </code></pre> <h2 id="6-idempotency-note"><a class="header" href="#6-idempotency-note">6. Idempotency note</a></h2> <ul> <li>Create is <strong>not</strong> idempotent: double-tap protection via <code>submitting</code> flag (single-flight in <code>HouseFormCubit</code>, <code>13</code> §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).</li> <li>Update is naturally idempotent (<code>$set</code> full DTO).</li> </ul> <h2 id="7-a11y--motion"><a class="header" href="#7-a11y--motion">7. a11y & motion</a></h2> <ul> <li>Labels linked to inputs; error live-region announcements (<code>00-shared/09</code>).</li> <li>Submit disabled until required fields non-empty (but keep 409 handling enabled for races).</li> <li>Field error shake <code>m-fast</code> (<code>00-shared/08</code>); focus first invalid field.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="09---user-behaviour-houses-module"><a class="header" href="#09---user-behaviour-houses-module">09 - User Behaviour (Houses Module)</a></h1> <ul> <li><a href="houses/09_User_Behaviour.html#1-behaviours-observed-from-source--domain">1. Behaviours observed (from source + domain)</a></li> <li><a href="houses/09_User_Behaviour.html#2-trust--estimation-behaviour">2. Trust & estimation behaviour</a></li> <li><a href="houses/09_User_Behaviour.html#3-error-handling-behaviour">3. Error-handling behaviour</a></li> <li><a href="houses/09_User_Behaviour.html#g-gaps-the-behaviour-exposes-flagged-planned-unless-noted">G. Gaps the behaviour exposes (flagged <code>(planned)</code> unless noted)</a></li> </ul> <blockquote> <p>Observed/derived user behaviour patterns and the design decisions they force. Behavioural baselines (offline, motion, a11y) live in <a href="houses/../00-shared/08_Interaction_&_Motion.html">00-shared/08-10</a>; this file records houses-specific behaviours and the gaps they expose.</p> </blockquote> <hr /> <h2 id="1-behaviours-observed-from-source--domain"><a class="header" href="#1-behaviours-observed-from-source--domain">1. Behaviours observed (from source + domain)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Behaviour</th><th>Evidence / rationale</th><th>Design consequence</th></tr></thead><tbody> <tr><td>B1</td><td>Admins create houses in a single batch at year start</td><td>Houses are few (typically 4-8); list is never huge</td><td>Grid of cards, no heavy pagination UI; pagination still wired (<code>houses.service.ts:25-34</code>)</td></tr> <tr><td>B2</td><td>Code is the identity key, name is the label</td><td>Unique index on code only (<code>house.schema.ts:23</code>); service 409 on code (<code>houses.service.ts:19-21</code>)</td><td>Code shown as chip; duplicates of name allowed (no block, <a href="houses/08_Form_Specifications.html">08</a> §3)</td></tr> <tr><td>B3</td><td>Colour is the fast-recognition channel</td><td><code>color</code> is a free string (<code>house.schema.ts:15-16</code>)</td><td>Tokenized swatches (<a href="houses/11_Design_System_Mapping.html">11</a> §3); never color-only semantics</td></tr> <tr><td>B4</td><td>Assignment happens at admission, not at the house</td><td><code>houseId</code> lives on the student (<code>student.schema.ts:41-42</code>), set via student DTOs (<code>create-student.dto.ts:39-42</code>)</td><td>Houses module must not fake a "manage members" write surface; deep-link to student edit instead</td></tr> <tr><td>B5</td><td>Reassignment = PATCH student, keeps history silently</td><td><code>update</code> <code>$set</code>s <code>houseId</code> (<code>student.service.ts:142-155</code>); transfer keeps house (<code>:185-192</code>)</td><td>No history UI (no server audit field); accept and document</td></tr> <tr><td>B6</td><td>Users expect member counts on a house</td><td>Core detail-screen ask</td><td>Client-join today + <code>HouseCountBadge</code> verified/unverified duality (<a href="houses/07_Component_Library.html">07</a> §5)</td></tr> <tr><td>B7</td><td>Deletion is rare and consequential</td><td>Soft delete only (<code>houses.service.ts:51-54</code>)</td><td>Confirm dialog with consequence copy (<a href="houses/07_Component_Library.html">07</a> §7)</td></tr> </tbody></table> </div> <h2 id="2-trust--estimation-behaviour"><a class="header" href="#2-trust--estimation-behaviour">2. Trust & estimation behaviour</a></h2> <ul> <li><strong>Never present estimated data as authoritative</strong>: member count from the client join is a filtered snapshot, not a server truth → <code>~</code> prefix (<a href="houses/07_Component_Library.html">07</a> §5).</li> <li><strong>Never claim protection the backend lacks</strong>: no delete guard exists, so the dialog says "members keep their house reference" instead of promising a cascade.</li> </ul> <h2 id="3-error-handling-behaviour"><a class="header" href="#3-error-handling-behaviour">3. Error-handling behaviour</a></h2> <div class="table-wrapper"><table><thead><tr><th>Situation</th><th>User expectation</th><th>Implementation</th></tr></thead><tbody> <tr><td>Duplicate code</td><td>"That code is taken"</td><td>409 verbatim inline (<code>houses.service.ts:19-21</code>, <a href="houses/07_Component_Library.html">07</a> §6)</td></tr> <tr><td>House deleted mid-edit</td><td>"It's gone"</td><td>404 → snackbar + pop (<a href="houses/08_Form_Specifications.html">08</a> §5)</td></tr> <tr><td>Offline</td><td>Still see houses</td><td>cached list + offline banner, mutations blocked (<a href="houses/../00-shared/10_QA_Baseline.html">00-shared/10</a> §2, <a href="houses/13_State_Management.html">13</a> §6)</td></tr> <tr><td>Update race (duplicate code via index)</td><td>clear message</td><td>error map → conflict copy (<a href="houses/08_Form_Specifications.html">08</a> §3, <a href="houses/14_QA_Checklist.html">14</a> §2.5)</td></tr> </tbody></table> </div> <h2 id="g-gaps-the-behaviour-exposes-flagged-planned-unless-noted"><a class="header" href="#g-gaps-the-behaviour-exposes-flagged-planned-unless-noted">G. Gaps the behaviour exposes (flagged <code>(planned)</code> unless noted)</a></h2> <ul> <li><strong>G1 Member list/filter</strong>: no <code>houseId</code> filter on <code>GET /students</code> (<code>student.service.ts:106</code> filters <code>{}</code>; <code>PaginationQueryDto</code> has only page/limit/sort/q) and no <code>GET /houses/:id/members</code>. Client joins full student list - unacceptable past a few hundred students. <code>(planned)</code> <code>GET /students?houseId=</code> or members endpoint.</li> <li><strong>G2 Delete cascade</strong>: deleting a house leaves dangling <code>student.houseId</code> (<code>student.schema.ts:41-42</code>); no 409, no unset. <code>(planned)</code> guard or cascade event.</li> <li><strong>G3 Identity fields</strong>: mascot, house master, points absent from <code>house.schema.ts:9-19</code> - the domain asks for them (B6/B7). <code>(planned)</code>.</li> <li><strong>G4 Events</strong>: no <code>HouseCreated/Updated/Deleted</code> or <code>StudentHouseAssigned</code> events (students module emits <code>StudentCreated/Updated</code> but nothing house-specific, <code>student.service.ts:79-91, 146-155</code>). <code>(planned)</code> if other modules react.</li> <li><strong>G5 List sorting/search</strong>: <code>sort</code>/<code>q</code> params exist (<code>pagination-query.dto.ts:21-29</code>) but houses list ignores them (<code>houses.service.ts:30</code>). <code>(planned)</code>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="10---interaction-specification-houses-module"><a class="header" href="#10---interaction-specification-houses-module">10 - Interaction Specification (Houses Module)</a></h1> <ul> <li><a href="houses/10_Interaction_Specification.html#1-navigation-interactions">1. Navigation interactions</a></li> <li><a href="houses/10_Interaction_Specification.html#2-editor-sheet-interactions">2. Editor sheet interactions</a></li> <li><a href="houses/10_Interaction_Specification.html#3-list-interactions">3. List interactions</a></li> <li><a href="houses/10_Interaction_Specification.html#4-detail-interactions">4. Detail interactions</a></li> <li><a href="houses/10_Interaction_Specification.html#5-motion-budget">5. Motion budget</a></li> <li><a href="houses/10_Interaction_Specification.html#6-offline--degraded-mode-00-shared10-2">6. Offline & degraded mode (00-shared/10 §2)</a></li> <li><a href="houses/10_Interaction_Specification.html#7-permission-gated-interactions">7. Permission-gated interactions</a></li> <li><a href="houses/10_Interaction_Specification.html#8-a11y-interaction-notes-00-shared09">8. a11y interaction notes (00-shared/09)</a></li> </ul> <blockquote> <p>Concrete interaction rules for the Houses module. Motion tokens (<code>m-fast</code>, <code>m-base</code>) and gesture standards from <a href="houses/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a> (Interaction & Motion); accessibility from <a href="houses/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>; offline behaviour from <a href="houses/../00-shared/10_QA_Baseline.html">00-shared/10</a>.</p> </blockquote> <hr /> <h2 id="1-navigation-interactions"><a class="header" href="#1-navigation-interactions">1. Navigation interactions</a></h2> <div class="table-wrapper"><table><thead><tr><th>From</th><th>Gesture</th><th>Result</th><th>Rule</th></tr></thead><tbody> <tr><td>List card</td><td>tap</td><td>push <code>/houses/:id</code></td><td>haptic light on phones</td></tr> <tr><td>List FAB</td><td>tap</td><td>open editor sheet (create)</td><td>gated <code>houses.create</code></td></tr> <tr><td>Detail edit menu</td><td>tap</td><td>open editor sheet (update, prefilled)</td><td>gated <code>houses.update</code></td></tr> <tr><td>Detail delete menu</td><td>tap</td><td>open <code>DeleteHouseDialog</code> (<a href="houses/07_Component_Library.html">07</a> §7)</td><td>gated <code>houses.delete</code></td></tr> <tr><td>Member row</td><td>tap</td><td>push <code>/students/:id</code></td><td>cross-module; deep link back</td></tr> <tr><td>AppBar back</td><td>swipe-back / button</td><td>pop</td><td>standard</td></tr> </tbody></table> </div> <h2 id="2-editor-sheet-interactions"><a class="header" href="#2-editor-sheet-interactions">2. Editor sheet interactions</a></h2> <div class="table-wrapper"><table><thead><tr><th>Action</th><th>Behaviour</th></tr></thead><tbody> <tr><td>Open (create)</td><td>sheet slides up <code>m-base</code> (320 ms); focus Name</td></tr> <tr><td>Open (update)</td><td>same; fields prefilled from loaded house</td></tr> <tr><td>Color swatch tap</td><td>selects; ring indicator <code>m-fast</code>; haptic light</td></tr> <tr><td>Custom hex input</td><td>validates on blur; invalid → field error + shake <code>m-fast</code></td></tr> <tr><td>Submit (Enter / button)</td><td>single-flight; button spinner; fields disabled</td></tr> <tr><td>409 conflict</td><td><code>ConflictFieldError</code> under Code + shake + focus + live-region</td></tr> <tr><td>Dismiss with dirty form</td><td>discard-confirm dialog (first time only, then silent)</td></tr> <tr><td>Success</td><td>sheet closes <code>m-base</code>; list/detail refetch (response-driven, <a href="houses/13_State_Management.html">13</a> §6)</td></tr> </tbody></table> </div> <h2 id="3-list-interactions"><a class="header" href="#3-list-interactions">3. List interactions</a></h2> <div class="table-wrapper"><table><thead><tr><th>Action</th><th>Behaviour</th></tr></thead><tbody> <tr><td>Pull-to-refresh</td><td>refetch page 1 (keep scroll position)</td></tr> <tr><td>Pagination</td><td>footer <code>AppPagination</code>; disabled Prev on page 1, Next when <code>!meta.hasNext</code> (<code>pagination-query.dto.ts:52-53</code>)</td></tr> <tr><td>Card press</td><td>elevation + <code>m-fast</code>; pressed state ≤ 200 ms perceived</td></tr> </tbody></table> </div> <h2 id="4-detail-interactions"><a class="header" href="#4-detail-interactions">4. Detail interactions</a></h2> <div class="table-wrapper"><table><thead><tr><th>Action</th><th>Behaviour</th></tr></thead><tbody> <tr><td>Pull-to-refresh</td><td>refetch house + members section independently (<a href="houses/13_State_Management.html">13</a> §4)</td></tr> <tr><td>Members refresh icon</td><td>refetch students join only; spinner on count badge</td></tr> <tr><td>Delete confirm</td><td>destructive button <code>error</code>; confirm → DELETE → snackbar "House deleted" → pop to list; failure → snackbar + dialog stays</td></tr> </tbody></table> </div> <h2 id="5-motion-budget"><a class="header" href="#5-motion-budget">5. Motion budget</a></h2> <div class="table-wrapper"><table><thead><tr><th>Element</th><th>Token (<a href="houses/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>)</th></tr></thead><tbody> <tr><td>Card appear stagger</td><td><code>m-fast</code> (≤4 rows)</td></tr> <tr><td>Sheet slide / dialog scale</td><td><code>m-base</code></td></tr> <tr><td>Field error shake</td><td><code>m-fast</code></td></tr> <tr><td>Colour swatch selection</td><td><code>m-fast</code>, 120 ms</td></tr> <tr><td>Cross-screen push</td><td>platform default; no custom hero except HouseHeader color bridge (list card swatch → detail banner, <code>m-base</code>)</td></tr> </tbody></table> </div> <h2 id="6-offline--degraded-mode-00-shared10-2"><a class="header" href="#6-offline--degraded-mode-00-shared10-2">6. Offline & degraded mode (<a href="houses/../00-shared/10_QA_Baseline.html">00-shared/10</a> §2)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Mode</th><th>Behaviour</th></tr></thead><tbody> <tr><td>List cached</td><td>render cached houses + <code>AppOfflineBanner</code>; refresh disabled</td></tr> <tr><td>Detail cached</td><td>show from list cache; members section shows stale join or hidden</td></tr> <tr><td>Mutation</td><td>blocked; FAB/menu disabled with tooltip "You're offline"</td></tr> <tr><td>Conflict while offline</td><td>queued? <strong>No</strong> - mutations never queue; user retries manually</td></tr> </tbody></table> </div> <h2 id="7-permission-gated-interactions"><a class="header" href="#7-permission-gated-interactions">7. Permission-gated interactions</a></h2> <div class="table-wrapper"><table><thead><tr><th>Permission</th><th>Visible</th><th>Hidden behaviour</th></tr></thead><tbody> <tr><td><code>houses.read</code> absent</td><td>nothing</td><td>empty state + "contact your admin" (<a href="houses/06_Screen_Specifications.html">06</a> §1.3)</td></tr> <tr><td><code>houses.create</code> absent</td><td>no FAB</td><td>-</td></tr> <tr><td><code>houses.update/delete</code> absent</td><td>no card menu</td><td>-</td></tr> <tr><td>403 on direct deep link</td><td><code>AppEmptyState</code> + back</td><td>never auto-redirect loop</td></tr> </tbody></table> </div> <p>Source: <code>permissions.constants.ts:46-49</code>; server RBAC not wired - client gating only (<code>AGENTS.md</code>).</p> <h2 id="8-a11y-interaction-notes-00-shared09"><a class="header" href="#8-a11y-interaction-notes-00-shared09">8. a11y interaction notes (<a href="houses/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>)</a></h2> <ul> <li>All interactive elements ≥ 48 dp hit area; cards expose <code>onTap</code> semantics.</li> <li>Focus order: header → members → meta; menus traversable by keyboard.</li> <li>Error announcements via live region, never visual-only.</li> <li>Color swatches carry labels; selection announced ("Color Red selected").</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="11---design-system-mapping-houses-module"><a class="header" href="#11---design-system-mapping-houses-module">11 - Design System Mapping (Houses Module)</a></h1> <ul> <li><a href="houses/11_Design_System_Mapping.html#1-token-mapping-from-00-shared02">1. Token mapping (from 00-shared/02)</a></li> <li><a href="houses/11_Design_System_Mapping.html#2-component--token-wiring-from-00-shared03">2. Component → token wiring (from 00-shared/03)</a></li> <li><a href="houses/11_Design_System_Mapping.html#3-house-colour-resolution-critical-path">3. House colour resolution (critical path)</a></li> <li><a href="houses/11_Design_System_Mapping.html#4-preset-palette-8-swatches-for-colorpickerfield">4. Preset palette (8 swatches for <code>ColorPickerField</code>)</a></li> <li><a href="houses/11_Design_System_Mapping.html#5-typography--spacing">5. Typography & spacing</a></li> <li><a href="houses/11_Design_System_Mapping.html#6-motion-tokens">6. Motion tokens</a></li> <li><a href="houses/11_Design_System_Mapping.html#7-dark-mode--theming">7. Dark mode & theming</a></li> </ul> <blockquote> <p>Maps the Houses module onto the shared design system: tokens in <a href="houses/../00-shared/02_Design_Tokens.html">00-shared/02</a> (Design Tokens) and system mapping in <a href="houses/../00-shared/04_Design_System_Mapping.html">00-shared/04</a> (Design System Mapping). The critical module problem: <code>color</code> is a <strong>free string</strong> stored on the server (<code>house.schema.ts:15-16</code>) - the client must map it to design tokens safely.</p> </blockquote> <hr /> <h2 id="1-token-mapping-from-00-shared02"><a class="header" href="#1-token-mapping-from-00-shared02">1. Token mapping (from <a href="houses/../00-shared/02_Design_Tokens.html">00-shared/02</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>House field</th><th>Design token</th><th>Notes</th></tr></thead><tbody> <tr><td>name</td><td><code>text/display</code> (detail) / <code>text/title</code> (card)</td><td>typography scale</td></tr> <tr><td>code</td><td><code>text/label</code> chip</td><td>mono-ish? no - standard label, uppercase</td></tr> <tr><td>motto</td><td><code>text/body</code> italic, <code>color/text-muted</code></td><td>-</td></tr> <tr><td>colour surfaces</td><td>house palette → <code>color/primary</code>-adjacent swatch set</td><td>see §3</td></tr> <tr><td>destructive (delete)</td><td><code>color/error</code></td><td><code>DeleteHouseDialog</code> CTA</td></tr> <tr><td>offline / conflict</td><td><code>color/warning</code> banners</td><td>shared <code>AppOfflineBanner</code></td></tr> </tbody></table> </div> <h2 id="2-component--token-wiring-from-00-shared03"><a class="header" href="#2-component--token-wiring-from-00-shared03">2. Component → token wiring (from <a href="houses/../00-shared/03_Component_Library.html">00-shared/03</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Widget (<a href="houses/07_Component_Library.html">07</a>)</th><th>Token bindings</th></tr></thead><tbody> <tr><td><code>HouseColorCard</code></td><td>swatch strip = resolved house colour; text <code>onSurface</code>; chip <code>surface-variant</code></td></tr> <tr><td><code>HouseHeader</code></td><td>banner = house colour; text <code>onPrimary</code> over scrim</td></tr> <tr><td><code>MemberTile</code></td><td>avatar hue derived from admission number (deterministic, not house colour)</td></tr> <tr><td><code>ColorPickerField</code></td><td>8 preset swatches from house palette (see §4)</td></tr> </tbody></table> </div> <h2 id="3-house-colour-resolution-critical-path"><a class="header" href="#3-house-colour-resolution-critical-path">3. House colour resolution (critical path)</a></h2> <p>Server stores any string (<code>house.schema.ts:15-16</code>); the client must never trust it as a valid <code>Color</code>. Resolver order:</p> <ol> <li>Exact match against a <strong>known palette table</strong> (name or hex) → token colour.</li> <li>Hex parse (<code>#RRGGBB</code>, <code>#RRGGBBAA</code>) → validated; fall through on failure.</li> <li>Unknown/named colour not in table → <strong>deterministic fallback</strong>: hash of <code>code</code> picks a token from the house palette (stable per house, so lists don't shift).</li> <li>Missing <code>color</code> → neutral token (<code>surface-variant</code>).</li> </ol> <pre><code class="language-dart">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 } </code></pre> <ul> <li>Contrast: banner text is always <code>onPrimary</code> over a 24% black scrim - arbitrary user colours pass AA by construction (<code>00-shared/09</code>).</li> </ul> <h2 id="4-preset-palette-8-swatches-for-colorpickerfield"><a class="header" href="#4-preset-palette-8-swatches-for-colorpickerfield">4. Preset palette (8 swatches for <code>ColorPickerField</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Name</th><th>Hex</th><th>Contrast vs white text</th></tr></thead><tbody> <tr><td>Crimson</td><td><code>#BA1A1A</code></td><td>AA+</td></tr> <tr><td>Royal Blue</td><td><code>#0B57D0</code></td><td>AA+</td></tr> <tr><td>Emerald</td><td><code>#146C2E</code></td><td>AA+</td></tr> <tr><td>Gold</td><td><code>#8A5A00</code></td><td>AA+</td></tr> <tr><td>Purple</td><td><code>#5B21B6</code></td><td>AA+</td></tr> <tr><td>Teal</td><td><code>#00695C</code></td><td>AA+</td></tr> <tr><td>Maroon</td><td><code>#7B1E3B</code></td><td>AA+</td></tr> <tr><td>Slate</td><td><code>#455A64</code></td><td>AA+</td></tr> </tbody></table> </div> <p>All swatches resolve through the same resolver (§3) so a stored hex round-trips identically. QA: contrast matrix test in <code>14</code> §6.</p> <h2 id="5-typography--spacing"><a class="header" href="#5-typography--spacing">5. Typography & spacing</a></h2> <div class="table-wrapper"><table><thead><tr><th>Element</th><th>Token</th></tr></thead><tbody> <tr><td>Card name</td><td><code>text/title-medium</code>, weight 600</td></tr> <tr><td>Card motto</td><td><code>text/body-small</code>, 1-line ellipsis</td></tr> <tr><td>Detail name</td><td><code>text/display-small</code></td></tr> <tr><td>Code chip</td><td><code>text/label-large</code>, letter-spacing 0.5</td></tr> <tr><td>Members header</td><td><code>text/title-medium</code> + count <code>AppBadge</code></td></tr> <tr><td>Grid gutters</td><td><code>space/16</code>; card padding <code>space/16</code>; banner height 180</td></tr> </tbody></table> </div> <h2 id="6-motion-tokens"><a class="header" href="#6-motion-tokens">6. Motion tokens</a></h2> <div class="table-wrapper"><table><thead><tr><th>Element</th><th>Token (<code>00-shared/08</code>)</th></tr></thead><tbody> <tr><td>Card stagger</td><td><code>m-fast</code> (150 ms, 40 ms stagger)</td></tr> <tr><td>Sheet/dialog</td><td><code>m-base</code> (320 ms, easeOut)</td></tr> <tr><td>Swatch select</td><td><code>m-fast</code> (120 ms)</td></tr> <tr><td>Hero (swatch → banner)</td><td><code>m-base</code></td></tr> </tbody></table> </div> <h2 id="7-dark-mode--theming"><a class="header" href="#7-dark-mode--theming">7. Dark mode & theming</a></h2> <ul> <li>House colours are brand colours: used identically in light/dark; the scrim keeps AA on both (<code>00-shared/02</code> dark tokens).</li> <li>Fallback neutral in dark = <code>surface-variant-dark</code>.</li> <li>Golden test for the 8 presets × light/dark (<code>00-shared/10</code> §6).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="12---api-mapping-houses-module"><a class="header" href="#12---api-mapping-houses-module">12 - API Mapping (Houses Module)</a></h1> <ul> <li><a href="houses/12_API_Mapping.html#1-houses-housescontrollerts24-52">1. Houses (<code>houses.controller.ts:24-52</code>)</a></li> <li><a href="houses/12_API_Mapping.html#2-assignment-lives-in-students-not-houses">2. Assignment (lives in Students, not Houses)</a></li> <li><a href="houses/12_API_Mapping.html#3-request-examples">3. Request examples</a></li> <li><a href="houses/12_API_Mapping.html#4-response-shapes">4. Response shapes</a></li> <li><a href="houses/12_API_Mapping.html#5-error-map">5. Error map</a></li> <li><a href="houses/12_API_Mapping.html#6-house-document-schema-truth">6. House document (schema truth)</a></li> <li><a href="houses/12_API_Mapping.html#7-planned--not-yet-in-source">7. Planned / not yet in source</a></li> <li><a href="houses/12_API_Mapping.html#8-analytics-contract-proposed">8. Analytics contract <code>(proposed)</code></a></li> </ul> <blockquote> <p>Exact wire contracts for the Houses module. Base path <code>/api/v1</code> (URI versioning, <code>main.ts</code>); all endpoints JWT-guarded (<code>houses.controller.ts:19-20</code>), tenant-scoped via <code>HouseRepository</code> → <code>BaseRepository</code> (<code>house.repository.ts:9-15</code>). Envelopes per <a href="houses/../00-shared/07_API_Conventions.html">00-shared/07</a> §2-3. Permissions from <code>permissions.constants.ts:46-49</code> (RBAC guards on endpoints not yet wired - <code>AGENTS.md</code>).</p> </blockquote> <hr /> <h2 id="1-houses-housescontrollerts24-52"><a class="header" href="#1-houses-housescontrollerts24-52">1. Houses (<code>houses.controller.ts:24-52</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Method</th><th>Path</th><th>Permission</th><th>DTO / source</th><th>Description</th></tr></thead><tbody> <tr><td>POST</td><td><code>/houses</code></td><td><code>houses.create</code></td><td><code>CreateHouseDto</code> (<code>create-house.dto.ts:4-22</code>)</td><td>Create; 409 dup code (<code>houses.service.ts:19-21</code>); unique index <code>{tenantId, code}</code> (<code>house.schema.ts:23</code>)</td></tr> <tr><td>GET</td><td><code>/houses?page=1&limit=20</code></td><td><code>houses.read</code></td><td>-</td><td>List, paginated meta, <strong>no sort</strong> (<code>houses.service.ts:25-34</code>; defaults <code>houses.controller.ts:32</code>)</td></tr> <tr><td>GET</td><td><code>/houses/:id</code></td><td><code>houses.read</code></td><td>-</td><td>Get by ID; 404 <code>House not found.</code> (<code>houses.service.ts:36-40</code>)</td></tr> <tr><td>PATCH</td><td><code>/houses/:id</code></td><td><code>houses.update</code></td><td><code>CreateHouseDto</code> (reused - <code>houses.controller.ts:44</code>)</td><td>Update via <code>$set</code>; 404 (<code>houses.service.ts:42-49</code>); <strong>no dup check</strong> (index backs it)</td></tr> <tr><td>DELETE</td><td><code>/houses/:id</code></td><td><code>houses.delete</code></td><td>-</td><td>Soft delete (<code>houses.service.ts:51-54</code>); <strong>no member guard</strong>; 404</td></tr> </tbody></table> </div> <h2 id="2-assignment-lives-in-students-not-houses"><a class="header" href="#2-assignment-lives-in-students-not-houses">2. Assignment (lives in Students, not Houses)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Method</th><th>Path</th><th>Permission</th><th>DTO / source</th><th>Description</th></tr></thead><tbody> <tr><td>POST</td><td><code>/students</code></td><td><code>student.create</code></td><td><code>CreateStudentDto</code> incl. optional <code>houseId</code> (<code>create-student.dto.ts:39-42</code>)</td><td>Create student with house</td></tr> <tr><td>PATCH</td><td><code>/students/:id</code></td><td><code>student.update</code></td><td><code>UpdateStudentDto</code> incl. optional <code>houseId</code> (<code>update-student.dto.ts:45-48</code>)</td><td>Reassign house (<code>student.service.ts:142-155</code>)</td></tr> </tbody></table> </div> <ul> <li>Transfer does <strong>not</strong> touch <code>houseId</code> (<code>student.service.ts:185-192</code>).</li> <li>Reference: <code>student.schema.ts:41-42</code> (<code>houseId</code> → <code>ref: 'House'</code>, optional).</li> </ul> <h2 id="3-request-examples"><a class="header" href="#3-request-examples">3. Request examples</a></h2> <pre><code class="language-json">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 </code></pre> <h2 id="4-response-shapes"><a class="header" href="#4-response-shapes">4. Response shapes</a></h2> <ul> <li>List: <code>{ data: HouseDoc[], meta: { page, limit, totalItems, totalPages, hasNext, hasPrevious } }</code> (<code>buildPaginationMeta</code>, <code>pagination-query.dto.ts:41-55</code>).</li> <li>Single/created/updated: <code>{ data: HouseDoc }</code> (envelope interceptor, <code>00-shared/07</code>).</li> <li>Delete: 200/204 envelope, no body (<code>houses.service.ts:51-54</code>).</li> <li>House doc fields: <code>_id, name, code, color?, motto?, tenantId, createdBy?, updatedBy?, isDeleted, deletedAt?, deletedBy?, version, createdAt, updatedAt</code> (<code>house.schema.ts:8-19</code>, <code>base.schema.ts:8-34</code>).</li> </ul> <h2 id="5-error-map-1"><a class="header" href="#5-error-map-1">5. Error map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Code</th><th>Meaning</th><th>Source</th></tr></thead><tbody> <tr><td>401</td><td>unauthenticated</td><td><code>JwtAuthGuard</code> (<code>houses.controller.ts:19</code>)</td></tr> <tr><td>404</td><td>house missing</td><td><code>houses.service.ts:38, 47, 53</code></td></tr> <tr><td>409</td><td>duplicate code (create)</td><td><code>houses.service.ts:19-21</code></td></tr> <tr><td>409</td><td>duplicate code (update, via unique index)</td><td><code>house.schema.ts:23</code> - not service-checked (<code>08</code> §3)</td></tr> <tr><td>400</td><td>DTO validation (class-validator)</td><td><code>create-house.dto.ts</code></td></tr> <tr><td>500</td><td>any Mongo error not mapped</td><td>e.g. E11000 race on create (<code>14</code> §2.4)</td></tr> </tbody></table> </div> <h2 id="6-house-document-schema-truth"><a class="header" href="#6-house-document-schema-truth">6. House document (schema truth)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type</th><th>Required</th><th>Notes</th></tr></thead><tbody> <tr><td>name</td><td>String</td><td>✅</td><td>trim (<code>house.schema.ts:9-10</code>)</td></tr> <tr><td>code</td><td>String</td><td>✅</td><td>trim; unique per tenant (<code>house.schema.ts:12-13, 23</code>)</td></tr> <tr><td>color</td><td>String</td><td>❌</td><td>free string (<code>house.schema.ts:15-16</code>)</td></tr> <tr><td>motto</td><td>String</td><td>❌</td><td>(<code>house.schema.ts:18-19</code>)</td></tr> </tbody></table> </div> <h2 id="7-planned--not-yet-in-source"><a class="header" href="#7-planned--not-yet-in-source">7. Planned / not yet in source</a></h2> <div class="table-wrapper"><table><thead><tr><th>Path / contract</th><th>Status</th><th>Source</th></tr></thead><tbody> <tr><td><code>GET /students?houseId=</code> filter or <code>GET /houses/:id/members</code></td><td><code>(planned)</code></td><td><code>student.service.ts:106</code> filters <code>{}</code>; no endpoint exists</td></tr> <tr><td>Delete guard / cascade on <code>DELETE /houses/:id</code></td><td><code>(planned)</code></td><td><code>houses.service.ts:51-54</code> no check; <code>student.schema.ts:41-42</code></td></tr> <tr><td><code>sort</code>/<code>q</code> on <code>GET /houses</code></td><td><code>(planned)</code></td><td>params exist (<code>pagination-query.dto.ts:21-29</code>), unused (<code>houses.service.ts:30</code>)</td></tr> <tr><td>Mascot / house master / points fields</td><td><code>(planned)</code></td><td>absent from <code>house.schema.ts:9-19</code></td></tr> <tr><td>House domain events</td><td><code>(planned)</code></td><td>houses service emits none</td></tr> <tr><td>Bulk house import</td><td><code>(planned)</code></td><td><code>IMPLEMENTATION_PLAN.md:172</code> (bulk import framework)</td></tr> <tr><td>QR member check-in, push results</td><td><code>(forward-looking)</code></td><td>no contract in source</td></tr> </tbody></table> </div> <h2 id="8-analytics-contract-proposed"><a class="header" href="#8-analytics-contract-proposed">8. Analytics contract <code>(proposed)</code></a></h2> <p><code>houses.list.*</code>, <code>houses.detail.*</code>, <code>houses.editor.*</code> events per <code>00-shared/10</code> §8 - not implemented on any client or server (SDK open).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="13---state-management-houses-module"><a class="header" href="#13---state-management-houses-module">13 - State Management (Houses Module)</a></h1> <ul> <li><a href="houses/13_State_Management.html#1-cubit-map">1. Cubit map</a></li> <li><a href="houses/13_State_Management.html#2-houselistcubit">2. HouseListCubit</a></li> <li><a href="houses/13_State_Management.html#3-houseformcubit-create--edit-share-shape">3. HouseFormCubit (create + edit share shape)</a></li> <li><a href="houses/13_State_Management.html#4-housedetailcubit--housememberscubit">4. HouseDetailCubit + HouseMembersCubit</a></li> <li><a href="houses/13_State_Management.html#5-cross-cutting">5. Cross-cutting</a></li> </ul> <blockquote> <p>Per-screen Cubit/Bloc design on top of <a href="houses/../00-shared/06_State_Management.html">00-shared/06</a> conventions (stack: <code>flutter_bloc</code> + <code>get_it</code>; server state via dio repository; <code>LoadState</code> = Initial/Loading/Success/Error(ApiException)). Mermaid diagrams included.</p> </blockquote> <hr /> <h2 id="1-cubit-map-2"><a class="header" href="#1-cubit-map-2">1. Cubit map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Cubit</th><th>Screen (<a href="houses/05_Screen_Inventory.html">05</a>)</th><th>Data</th></tr></thead><tbody> <tr><td><code>HouseListCubit</code></td><td>1</td><td>page, limit, <code>List<House></code>, <code>PaginationMeta</code></td></tr> <tr><td><code>HouseDetailCubit</code></td><td>2</td><td><code>House</code>, members (joined list + verified flag)</td></tr> <tr><td><code>HouseFormCubit</code></td><td>3</td><td>form model, field errors, conflicts, submit</td></tr> <tr><td><code>HouseMembersCubit</code></td><td>2 (members section)</td><td><code>List<StudentBrief></code> from students join, <code>(planned)</code> server endpoint swap</td></tr> </tbody></table> </div> <p>Repositories (<code>HouseRepository</code> in <code>features/houses/data/</code>) are the only layer touching HTTP; they map envelopes to models and throw <code>ApiException(status, message)</code> (<a href="houses/../00-shared/06_State_Management.html">00-shared/06</a> §2-3). Student data is fetched via the students module's repository (cross-module data reuse, never via the houses repository).</p> <h2 id="2-houselistcubit"><a class="header" href="#2-houselistcubit">2. HouseListCubit</a></h2> <pre class="mermaid">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) </pre> <ul> <li>Fetch: <code>GET /houses?page&limit</code> (<code>houses.controller.ts:30-34</code>); <code>meta</code> drives pagination (<code>buildPaginationMeta</code>, <code>pagination-query.dto.ts:41-55</code>).</li> <li>No server sort (<code>houses.service.ts:30</code>) - client sorts by <code>createdAt</code> asc for stable card order.</li> <li>Mutations never optimistically mutate the grid; after create/delete the cubit refetches page 1 (response-driven; keeps conflict surfaces honest).</li> </ul> <h2 id="3-houseformcubit-create--edit-share-shape"><a class="header" href="#3-houseformcubit-create--edit-share-shape">3. HouseFormCubit (create + edit share shape)</a></h2> <pre class="mermaid">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) </pre> <ul> <li>State fields: <code>saved: bool</code>, <code>submitting: bool</code> (single-flight), <code>conflicts: Map<String, String></code> (server 409 verbatim).</li> <li><code>toDto()</code> always returns the <strong>full</strong> field set (PATCH requires it, <code>houses.controller.ts:44</code>, <code>08</code> §4).</li> <li>Update-mode duplicate code surfaces as generic 500/E11000 from the index (<code>house.schema.ts:23</code>) - error mapper normalizes to conflict copy (<code>08</code> §3).</li> </ul> <h2 id="4-housedetailcubit--housememberscubit"><a class="header" href="#4-housedetailcubit--housememberscubit">4. HouseDetailCubit + HouseMembersCubit</a></h2> <pre class="mermaid">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 [] </pre> <ul> <li>House fetch: <code>GET /houses/:id</code> (<code>houses.controller.ts:36-40</code>); 404 <code>House not found.</code> (<code>houses.service.ts:38</code>).</li> <li>Members: client join today - <code>GET /students?page&limit</code> (<code>student.controller.ts:41-43</code>) then <code>where(houseId == id)</code>; <code>verified: false</code> (<code>07</code> §5). Swap to <code>(planned)</code> <code>GET /students?houseId=</code> / members endpoint with zero cubit change if the repository abstracts it.</li> <li>Members state is independent: one section's failure never blanks the header (per-section <code>LoadState</code>, <code>06</code> §2.3).</li> </ul> <h2 id="5-cross-cutting-2"><a class="header" href="#5-cross-cutting-2">5. Cross-cutting</a></h2> <ul> <li><strong>Cache</strong>: house list cached in memory (<code>Hive</code> optional) per tenant; detail reads cache-first then refreshes (offline tolerance, <code>00-shared/10</code> §2). Members join is <strong>never cached as truth</strong> - only the filtered snapshot with <code>verified: false</code>.</li> <li><strong>Ref resolution (student forms)</strong>: house picker options come from <code>HouseListCubit</code> cache or <code>GET /houses</code>; no per-picker fetch.</li> <li><strong>Events as hints</strong>: no house domain events exist (G4, <code>09</code>); client refresh is response-driven, not event-driven. If <code>HouseUpdated</code> events land <code>(planned)</code>, the detail cubit can refresh on hint - do not build the plumbing now.</li> <li><strong>Permission gating</strong>: cubits expose <code>canCreate/canUpdate/canDelete</code> from RBAC (<code>permissions.constants.ts:46-49</code>); UI hides actions accordingly (<code>10</code> §7).</li> <li><strong>Planned cubits</strong>: <code>HousePointsCubit</code> / <code>HouseEventsCubit</code> <code>(planned)</code> - no data contract; do not scaffold (<code>IMPLEMENTATION_PLAN.md</code> has no houses items).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14---qa-checklist-houses-module"><a class="header" href="#14---qa-checklist-houses-module">14 - QA Checklist (Houses Module)</a></h1> <ul> <li><a href="houses/14_QA_Checklist.html#1-core-flows-must-pass">1. Core flows (must pass)</a></li> <li><a href="houses/14_QA_Checklist.html#2-validation--conflicts-negative-paths">2. Validation & conflicts (negative paths)</a></li> <li><a href="houses/14_QA_Checklist.html#3-reassignment--transfer-semantics">3. Reassignment & transfer semantics</a></li> <li><a href="houses/14_QA_Checklist.html#4-soft-delete--reference-integrity-the-big-gap">4. Soft-delete & reference integrity (the big gap)</a></li> <li><a href="houses/14_QA_Checklist.html#5-multi-tenancy">5. Multi-tenancy</a></li> <li><a href="houses/14_QA_Checklist.html#6-uiux-manual--golden">6. UI/UX (manual + golden)</a></li> <li><a href="houses/14_QA_Checklist.html#7-e2e-regression-seeds-manual-fixtures">7. E2E regression seeds (manual fixtures)</a></li> <li><a href="houses/14_QA_Checklist.html#8-backend-follow-ups-flagged-to-engineering">8. Backend follow-ups (flagged to engineering)</a></li> </ul> <blockquote> <p>Module-specific quality gates on top of <a href="houses/../00-shared/10_QA_Baseline.html">00-shared/10</a> (QA baseline: performance budgets, offline matrix, a11y, motion, security). Every item is testable and maps to source.</p> </blockquote> <hr /> <h2 id="1-core-flows-must-pass"><a class="header" href="#1-core-flows-must-pass">1. Core flows (must pass)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expectation</th><th>Source</th></tr></thead><tbody> <tr><td>1.1</td><td>Create house → appears in list</td><td>POST 201; doc in GET list, <code>createdAt</code> order (no server sort)</td><td><code>houses.controller.ts:24-34</code>, <code>houses.service.ts:30</code></td></tr> <tr><td>1.2</td><td>Create house with only name+code</td><td>201; <code>color</code>/<code>motto</code> absent (optional, <code>create-house.dto.ts:13-21</code>)</td><td><code>house.schema.ts:15-19</code></td></tr> <tr><td>1.3</td><td>House detail → identity fields render</td><td>name, code, color, motto round-trip verbatim (trim only)</td><td><code>houses.service.ts:36-40</code>, <code>house.schema.ts:9-19</code></td></tr> <tr><td>1.4</td><td>Update house → PATCH applies</td><td><code>$set</code> full DTO; untouched fields stable</td><td><code>houses.service.ts:42-49</code>, <code>houses.controller.ts:44</code></td></tr> <tr><td>1.5</td><td>Soft delete → list excludes it</td><td><code>isDeleted: true</code> filtered by scoped query</td><td><code>houses.service.ts:51-54</code>, <code>base.repository.ts</code></td></tr> <tr><td>1.6</td><td>Reassign student house via <code>PATCH /students/:id</code></td><td><code>houseId</code> changes; student detail reflects</td><td><code>update-student.dto.ts:45-48</code>, <code>student.service.ts:142-155</code></td></tr> </tbody></table> </div> <h2 id="2-validation--conflicts-negative-paths"><a class="header" href="#2-validation--conflicts-negative-paths">2. Validation & conflicts (negative paths)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expectation</th><th>Source</th></tr></thead><tbody> <tr><td>2.1</td><td>Create with duplicate <strong>code</strong></td><td>409 verbatim <code>House code "X" already exists.</code></td><td><code>houses.service.ts:19-21</code>; index <code>house.schema.ts:23</code></td></tr> <tr><td>2.2</td><td>Create with duplicate <strong>name</strong> (different code)</td><td><strong>Allowed</strong> (no check/index) - document as by-design; UI soft-warns</td><td><code>houses.service.ts:19-21</code> checks code only</td></tr> <tr><td>2.3</td><td>Duplicate <strong>color</strong> across houses</td><td>Allowed - palette conflict is a UX concern, not API error</td><td><code>house.schema.ts:15-16</code></td></tr> <tr><td>2.4</td><td>Concurrent create of same code (race)</td><td>One 201, other → Mongo E11000 → 500 as wired today; <strong>gap</strong>: should map to 409 <code>(planned)</code></td><td><code>house.schema.ts:23</code>, error filter</td></tr> <tr><td>2.5</td><td>Update code to an existing one</td><td>No service check (<code>houses.service.ts:42-49</code>); unique index throws → generic error; client normalizes to conflict copy (<a href="houses/08_Form_Specifications.html">08</a> §3)</td><td>gap</td></tr> <tr><td>2.6</td><td>Missing name or code</td><td>400 (class-validator <code>@IsString</code> required)</td><td><code>create-house.dto.ts:5-11</code></td></tr> <tr><td>2.7</td><td>Invalid color (e.g. <code>blue!</code>)</td><td><strong>Accepted</strong> (free string) - client must validate hex before send</td><td><code>create-house.dto.ts:13-16</code></td></tr> <tr><td>2.8</td><td>Unauthenticated call</td><td>401</td><td><code>houses.controller.ts:19-20</code></td></tr> </tbody></table> </div> <h2 id="3-reassignment--transfer-semantics"><a class="header" href="#3-reassignment--transfer-semantics">3. Reassignment & transfer semantics</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expectation</th><th>Source</th></tr></thead><tbody> <tr><td>3.1</td><td>Transfer student (class change)</td><td><code>houseId</code> <strong>unchanged</strong> (transfer <code>$set</code> excludes house)</td><td><code>student.service.ts:185-192</code></td></tr> <tr><td>3.2</td><td>Clear a student's house</td><td>PATCH with <code>houseId</code> omitted/null - field optional</td><td><code>update-student.dto.ts:45-48</code></td></tr> <tr><td>3.3</td><td>Set house on create</td><td><code>houseId</code> <code>IsMongoId</code> accepted</td><td><code>create-student.dto.ts:39-42</code></td></tr> <tr><td>3.4</td><td><code>houseId</code> pointing at a <strong>deleted house</strong></td><td>Allowed; no referential validation - students show "house unavailable"; client must render fallback</td><td>gap (<a href="houses/09_User_Behaviour.html">09</a> G2)</td></tr> <tr><td>3.5</td><td>House member count after reassignment</td><td>Changes only after refetch (client join) - expect <code>~</code> unverified count</td><td><a href="houses/07_Component_Library.html">07</a> §5, <a href="houses/13_State_Management.html">13</a> §4</td></tr> </tbody></table> </div> <h2 id="4-soft-delete--reference-integrity-the-big-gap"><a class="header" href="#4-soft-delete--reference-integrity-the-big-gap">4. Soft-delete & reference integrity (the big gap)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expectation</th><th>Source</th></tr></thead><tbody> <tr><td>4.1</td><td>Delete house with members</td><td><strong>No 409, no cascade</strong> - soft delete succeeds; students keep dangling <code>houseId</code> (<code>student.schema.ts:41-42</code>). Client warns pre-delete (<a href="houses/07_Component_Library.html">07</a> §7); server guard <code>(planned)</code></td><td><code>houses.service.ts:51-54</code></td></tr> <tr><td>4.2</td><td>Delete already-deleted house</td><td>404 (scoped filter excludes deleted)</td><td><code>houses.service.ts:53</code></td></tr> <tr><td>4.3</td><td>Recreate house with same code after delete</td><td><strong>Allowed</strong> (soft-deleted row excluded by scoped query) - old students still point at the deleted <code>_id</code>, new house gets a new <code>_id</code>: historical membership silently orphaned. Document + test</td><td><code>base.repository.ts</code> scoped filter</td></tr> <tr><td>4.4</td><td>Detail of deleted house via stale link</td><td>404 → "House not found" UX</td><td><code>houses.service.ts:38</code></td></tr> </tbody></table> </div> <h2 id="5-multi-tenancy"><a class="header" href="#5-multi-tenancy">5. Multi-tenancy</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expectation</th><th>Source</th></tr></thead><tbody> <tr><td>5.1</td><td>Tenant B queries houses</td><td>sees only B's rows</td><td><code>base.repository.ts</code> scoped filter (<code>house.repository.ts:9-15</code>)</td></tr> <tr><td>5.2</td><td>Cross-tenant duplicate codes</td><td>allowed - uniqueness per tenant</td><td><code>house.schema.ts:23</code></td></tr> <tr><td>5.3</td><td><code>tenantId</code> in request body</td><td>ignored - server never reads it from body</td><td><code>AGENTS.md</code></td></tr> </tbody></table> </div> <h2 id="6-uiux-manual--golden"><a class="header" href="#6-uiux-manual--golden">6. UI/UX (manual + golden)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th></tr></thead><tbody> <tr><td>6.1</td><td>Pagination boundaries: page 1 Prev disabled; last page Next disabled (<code>meta.hasNext/hasPrevious</code>)</td></tr> <tr><td>6.2</td><td>409 inline error under Code; focus + live-region</td></tr> <tr><td>6.3</td><td>PATCH full-set prefill: edit opens with all 4 fields filled (<a href="houses/08_Form_Specifications.html">08</a> §4)</td></tr> <tr><td>6.4</td><td>Color round-trip: swatch selected → stored hex → detail banner identical (<a href="houses/11_Design_System_Mapping.html">11</a> §3-4)</td></tr> <tr><td>6.5</td><td>Contrast matrix: 8 presets × light/dark × white text ≥ AA (<a href="houses/11_Design_System_Mapping.html">11</a> §4)</td></tr> <tr><td>6.6</td><td>Unverified count shows <code>~</code> + tooltip; verified count shows exact</td></tr> <tr><td>6.7</td><td>Offline: cached list + banner; mutations blocked (<a href="houses/../00-shared/10_QA_Baseline.html">00-shared/10</a> §2)</td></tr> <tr><td>6.8</td><td>Empty states: list, detail members, search-less grid</td></tr> <tr><td>6.9</td><td>Permission gating: read-only role sees no FAB/menu; 403 copy on deep link</td></tr> <tr><td>6.10</td><td>Skeleton < 200 ms perceived; content < 2 s on network (<a href="houses/../00-shared/10_QA_Baseline.html">00-shared/10</a> §1)</td></tr> <tr><td>6.11</td><td>Delete dialog copy accurate: "members keep their house reference" (no fake cascade promise, <a href="houses/09_User_Behaviour.html">09</a> §2)</td></tr> </tbody></table> </div> <h2 id="7-e2e-regression-seeds-manual-fixtures"><a class="header" href="#7-e2e-regression-seeds-manual-fixtures">7. E2E regression seeds (manual fixtures)</a></h2> <ul> <li>2 tenants, identical house codes (proves 5.2 isolation).</li> <li>House with a <code>#</code>-less color value and one with <code>color</code> missing (fallback path, <a href="houses/11_Design_System_Mapping.html">11</a> §3).</li> <li>Student assigned to a house that is then soft-deleted (4.1, 4.3 orphan check).</li> <li>3 houses with the same name, different codes (2.2 by-design duplicates).</li> <li>Duplicate-code create race reproduced by two parallel POSTs (2.4).</li> </ul> <h2 id="8-backend-follow-ups-flagged-to-engineering"><a class="header" href="#8-backend-follow-ups-flagged-to-engineering">8. Backend follow-ups (flagged to engineering)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Item</th><th>Priority</th></tr></thead><tbody> <tr><td>1</td><td>Map E11000 race to 409 on create (2.4)</td><td>High</td></tr> <tr><td>2</td><td>Duplicate-code check on update (2.5)</td><td>Medium</td></tr> <tr><td>3</td><td><code>GET /students?houseId=</code> or members endpoint (G1)</td><td>High</td></tr> <tr><td>4</td><td>Delete guard / cascade for assigned students (G2)</td><td>High</td></tr> <tr><td>5</td><td><code>sort</code>/<code>q</code> on houses list (G5)</td><td>Low</td></tr> <tr><td>6</td><td>House events + RBAC guards on endpoints (<code>IMPLEMENTATION_PLAN.md:239</code> audit)</td><td>Medium</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="15---flutter-implementation-guide-houses-module"><a class="header" href="#15---flutter-implementation-guide-houses-module">15 - Flutter Implementation Guide (Houses Module)</a></h1> <ul> <li><a href="houses/15_Flutter_Implementation_Guide.html#1-feature-folder">1. Feature folder</a></li> <li><a href="houses/15_Flutter_Implementation_Guide.html#2-model-mirror-schema-exactly">2. Model (mirror schema exactly)</a></li> <li><a href="houses/15_Flutter_Implementation_Guide.html#3-repository-dio">3. Repository (dio)</a></li> <li><a href="houses/15_Flutter_Implementation_Guide.html#4-cubits">4. Cubits</a></li> <li><a href="houses/15_Flutter_Implementation_Guide.html#5-screens">5. Screens</a></li> <li><a href="houses/15_Flutter_Implementation_Guide.html#6-validation-client-mirror-of-dtos">6. Validation (client mirror of DTOs)</a></li> <li><a href="houses/15_Flutter_Implementation_Guide.html#7-navigation-go_router">7. Navigation (go_router)</a></li> <li><a href="houses/15_Flutter_Implementation_Guide.html#8-di-registration-get_it">8. DI registration (get_it)</a></li> <li><a href="houses/15_Flutter_Implementation_Guide.html#9-permissions--auth">9. Permissions & auth</a></li> <li><a href="houses/15_Flutter_Implementation_Guide.html#10-testing">10. Testing</a></li> <li><a href="houses/15_Flutter_Implementation_Guide.html#11-forward-looking-hooks">11. Forward-looking hooks</a></li> </ul> <blockquote> <p>Module-specific build guide on top of <a href="houses/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a> (Flutter architecture, get_it DI, dio, go_router, flutter_bloc) and <a href="houses/../00-shared/06_State_Management.html">00-shared/06</a> (state patterns). Order = recommended implementation sequence; each step maps to source files.</p> </blockquote> <hr /> <h2 id="1-feature-folder-1"><a class="header" href="#1-feature-folder-1">1. Feature folder</a></h2> <pre><code>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 </code></pre> <p>Widgets per <code>07_Component_Library.md</code>; screens per <code>05</code>/<code>06</code>. Students data lives in <code>lib/features/students/</code> - houses imports its repository for the member join (<code>13</code> §4) but owns no student model copy.</p> <h2 id="2-model-mirror-schema-exactly"><a class="header" href="#2-model-mirror-schema-exactly">2. Model (mirror schema exactly)</a></h2> <p><code>House</code> - <code>house.schema.ts:8-19</code> + <code>base.schema.ts:8-34</code>: <code>id (String ObjectId), name, code, color?, motto?</code>, plus <code>createdAt/updatedAt</code> (DateTime, server sends strings - parse defensively) and <code>version</code> (int, read-only).</p> <p>Immutable class + <code>fromJson</code>/<code>toJson</code> (repository maps envelope <code>data</code>). Never send <code>tenantId</code>/<code>isDeleted</code>/<code>version</code> (<code>base.schema.ts:10-11, 20-21, 30-31</code>).</p> <h2 id="3-repository-dio"><a class="header" href="#3-repository-dio">3. Repository (dio)</a></h2> <ul> <li>Endpoints per <code>12_API_Mapping.md</code>; base <code>/api/v1</code> + <code>ApiBearerAuth</code> (<code>houses.controller.ts:17-20</code>).</li> <li><code>fetch(page, limit)</code> → parse <code>data</code> + <code>meta {page, limit, totalItems, totalPages, hasNext, hasPrevious}</code> (<code>pagination-query.dto.ts:41-55</code>).</li> <li><code>create(dto)</code> / <code>update(id, dto)</code> / <code>getById(id)</code> / <code>remove(id)</code>.</li> <li>Typed errors: <code>ApiException(409, message)</code> - message verbatim for <code>ConflictFieldError</code> (<code>houses.service.ts:19-21</code>); <code>ApiException(404)</code> for detail.</li> <li>Update sends the full DTO body (PATCH = <code>CreateHouseDto</code>, <code>houses.controller.ts:44</code>).</li> <li>Member join: separate students repository call (cross-module), single-flight per <code>00-shared/11</code> "AppDio".</li> </ul> <h2 id="4-cubits"><a class="header" href="#4-cubits">4. Cubits</a></h2> <p>Per <code>13_State_Management.md</code>: implement <code>HouseListCubit</code> first (pagination pattern), then <code>HouseFormCubit</code> (with <code>conflicts</code> map), then <code>HouseDetailCubit</code> + <code>HouseMembersCubit</code>. Use shared <code>LoadState</code> sealed class and pagination mixin (<code>00-shared/06</code> §3.1-3.2).</p> <h2 id="5-screens"><a class="header" href="#5-screens">5. Screens</a></h2> <div class="table-wrapper"><table><thead><tr><th>Order</th><th>Screen</th><th>Key widgets</th><th>Source</th></tr></thead><tbody> <tr><td>1</td><td>Houses list</td><td>HouseColorCard grid, AppPagination, FAB</td><td><code>06</code> §1</td></tr> <tr><td>2</td><td>House editor (sheet/dialog)</td><td>ColorPickerField, ConflictFieldError</td><td><code>06</code> §3</td></tr> <tr><td>3</td><td>House detail</td><td>HouseHeader, HouseCountBadge, MemberTile</td><td><code>06</code> §2</td></tr> </tbody></table> </div> <h2 id="6-validation-client-mirror-of-dtos"><a class="header" href="#6-validation-client-mirror-of-dtos">6. Validation (client mirror of DTOs)</a></h2> <ul> <li>Form rules per <code>08</code> §2: name/code required <code>IsString</code> (<code>create-house.dto.ts:5-11</code>); color optional string, client enforces <code>#RRGGBB</code> (<code>create-house.dto.ts:13-16</code>); motto optional (<code>:18-21</code>).</li> <li>Conflict messages: render server text verbatim (<code>07</code> §6).</li> <li>Color resolver before any paint: <code>11</code> §3 (<code>resolveHouseColor</code>).</li> </ul> <h2 id="7-navigation-go_router-1"><a class="header" href="#7-navigation-go_router-1">7. Navigation (go_router)</a></h2> <pre><code>/houses list (auth + houses.read) /houses/:id detail | deep-linkable </code></pre> <p>Create/edit forms = bottom sheets (phone) / dialogs (tablet), not routes (<code>00-shared/03</code>). Member rows deep-link to <code>/students/:id</code> (students module). Planned: <code>/houses/:id/members</code> standalone <code>(planned)</code> when server endpoint lands.</p> <h2 id="8-di-registration-get_it"><a class="header" href="#8-di-registration-get_it">8. DI registration (get_it)</a></h2> <pre><code class="language-dart">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) </code></pre> <h2 id="9-permissions--auth"><a class="header" href="#9-permissions--auth">9. Permissions & auth</a></h2> <ul> <li>Gate FAB/menu on RBAC (<code>permissions.constants.ts:46-49</code>); 403 handling per <code>00-shared/07</code>; JWT via <code>AppDio</code> interceptor (<code>00-shared/11</code>).</li> <li>Note: RBAC guards on endpoints are not yet wired in the backend (<code>AGENTS.md</code> "Not yet implemented") - client still gates UI.</li> </ul> <h2 id="10-testing-5"><a class="header" href="#10-testing-5">10. Testing</a></h2> <ul> <li>Unit: cubit state transitions incl. 409 → <code>Conflict</code>, 404 → <code>NotFound</code> + pop, members section failure isolation (<code>13</code> §2-4); <code>resolveHouseColor</code> fallback paths (<code>11</code> §3); model <code>fromJson</code> for absent color/motto.</li> <li>Widget: golden per screen (<code>00-shared/10</code> §6); color contrast matrix (<code>14</code> §6.5); delete dialog copy.</li> <li>Integration: mock dio with fixture envelopes; pagination boundary tests.</li> <li>E2E seeds per <code>14</code> §7.</li> </ul> <h2 id="11-forward-looking-hooks"><a class="header" href="#11-forward-looking-hooks">11. Forward-looking hooks</a></h2> <ul> <li>Points / leaderboard / events screens <code>(planned)</code> - no backend contract (<code>IMPLEMENTATION_PLAN.md</code> has no houses items); do NOT scaffold ahead of the schema (<code>house.schema.ts:9-19</code>).</li> <li>QR member check-in <code>(forward-looking)</code>: <code>mobile_scanner</code> dependency flagged in <code>00-shared/11</code> §1.</li> <li>Push on house events <code>(forward-looking)</code>: notification channels undefined.</li> <li>Analytics events <code>houses.*</code> <code>(proposed)</code> (<code>05</code> Analytics section).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-i18n-module"><a class="header" href="#01--product-overview-i18n-module">01 — Product Overview (i18n Module)</a></h1> <ul> <li><a href="i18n/01_Product_Overview.html#1-what-this-module-is">1. What this module is</a></li> <li><a href="i18n/01_Product_Overview.html#2-what-it-is-today-implemented">2. What it is today (implemented)</a></li> <li><a href="i18n/01_Product_Overview.html#3-storage-model">3. Storage model</a></li> <li><a href="i18n/01_Product_Overview.html#4-product-value">4. Product value</a></li> <li><a href="i18n/01_Product_Overview.html#5-scope-boundary--prd-native-app-exclusion">5. Scope boundary — PRD native-app exclusion</a></li> <li><a href="i18n/01_Product_Overview.html#6-non-goals-today">6. Non-goals (today)</a></li> <li><a href="i18n/01_Product_Overview.html#7-definitions">7. Definitions</a></li> </ul> <blockquote> <p>Backend + app-facing view of StudyLyon's internationalization. Grounded in <code>src/shared/i18n/</code> and <code>src/modules/i18n/</code>; shared conventions per <a href="i18n/../00-shared/01_Product_Overview.html">00-shared/01</a>, <a href="i18n/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a>.</p> </blockquote> <hr /> <h2 id="1-what-this-module-is"><a class="header" href="#1-what-this-module-is">1. What this module is</a></h2> <p>StudyLyon ships an i18n framework: a request-scoped locale resolver (<code>I18nMiddleware</code>), 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.</p> <h2 id="2-what-it-is-today-implemented"><a class="header" href="#2-what-it-is-today-implemented">2. What it is today (implemented)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Capability</th><th>Status</th><th>Source</th></tr></thead><tbody> <tr><td>Locale resolution from <code>Accept-Language</code></td><td>live</td><td><code>src/shared/i18n/i18n.middleware.ts:13-18</code></td></tr> <tr><td>Translation lookup + <code>{param}</code> interpolation</td><td>live</td><td><code>src/shared/i18n/i18n.service.ts:32-45</code></td></tr> <tr><td>Message catalog endpoint</td><td>live</td><td><code>src/modules/i18n/i18n.controller.ts:13-17</code></td></tr> <tr><td>Catalogs: <code>en</code>, <code>hi</code> shipped</td><td>live</td><td><code>src/shared/i18n/translations/</code></td></tr> <tr><td>7 further locales declared</td><td>planned (data-only)</td><td><code>i18n.service.ts:9-21</code></td></tr> <tr><td>Admin translation editor / key CRUD</td><td><strong>(planned)</strong></td><td>no code — <code>IMPLEMENTATION_PLAN.md:171</code></td></tr> <tr><td>Crowdin + CI drift check</td><td><strong>(planned)</strong></td><td><code>IMPLEMENTATION_PLAN.md:845</code></td></tr> </tbody></table> </div> <h2 id="3-storage-model"><a class="header" href="#3-storage-model">3. Storage model</a></h2> <p>Translations are <strong>static JSON files</strong> compiled into an in-memory <code>CATALOGS</code> record at boot (<code>i18n.service.ts:6</code>). There is <strong>no MongoDB collection</strong> for translations, and catalogs are <strong>global</strong> — not tenant-scoped (deliberate: UI strings are product-owned, tenant data is not). See <a href="i18n/04_Information_Architecture.html">04_Information_Architecture.md</a>.</p> <h2 id="4-product-value"><a class="header" href="#4-product-value">4. Product value</a></h2> <ul> <li>One codebase, 9 target Indian locales (en, hi, ta, te, kn, ml, gu, mr, bn).</li> <li>API responses, notification copy and (planned) app UI in the user's language without per-tenant translation data.</li> <li>Fallback chain guarantees a string is always returned, even when a translation is missing (<code>i18n.service.ts:40</code>).</li> </ul> <h2 id="5-scope-boundary--prd-native-app-exclusion"><a class="header" href="#5-scope-boundary--prd-native-app-exclusion">5. Scope boundary — PRD native-app exclusion</a></h2> <p>The original PRD scoped the <strong>web/API platform</strong> and explicitly <strong>excluded the native mobile app</strong>. These design docs cover the mobile/Flutter surface introduced later (see <a href="i18n/../00-shared/11_Flutter_App_Architecture.html">00-shared/11_Flutter_App_Architecture.md</a>) plus the <strong>admin console</strong> surfaces for translation management <strong>(planned)</strong>. The server-side i18n service itself is in production scope and fully described in <a href="i18n/12_API_Mapping.html">12_API_Mapping.md</a>.</p> <h2 id="6-non-goals-today"><a class="header" href="#6-non-goals-today">6. Non-goals (today)</a></h2> <ul> <li>Per-tenant custom translations (no tenantId on catalogs).</li> <li>Runtime translation editing (catalogs are static; editing is <strong>(planned)</strong>).</li> <li>Right-to-left locales (all 9 supported locales are LTR; RTL is <strong>(forward-looking)</strong> — see <a href="i18n/07_Component_Library.html">07_Component_Library.md</a>).</li> <li>Translation of tenant-authored content (announcement bodies, thread titles) — that is content, not UI strings.</li> </ul> <h2 id="7-definitions"><a class="header" href="#7-definitions">7. Definitions</a></h2> <ul> <li><strong>Locale</strong> — lowercase BCP-47 primary tag, e.g. <code>en</code>, <code>hi</code>, <code>ta</code>.</li> <li><strong>Catalog</strong> — one JSON file of <code>key → string</code> pairs per locale.</li> <li><strong>Key</strong> — dotted path, e.g. <code>errors.resourceNotFound</code>.</li> <li><strong>Interpolation</strong> — <code>{name}</code> placeholders substituted at translate time (<code>i18n.service.ts:42-44</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-i18n-module"><a class="header" href="#02--user-personas-i18n-module">02 — User Personas (i18n Module)</a></h1> <ul> <li><a href="i18n/02_User_Personas.html#1-ravi--student-consumes-translated-ui">1. Ravi — Student (consumes translated UI)</a></li> <li><a href="i18n/02_User_Personas.html#2-meera--school-admin-consumes--indirectly-authors">2. Meera — School Admin (consumes + indirectly authors)</a></li> <li><a href="i18n/02_User_Personas.html#3-aarav--platform-admin--translation-manager-planned">3. Aarav — Platform Admin / Translation Manager (planned)</a></li> <li><a href="i18n/02_User_Personas.html#4-dev--content-ops--key-hygiene">4. Dev / Content Ops — key hygiene</a></li> <li><a href="i18n/02_User_Personas.html#5-what-personas-do-not-do">5. What personas do NOT do</a></li> </ul> <blockquote> <p>Who touches translation infrastructure. Components per <a href="i18n/../00-shared/03_Component_Library.html">00-shared/03</a>; global IA per <a href="i18n/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a>.</p> </blockquote> <hr /> <h2 id="1-ravi--student-consumes-translated-ui"><a class="header" href="#1-ravi--student-consumes-translated-ui">1. Ravi — Student (consumes translated UI)</a></h2> <ul> <li>Age 17, Grade 12, prefers Hindi on his phone (<code>Accept-Language: hi-IN</code>).</li> <li>Never touches translation tooling; only consumes the catalog the app downloads via <code>GET /api/v1/i18n/messages</code>.</li> <li>Failure mode: a missing <code>hi</code> string silently falls back to English (<code>i18n.service.ts:40</code>) — fine for Ravi, invisible to him.</li> <li>Needs: complete catalogs in his locale, correct pluralization (<code>leave.requested</code> with <code>{days}</code>).</li> </ul> <h2 id="2-meera--school-admin-consumes--indirectly-authors"><a class="header" href="#2-meera--school-admin-consumes--indirectly-authors">2. Meera — School Admin (consumes + indirectly authors)</a></h2> <ul> <li>Uses the web admin console; toggles her account locale.</li> <li>Triggers translated system notifications (e.g. announcement published) which currently hardcode English in <code>notifications.handler.ts:16-31</code> <strong>(planned</strong> fix: key-based titles**)**.</li> <li>Needs: system messages in her locale, not English-only.</li> </ul> <h2 id="3-aarav--platform-admin--translation-manager-planned"><a class="header" href="#3-aarav--platform-admin--translation-manager-planned">3. Aarav — Platform Admin / Translation Manager <strong>(planned)</strong></a></h2> <ul> <li>New persona for the <strong>(planned)</strong> admin translation console.</li> <li>Reviews missing keys per locale, edits translations, watches drift.</li> <li>Needs: per-locale editor, missing-key view, placeholder validation, sync status (which locale is behind).</li> <li>Today he would edit <code>en.json</code> / <code>hi.json</code> by hand and deploy — no UI.</li> </ul> <h2 id="4-dev--content-ops--key-hygiene"><a class="header" href="#4-dev--content-ops--key-hygiene">4. Dev / Content Ops — key hygiene</a></h2> <ul> <li>Adds new product strings; keeps keys in dotted namespaces (<code>errors.*</code>, <code>leave.*</code>, <code>announcement.*</code>).</li> <li>Needs: CI drift check <strong>(planned)</strong> per <code>IMPLEMENTATION_PLAN.md:845</code> ("CI check for missing translations") and Crowdin extraction.</li> </ul> <h2 id="5-what-personas-do-not-do"><a class="header" href="#5-what-personas-do-not-do">5. What personas do NOT do</a></h2> <ul> <li>No tenant admin edits translations at runtime (per-tenant catalogs are a non-goal — <a href="i18n/01_Product_Overview.html">01_Product_Overview.md §6</a>).</li> <li>No student/staff chooses a locale in-app yet; locale comes only from the <code>Accept-Language</code> header via middleware (<code>i18n.middleware.ts:13-18</code>). In-app locale switching is <strong>(forward-looking)</strong>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journey-i18n-module"><a class="header" href="#03--user-journey-i18n-module">03 — User Journey (i18n Module)</a></h1> <ul> <li><a href="i18n/03_User_Journey.html#1-j1--app-cold-start-in-a-supported-locale-student-ravi-hi">1. J1 — App cold start in a supported locale (student Ravi, <code>hi</code>)</a></li> <li><a href="i18n/03_User_Journey.html#2-j2--unsupportedabsent-locale-teacher-with-fr-or-no-header">2. J2 — Unsupported/absent locale (teacher with <code>fr</code> or no header)</a></li> <li><a href="i18n/03_User_Journey.html#3-j3--missing-key-in-a-shipped-catalog-drift">3. J3 — Missing key in a shipped catalog (drift)</a></li> <li><a href="i18n/03_User_Journey.html#4-j4--translation-manager-edits-a-key-planned">4. J4 — Translation manager edits a key (planned)</a></li> <li><a href="i18n/03_User_Journey.html#5-j5--new-locale-onboarding-planned">5. J5 — New locale onboarding (planned)</a></li> <li><a href="i18n/03_User_Journey.html#6-journey-map">6. Journey map</a></li> </ul> <blockquote> <p>End-to-end journeys around locale resolution, catalog delivery and translation management. Motion/interaction conventions per <a href="i18n/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>; QA baseline per <a href="i18n/../00-shared/10_QA_Baseline.html">00-shared/10</a>.</p> </blockquote> <hr /> <h2 id="1-j1--app-cold-start-in-a-supported-locale-student-ravi-hi"><a class="header" href="#1-j1--app-cold-start-in-a-supported-locale-student-ravi-hi">1. J1 — App cold start in a supported locale (student Ravi, <code>hi</code>)</a></h2> <ol> <li>Ravi opens the app; device sends <code>Accept-Language: hi-IN, hi;q=0.9, en;q=0.8</code>.</li> <li><code>I18nMiddleware</code> takes the first tag, strips region and case: <code>hi-IN</code> → <code>hi</code>, whitelisted in <code>SUPPORTED_LOCALES</code> (<code>i18n.middleware.ts:16-17</code>, <code>i18n.service.ts:11-21</code>).</li> <li>App calls <code>GET /api/v1/i18n/messages?locale=hi</code> (JWT-authenticated, <code>i18n.controller.ts:13-17</code>); receives the full <code>hi</code> catalog.</li> <li>UI strings render in Hindi. Server-rendered messages (e.g. errors) use <code>I18nService.translate()</code> with the same resolved locale. <strong>Success: consistent Hindi everywhere.</strong></li> </ol> <h2 id="2-j2--unsupportedabsent-locale-teacher-with-fr-or-no-header"><a class="header" href="#2-j2--unsupportedabsent-locale-teacher-with-fr-or-no-header">2. J2 — Unsupported/absent locale (teacher with <code>fr</code> or no header)</a></h2> <ol> <li>Middleware leaves <code>ctx.locale</code> unset (<code>fr</code> not in whitelist).</li> <li><code>getLocale()</code> returns <code>'en'</code> (<code>i18n.service.ts:27-30</code>).</li> <li><code>GET /i18n/messages</code> returns the <strong>English catalog</strong> (<code>getMessages</code> → <code>DEFAULT_CATALOG</code>, <code>i18n.service.ts:47-49</code>). <strong>Success: graceful English fallback, never an empty payload.</strong></li> </ol> <h2 id="3-j3--missing-key-in-a-shipped-catalog-drift"><a class="header" href="#3-j3--missing-key-in-a-shipped-catalog-drift">3. J3 — Missing key in a shipped catalog (drift)</a></h2> <ol> <li>Developer adds <code>report.ready</code> to <code>en.json</code> but not <code>hi.json</code>.</li> <li>A <code>hi</code> user triggers the message. <code>translate('report.ready')</code> finds no <code>hi</code> entry → falls back to <code>DEFAULT_CATALOG</code> (<code>i18n.service.ts:37-40</code>).</li> <li>User sees English inside an otherwise Hindi screen. <strong>Today: silent.</strong> <strong>(planned)</strong>: CI drift check (<code>IMPLEMENTATION_PLAN.md:845</code>) fails the build so this never ships.</li> </ol> <h2 id="4-j4--translation-manager-edits-a-key-planned"><a class="header" href="#4-j4--translation-manager-edits-a-key-planned">4. J4 — Translation manager edits a key <strong>(planned)</strong></a></h2> <ol> <li>Aarav opens the <strong>(planned)</strong> translation console, filters locale <code>ta</code>, status <code>missing</code>.</li> <li>Edits the key in the per-locale editor (<a href="i18n/06_Screen_Specifications.html">06_Screen_Specifications.md §2</a>).</li> <li>Saves → validation checks placeholders match the <code>en</code> source (<a href="i18n/08_Form_Specifications.html">08_Form_Specifications.md §2</a>) → updated catalog published.</li> <li>Next app fetch (<code>GET /i18n/messages</code>) returns the new string. <em>(Publishing mechanics — file/DB, per-tenant or global — are <strong>(planned)</strong> and unresolved; see <a href="i18n/../00-shared/12_Assumptions_&_Open_Questions.html">00-shared/12</a>.)</em></li> </ol> <h2 id="5-j5--new-locale-onboarding-planned"><a class="header" href="#5-j5--new-locale-onboarding-planned">5. J5 — New locale onboarding <strong>(planned)</strong></a></h2> <ol> <li>Platform admin adds locale <code>kn</code> to the locale-management screen.</li> <li>Catalog created empty → all keys show <code>missing</code>.</li> <li>Translators fill in; when a coverage threshold is met, <code>kn</code> is promoted to <code>SUPPORTED_LOCALES</code> — today this is a code+deploy change (<code>i18n.service.ts:9-21</code> is a code constant).</li> </ol> <h2 id="6-journey-map"><a class="header" href="#6-journey-map">6. Journey map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Step</th><th>Actor</th><th>System action</th><th>Outcome</th></tr></thead><tbody> <tr><td>Header sent</td><td>device</td><td>middleware whitelist check</td><td>locale or <code>en</code></td></tr> <tr><td>Fetch catalog</td><td>app</td><td><code>GET /i18n/messages?locale=</code></td><td>full catalog, fallback <code>en</code></td></tr> <tr><td>Render string</td><td>app/server</td><td>key lookup + <code>{param}</code> fill</td><td>localized text</td></tr> <tr><td>Missing key</td><td>—</td><td>catalog → <code>en</code> → key</td><td>never-empty guarantee</td></tr> <tr><td>Edit key <strong>(planned)</strong></td><td>admin</td><td>console CRUD</td><td>next fetch serves it</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-i18n-module"><a class="header" href="#04--information-architecture-i18n-module">04 — Information Architecture (i18n Module)</a></h1> <ul> <li><a href="i18n/04_Information_Architecture.html#1-data-model">1. Data model</a></li> <li><a href="i18n/04_Information_Architecture.html#2-catalog-contents-today">2. Catalog contents today</a></li> <li><a href="i18n/04_Information_Architecture.html#3-locale-taxonomy">3. Locale taxonomy</a></li> <li><a href="i18n/04_Information_Architecture.html#4-resolution-chain-server">4. Resolution chain (server)</a></li> <li><a href="i18n/04_Information_Architecture.html#5-admin-console-ia-planned">5. Admin console IA (planned)</a></li> <li><a href="i18n/04_Information_Architecture.html#6-naming-rules">6. Naming rules</a></li> </ul> <blockquote> <p>How translation data is organized. Global IA per <a href="i18n/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a>; API envelope conventions per <a href="i18n/../00-shared/07_API_Conventions.html">00-shared/07</a>.</p> </blockquote> <hr /> <h2 id="1-data-model"><a class="header" href="#1-data-model">1. Data model</a></h2> <p><strong>Translation</strong> (logical entity; today a JSON entry, no DB):</p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type</th><th>Notes</th><th>Source</th></tr></thead><tbody> <tr><td><code>key</code></td><td>string</td><td>dotted namespace, e.g. <code>errors.resourceNotFound</code></td><td><code>en.json:3</code></td></tr> <tr><td><code>locale</code></td><td>string</td><td>primary BCP-47 tag</td><td><code>i18n.service.ts:11-21</code></td></tr> <tr><td><code>value</code></td><td>string</td><td>localized template, may contain <code>{param}</code></td><td><code>en.json:9</code></td></tr> </tbody></table> </div> <p>Physical storage: one file per locale under <code>src/shared/i18n/translations/<locale>.json</code>, compiled to the in-memory <code>CATALOGS</code> record (<code>i18n.service.ts:6</code>). Not a Mongo collection; <strong>not tenant-scoped</strong> (no <code>tenantId</code>).</p> <h2 id="2-catalog-contents-today"><a class="header" href="#2-catalog-contents-today">2. Catalog contents today</a></h2> <div class="table-wrapper"><table><thead><tr><th>Namespace</th><th>Keys</th><th>Example</th></tr></thead><tbody> <tr><td><code>app.*</code></td><td><code>app.welcome</code></td><td>"Welcome to StudyLyon" (<code>en.json:2</code>)</td></tr> <tr><td><code>errors.*</code></td><td><code>errors.resourceNotFound</code>, <code>errors.conflict</code>, <code>errors.validation</code>, <code>errors.unauthorized</code>, <code>errors.forbidden</code>, <code>errors.internal</code></td><td><code>en.json:3-8</code></td></tr> <tr><td><code>leave.*</code></td><td><code>leave.requested</code> (<code>{days}</code> param)</td><td><code>en.json:9</code></td></tr> <tr><td><code>announcement.*</code></td><td><code>announcement.published</code></td><td><code>en.json:10</code></td></tr> </tbody></table> </div> <p>9 keys total in each of <code>en.json</code> and <code>hi.json</code> (identical key sets today).</p> <h2 id="3-locale-taxonomy"><a class="header" href="#3-locale-taxonomy">3. Locale taxonomy</a></h2> <ul> <li><strong>Shipped</strong> (file exists): <code>en</code>, <code>hi</code>.</li> <li><strong>Declared</strong> (in <code>SUPPORTED_LOCALES</code>, no file yet; data-only addition per <code>i18n.service.ts:9-10</code>): <code>ta</code>, <code>te</code>, <code>kn</code>, <code>ml</code>, <code>gu</code>, <code>mr</code>, <code>bn</code>.</li> <li><strong>Fallback</strong>: <code>en</code> is the universal fallback (<code>DEFAULT_CATALOG</code>, <code>i18n.service.ts:7</code>).</li> <li>All 9 are LTR. No RTL locale is planned; RTL support is <strong>(forward-looking)</strong>.</li> </ul> <h2 id="4-resolution-chain-server"><a class="header" href="#4-resolution-chain-server">4. Resolution chain (server)</a></h2> <pre><code>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) </code></pre> <h2 id="5-admin-console-ia-planned"><a class="header" href="#5-admin-console-ia-planned">5. Admin console IA <strong>(planned)</strong></a></h2> <pre><code>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) </code></pre> <h2 id="6-naming-rules"><a class="header" href="#6-naming-rules">6. Naming rules</a></h2> <ul> <li>Keys are dotted namespaces ordered <code><module>.<entity>.<state></code>.</li> <li>Params are <code>{snake_case}</code>; a key's params must be identical across locales (enforced <strong>(planned)</strong> in editor + CI).</li> <li><code>errors.*</code> mirrors exception semantics — one key per error class, kept in sync with <code>common/filters/http-exception.filter.ts</code>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-i18n-module"><a class="header" href="#05--screen-inventory-i18n-module">05 — Screen Inventory (i18n Module)</a></h1> <ul> <li><a href="i18n/05_Screen_Inventory.html#legend">Legend</a></li> <li><a href="i18n/05_Screen_Inventory.html#1-translation-key-list-admini18nkeys-planned">1. Translation Key List (<code>/admin/i18n/keys</code>) (planned)</a></li> <li><a href="i18n/05_Screen_Inventory.html#2-key-editor-admini18nkeyskey-planned">2. Key Editor (<code>/admin/i18n/keys/:key</code>) (planned)</a></li> <li><a href="i18n/05_Screen_Inventory.html#3-new-key-admini18nkeysnew-planned">3. New Key (<code>/admin/i18n/keys/new</code>) (planned)</a></li> <li><a href="i18n/05_Screen_Inventory.html#4-locale-management-admini18nlocales-planned">4. Locale Management (<code>/admin/i18n/locales</code>) (planned)</a></li> <li><a href="i18n/05_Screen_Inventory.html#5-translation-sync--drift-view-admini18nsync-planned">5. Translation Sync / Drift view (<code>/admin/i18n/sync</code>) (planned)</a></li> <li><a href="i18n/05_Screen_Inventory.html#6-mobile-app-surface">6. Mobile app surface</a></li> <li><a href="i18n/05_Screen_Inventory.html#shared-components-used">Shared components used</a></li> <li><a href="i18n/05_Screen_Inventory.html#analytics-events-proposed">Analytics events (proposed)</a></li> <li><a href="i18n/05_Screen_Inventory.html#keyboard-landscape-tablet-desktop">Keyboard, landscape, tablet, desktop</a></li> </ul> <blockquote> <p>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 <strong>(planned)</strong> admin translation console. Authoritative components in <a href="i18n/../00-shared/03_Component_Library.html">00-shared/03</a>; this file enumerates which ones each screen uses with module specifics.</p> </blockquote> <hr /> <h2 id="legend-10"><a class="header" href="#legend-10">Legend</a></h2> <p>States = idle / loading / success / empty / error(offline, rate, invalid) / disabled / permission. Analytics follow <code>{module}.{screen}.{action}</code> (proposed; SDK open — <a href="i18n/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a>).</p> <hr /> <h2 id="1-translation-key-list-admini18nkeys-planned"><a class="header" href="#1-translation-key-list-admini18nkeys-planned">1. Translation Key List (<code>/admin/i18n/keys</code>) <strong>(planned)</strong></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Browse, search, filter all translation keys across locales</td></tr> <tr><td>Entry</td><td>admin console home; console nav</td></tr> <tr><td>Exit</td><td>row tap → Key Editor; FAB → New Key; Locale chip → Locale Management</td></tr> <tr><td>Source</td><td><code>GET /api/v1/i18n/keys</code> <strong>(planned)</strong> — list with per-locale status</td></tr> <tr><td>Widgets</td><td>AppSearchBar, AppDataTable/AppListTile, AppChips (locale filter), AppBadge (<code>missing</code>, <code>stale</code>, <code>ok</code>), AppEmptyState, AppSkeleton</td></tr> <tr><td>Row data</td><td>key, namespace group, params <code>{days}</code>, coverage badge, last edited</td></tr> <tr><td>States</td><td>loading skeleton; empty ("No keys"); error (401 → login); permission-denied (no <code>i18n.*</code> role)</td></tr> <tr><td>Sort/filter</td><td>by namespace, by missing-in-locale, by placeholder presence</td></tr> <tr><td>Bulk actions <strong>(planned)</strong></td><td>select keys → bulk edit / delete / export</td></tr> <tr><td>Analytics (proposed)</td><td><code>i18n.keys.{open,search,filter,open_row,create}</code></td></tr> </tbody></table> </div> <h2 id="2-key-editor-admini18nkeyskey-planned"><a class="header" href="#2-key-editor-admini18nkeyskey-planned">2. Key Editor (<code>/admin/i18n/keys/:key</code>) <strong>(planned)</strong></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>View/edit one key's value in every locale</td></tr> <tr><td>Entry</td><td>row tap in Key List; inline "missing" badge CTA</td></tr> <tr><td>Exit</td><td>save → back to list; delete → confirm dialog</td></tr> <tr><td>Layout</td><td>master-detail on tablet: key list left, editor right</td></tr> <tr><td>Content</td><td>read-only <code>key</code> + params chips; per-locale <code>AppTextField</code>s (en source + all shipped/declared locales), one tab/tile per locale; missing locales highlighted</td></tr> <tr><td>Validation</td><td>placeholders must match source (see <a href="i18n/08_Form_Specifications.html">08_Form_Specifications.md §2</a>); non-empty if locale shipped</td></tr> <tr><td>Save</td><td><code>PUT /api/v1/i18n/keys/:key</code> <strong>(planned)</strong>; optimistic, rollback on fail</td></tr> <tr><td>States</td><td>dirty indicator, per-field save state, conflict → reload prompt</td></tr> <tr><td>Analytics (proposed)</td><td><code>i18n.key.{open,edit,save,delete,params_mismatch}</code></td></tr> </tbody></table> </div> <h2 id="3-new-key-admini18nkeysnew-planned"><a class="header" href="#3-new-key-admini18nkeysnew-planned">3. New Key (<code>/admin/i18n/keys/new</code>) <strong>(planned)</strong></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Create a key + source value in one step</td></tr> <tr><td>Form</td><td><code>key</code> (dotted path, uniqueness), <code>params</code> chips (optional), <code>en</code> value required, optional first-edit of other locales</td></tr> <tr><td>Create</td><td><code>POST /api/v1/i18n/keys</code> <strong>(planned)</strong> → jumps to Key Editor</td></tr> <tr><td>States</td><td>duplicate-key 409 inline</td></tr> </tbody></table> </div> <h2 id="4-locale-management-admini18nlocales-planned"><a class="header" href="#4-locale-management-admini18nlocales-planned">4. Locale Management (<code>/admin/i18n/locales</code>) <strong>(planned)</strong></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>View supported locales, coverage %, promote/demote, add new locale</td></tr> <tr><td>Source</td><td><code>GET /api/v1/i18n/locales</code> <strong>(planned)</strong></td></tr> <tr><td>Widgets</td><td>AppCard per locale: name, tag, coverage ring (missing/total), status chip (<code>shipped</code>/<code>declared</code>), "add locale"</td></tr> <tr><td>Actions</td><td>add locale (creates empty catalog), coverage drill-down → Key List filtered</td></tr> <tr><td>Note</td><td>today <code>SUPPORTED_LOCALES</code> is a code constant (<code>i18n.service.ts:11-21</code>); promoting a locale is a deploy. The console makes this data-driven <strong>(planned)</strong></td></tr> </tbody></table> </div> <h2 id="5-translation-sync--drift-view-admini18nsync-planned"><a class="header" href="#5-translation-sync--drift-view-admini18nsync-planned">5. Translation Sync / Drift view (<code>/admin/i18n/sync</code>) <strong>(planned)</strong></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>CI/Crowdin drift report: which keys are missing/stale per locale</td></tr> <tr><td>Source</td><td><code>GET /api/v1/i18n/drift</code> <strong>(planned)</strong> or CI artifact (<code>IMPLEMENTATION_PLAN.md:845</code>)</td></tr> <tr><td>Content</td><td>table of locale × missing keys; "diff" of placeholder mismatches; export CSV</td></tr> <tr><td>CTA</td><td>"Open missing in editor"</td></tr> </tbody></table> </div> <h2 id="6-mobile-app-surface"><a class="header" href="#6-mobile-app-surface">6. Mobile app surface</a></h2> <p>No dedicated screen. The app fetches the catalog once (<code>GET /api/v1/i18n/messages?locale=</code>, <code>i18n.controller.ts:13-17</code>), caches it, and renders through Flutter's <code>intl</code>/ARB (see <a href="i18n/15_Flutter_Implementation_Guide.html">15_Flutter_Implementation_Guide.md</a>). In-app locale switching is <strong>(forward-looking)</strong>.</p> <hr /> <h2 id="shared-components-used-13"><a class="header" href="#shared-components-used-13">Shared components used</a></h2> <p><code>AppTextField</code>, <code>AppButton</code>, <code>AppSnackbar</code>, <code>AppCard</code>, <code>AppListTile</code>, <code>AppDataTable</code>, <code>AppBottomSheet</code>, <code>AppDialog</code>, <code>AppMenu</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppOfflineBanner</code>, <code>AppChips</code>, <code>AppBadge</code>, <code>AppSearchBar</code>, <code>AppFAB</code>. Module-specific: <code>per-locale field group</code>, <code>coverage ring</code> — defined in <a href="i18n/07_Component_Library.html">07_Component_Library.md</a>.</p> <h2 id="analytics-events-proposed-10"><a class="header" href="#analytics-events-proposed-10">Analytics events (proposed)</a></h2> <p><code>i18n.keys.{open,search,filter,open_row,create}</code>, <code>i18n.key.{open,edit,save,delete,params_mismatch}</code>, <code>i18n.locale.{open,add,promote}</code>, <code>i18n.sync.{open,export}</code>.</p> <h2 id="keyboard-landscape-tablet-desktop-13"><a class="header" href="#keyboard-landscape-tablet-desktop-13">Keyboard, landscape, tablet, desktop</a></h2> <ul> <li>Key Editor: per-locale fields in scrollable columns; tablet splits list/detail; desktop constrains form width ≤ 720 dp.</li> <li>All fields keyboard-avoidance + <code>ScrollView</code> <code>resizeToAvoidBottomInset</code>.</li> <li>Devanagari/Indic IMEs: fields must not force Latin keyboards; keep <code>TextCapitalization.none</code> for translation fields.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-i18n-module"><a class="header" href="#06--screen-specifications-i18n-module">06 — Screen Specifications (i18n Module)</a></h1> <ul> <li><a href="i18n/06_Screen_Specifications.html#0-live-flow-catalog-delivery-no-screen">0. Live flow: catalog delivery (no screen)</a></li> <li><a href="i18n/06_Screen_Specifications.html#1-translation-key-list-admini18nkeys-planned">1. Translation Key List (<code>/admin/i18n/keys</code>) (planned)</a> <ul> <li><a href="i18n/06_Screen_Specifications.html#anatomy">Anatomy</a></li> <li><a href="i18n/06_Screen_Specifications.html#states">States</a></li> <li><a href="i18n/06_Screen_Specifications.html#behaviors">Behaviors</a></li> <li><a href="i18n/06_Screen_Specifications.html#a11y">a11y</a></li> </ul> </li> <li><a href="i18n/06_Screen_Specifications.html#2-key-editor-admini18nkeyskey-planned">2. Key Editor (<code>/admin/i18n/keys/:key</code>) (planned)</a> <ul> <li><a href="i18n/06_Screen_Specifications.html#anatomy-tabletdesktop-master-detail-phone-single-pane">Anatomy (tablet/desktop master-detail; phone single pane)</a></li> <li><a href="i18n/06_Screen_Specifications.html#states-1">States</a></li> <li><a href="i18n/06_Screen_Specifications.html#behaviors-1">Behaviors</a></li> <li><a href="i18n/06_Screen_Specifications.html#a11y-1">a11y</a></li> </ul> </li> <li><a href="i18n/06_Screen_Specifications.html#3-new-key-admini18nkeysnew-planned">3. New Key (<code>/admin/i18n/keys/new</code>) (planned)</a> <ul> <li><a href="i18n/06_Screen_Specifications.html#anatomy-1">Anatomy</a></li> <li><a href="i18n/06_Screen_Specifications.html#rules--states">Rules & states</a></li> <li><a href="i18n/06_Screen_Specifications.html#a11y-2">a11y</a></li> </ul> </li> <li><a href="i18n/06_Screen_Specifications.html#4-locale-management-admini18nlocales-planned">4. Locale Management (<code>/admin/i18n/locales</code>) (planned)</a> <ul> <li><a href="i18n/06_Screen_Specifications.html#anatomy-2">Anatomy</a></li> <li><a href="i18n/06_Screen_Specifications.html#states-2">States</a></li> <li><a href="i18n/06_Screen_Specifications.html#behaviors-2">Behaviors</a></li> </ul> </li> <li><a href="i18n/06_Screen_Specifications.html#5-sync--drift-view-admini18nsync-planned">5. Sync / Drift view (<code>/admin/i18n/sync</code>) (planned)</a> <ul> <li><a href="i18n/06_Screen_Specifications.html#anatomy-3">Anatomy</a></li> <li><a href="i18n/06_Screen_Specifications.html#states-3">States</a></li> </ul> </li> <li><a href="i18n/06_Screen_Specifications.html#6-edge-cases-across-screens">6. Edge cases across screens</a></li> <li><a href="i18n/06_Screen_Specifications.html#7-motion-summary">7. Motion summary</a></li> </ul> <blockquote> <p>Detailed specifications for the <strong>(planned)</strong> admin translation console and the live catalog-delivery flow. Largest file: every screen's anatomy, states, behaviors, edge cases, a11y, motion. Components per <a href="i18n/../00-shared/03_Component_Library.html">00-shared/03</a>; motion tokens per <a href="i18n/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>; a11y baseline per <a href="i18n/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>.</p> </blockquote> <hr /> <h2 id="0-live-flow-catalog-delivery-no-screen"><a class="header" href="#0-live-flow-catalog-delivery-no-screen">0. Live flow: catalog delivery (no screen)</a></h2> <p>The only production surface today. The app:</p> <ol> <li>Resolves locale via <code>Accept-Language</code> (middleware, <code>i18n.middleware.ts:13-18</code>) — or passes <code>?locale=</code> explicitly.</li> <li>Calls <code>GET /api/v1/i18n/messages?locale=hi</code> → full <code>Record<string,string></code> (<code>i18n.controller.ts:13-17</code>, <code>getMessages</code> fallback to en, <code>i18n.service.ts:47-49</code>).</li> <li>Caches locally; refreshes on locale change or app update (cache version = catalog hash <strong>(planned)</strong>).</li> </ol> <p>Contract edge cases:</p> <ul> <li><code>locale=xx</code> (unknown) → English catalog (<code>i18n.service.spec.ts:48-51</code>).</li> <li>Missing header → English.</li> <li>Response is a flat key→string map; <strong>no nested structure</strong> — the app must flatten ARB/JSON to the same flat map for lookup parity.</li> </ul> <hr /> <h2 id="1-translation-key-list-admini18nkeys-planned-1"><a class="header" href="#1-translation-key-list-admini18nkeys-planned-1">1. Translation Key List (<code>/admin/i18n/keys</code>) <strong>(planned)</strong></a></h2> <h3 id="anatomy"><a class="header" href="#anatomy">Anatomy</a></h3> <pre><code>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"] </code></pre> <h3 id="states-3"><a class="header" href="#states-3">States</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Behavior</th></tr></thead><tbody> <tr><td>loading</td><td>AppSkeleton rows (10); search disabled until first paint</td></tr> <tr><td>success</td><td>rows with per-locale coverage badge (<code>ok</code> = all shipped locales present, <code>missing</code> = ≥1 shipped locale lacks key)</td></tr> <tr><td>empty</td><td>AppEmptyState "No keys" + CTA "Create first key"; distinguishes "no filters match" vs "zero keys"</td></tr> <tr><td>error</td><td>401 → redirect login; 403 (no <code>i18n.*</code> perm <strong>(planned)</strong>) → permission state with copy; 5xx → retry CTA</td></tr> <tr><td>offline</td><td>AppOfflineBanner + cached last data (stale-while-revalidate)</td></tr> </tbody></table> </div> <h3 id="behaviors-7"><a class="header" href="#behaviors-7">Behaviors</a></h3> <ul> <li>Row tap → Key Editor (§2). Long-press → multi-select for bulk delete/export.</li> <li>Locale filter chips are multi-select; <code>missing:<locale></code> preselects keys the given locale lacks (drill-down from Locale Management).</li> <li>Search matches key + source value (en) + params.</li> <li>Keyboard: <code>/</code> focuses search. Esc clears filters.</li> <li>Motion: rows stagger <code>m-fast</code>; filter change animates list <code>m-base</code> fade.</li> </ul> <h3 id="a11y-4"><a class="header" href="#a11y-4">a11y</a></h3> <ul> <li>Search labeled "Search translation keys"; results announced count (<code>live region</code>).</li> <li>Badges have text labels ("missing in hi"), not color-only.</li> <li>Focus lands on first row after search completes.</li> </ul> <hr /> <h2 id="2-key-editor-admini18nkeyskey-planned-1"><a class="header" href="#2-key-editor-admini18nkeyskey-planned-1">2. Key Editor (<code>/admin/i18n/keys/:key</code>) <strong>(planned)</strong></a></h2> <h3 id="anatomy-tabletdesktop-master-detail-phone-single-pane"><a class="header" href="#anatomy-tabletdesktop-master-detail-phone-single-pane">Anatomy (tablet/desktop master-detail; phone single pane)</a></h3> <pre><code>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. </code></pre> <h3 id="states-4"><a class="header" href="#states-4">States</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Behavior</th></tr></thead><tbody> <tr><td>loading</td><td>skeleton; key header + params only</td></tr> <tr><td>loaded</td><td>per-locale values from <code>GET /api/v1/i18n/keys/:key</code> <strong>(planned)</strong></td></tr> <tr><td>missing locale</td><td>field empty + amber outline + chip <code>missing</code>; block "promote" of a locale with missing keys</td></tr> <tr><td>saving</td><td>field-level spinner; Save disabled while pending</td></tr> <tr><td>saved</td><td>snackbar "Saved" (module-level); per-field check animates</td></tr> <tr><td>conflict</td><td>stale version → dialog "Reload or overwrite"</td></tr> <tr><td>error</td><td>field error inline; network → AppOfflineBanner, no data loss (drafts kept in memory)</td></tr> </tbody></table> </div> <h3 id="behaviors-8"><a class="header" href="#behaviors-8">Behaviors</a></h3> <ul> <li><strong>Placeholder parity check (critical)</strong> — runs on every edit: <code>value.match(/\{(\w+)\}/g)</code> compared against the <code>en</code> source set (<code>i18n.service.ts:42-44</code> regex). Mismatch = amber inline warning, Save allowed but flagged; CI drift check <strong>(planned)</strong> (<code>IMPLEMENTATION_PLAN.md:845</code>) blocks promotion.</li> <li><strong>Empty value in shipped locale</strong> → Save blocked with red inline error (empty string would degrade the never-empty guarantee, <code>i18n.service.ts:40</code>).</li> <li>Delete → AppDialog confirm (destructive, <code>danger</code> button); deletes key in all locales. Undo snackbar 5 s <strong>(planned)</strong>.</li> <li>Unicode note: Devanagari/Tamil/Telugu etc. fields keep <code>TextCapitalization.none</code>; no autocorrect; <code>maxLines</code> ≥ 3, soft wrap.</li> <li>Character counter: warn > 200 chars (push/payload limits <strong>(forward-looking)</strong>).</li> </ul> <h3 id="a11y-5"><a class="header" href="#a11y-5">a11y</a></h3> <ul> <li>Per-field <code>Semantics</code> labels "value in hi".</li> <li>Status changes (missing → saved) announced via live region.</li> <li>Tab navigation order: source (en) first, then locales, actions last.</li> </ul> <hr /> <h2 id="3-new-key-admini18nkeysnew-planned-1"><a class="header" href="#3-new-key-admini18nkeysnew-planned-1">3. New Key (<code>/admin/i18n/keys/new</code>) <strong>(planned)</strong></a></h2> <h3 id="anatomy-1"><a class="header" href="#anatomy-1">Anatomy</a></h3> <pre><code>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] </code></pre> <h3 id="rules--states"><a class="header" href="#rules--states">Rules & states</a></h3> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Behavior</th></tr></thead><tbody> <tr><td>key format</td><td><code>/^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$/</code>; invalid → inline error</td></tr> <tr><td>duplicate</td><td>409 → inline "key exists" + "Open existing" link</td></tr> <tr><td>placeholder self-check</td><td>en value's <code>{...}</code> tokens must ⊆ declared params; mismatch → block Create</td></tr> <tr><td>create</td><td><code>POST /api/v1/i18n/keys</code> <strong>(planned)</strong> → navigate to Editor with snackbar</td></tr> <tr><td>error</td><td>5xx → retry; offline → banner</td></tr> </tbody></table> </div> <h3 id="a11y-6"><a class="header" href="#a11y-6">a11y</a></h3> <ul> <li>Key field hint: "lowercase, dots separate namespaces".</li> <li>Errors linked to fields; first invalid focused.</li> </ul> <hr /> <h2 id="4-locale-management-admini18nlocales-planned-1"><a class="header" href="#4-locale-management-admini18nlocales-planned-1">4. Locale Management (<code>/admin/i18n/locales</code>) <strong>(planned)</strong></a></h2> <h3 id="anatomy-2"><a class="header" href="#anatomy-2">Anatomy</a></h3> <pre><code>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] </code></pre> <h3 id="states-5"><a class="header" href="#states-5">States</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Behavior</th></tr></thead><tbody> <tr><td>loading</td><td>card skeletons</td></tr> <tr><td>empty</td><td>no declared locales → full-width add CTA</td></tr> <tr><td>partial coverage</td><td>ring amber + "x missing" link → Key List filtered <code>missing:<locale></code></td></tr> <tr><td>promote ready</td><td>coverage 100% + no param drift → "Promote to shipped" enabled (makes <code>SUPPORTED_LOCALES</code> include it; today code-level <code>i18n.service.ts:9-21</code> <strong>(planned</strong>: data-driven)**)</td></tr> <tr><td>demote</td><td>confirm dialog; blocking if it is the only locale with a key</td></tr> </tbody></table> </div> <h3 id="behaviors-9"><a class="header" href="#behaviors-9">Behaviors</a></h3> <ul> <li>Adding a locale creates an empty catalog — every key shows <code>missing</code> (drift flow, <code>03_User_Journey.md §J5</code>).</li> <li>Coverage = present keys / shipped-locale keys per locale.</li> <li>RTL note: if an RTL locale (ar/ur) is ever added <strong>(forward-looking)</strong>, this screen shows a <code>direction</code> field; all LTR today.</li> </ul> <hr /> <h2 id="5-sync--drift-view-admini18nsync-planned"><a class="header" href="#5-sync--drift-view-admini18nsync-planned">5. Sync / Drift view (<code>/admin/i18n/sync</code>) <strong>(planned)</strong></a></h2> <h3 id="anatomy-3"><a class="header" href="#anatomy-3">Anatomy</a></h3> <pre><code>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] </code></pre> <h3 id="states-6"><a class="header" href="#states-6">States</a></h3> <ul> <li>success / stale ("last run 3 d ago") / failed (CI unavailable).</li> <li>Empty: "All locales in sync" with celebratory success state.</li> </ul> <hr /> <h2 id="6-edge-cases-across-screens"><a class="header" href="#6-edge-cases-across-screens">6. Edge cases across screens</a></h2> <div class="table-wrapper"><table><thead><tr><th>Case</th><th>Handling</th></tr></thead><tbody> <tr><td><code>hi.json</code> missing a key</td><td>falls back to en at runtime (<code>i18n.service.ts:40</code>); editor shows missing; never blank string</td></tr> <tr><td>Param reorder/rename between locales</td><td>flagged in editor + sync; runtime keeps literal <code>{name}</code> if param absent (<code>i18n.service.ts:43</code>)</td></tr> <tr><td>Huge catalog (>10k keys)</td><td>list paginates; fetch returns flat map (memory: ~1 MB per locale — acceptable; app caches one locale)</td></tr> <tr><td>Locale promoted mid-session</td><td>app refetches catalog on next launch; version header <strong>(planned)</strong> enables delta push <strong>(forward-looking)</strong></td></tr> <tr><td>Concurrent editors</td><td>last-write-wins today; optimistic lock (version field) <strong>(planned)</strong> — conflict dialog §2</td></tr> <tr><td>Offline admin console</td><td>read-only cached view; saves queue <strong>(planned)</strong>; catalog delivery to mobile unaffected (server-side)</td></tr> </tbody></table> </div> <h2 id="7-motion-summary"><a class="header" href="#7-motion-summary">7. Motion summary</a></h2> <ul> <li>List/filter transitions: <code>m-base</code> (200 ms) fade+slide.</li> <li>Field save: <code>m-fast</code> check morph. Error shake reserved for form submit.</li> <li>Coverage ring: animates on data load <code>m-base</code> ease-out.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-i18n-module"><a class="header" href="#07--component-library-i18n-module">07 — Component Library (i18n Module)</a></h1> <ul> <li><a href="i18n/07_Component_Library.html#1-perlocalefieldgroup-module-specific">1. PerLocaleFieldGroup (module-specific)</a></li> <li><a href="i18n/07_Component_Library.html#2-coveragering">2. CoverageRing</a></li> <li><a href="i18n/07_Component_Library.html#3-paramschips">3. ParamsChips</a></li> <li><a href="i18n/07_Component_Library.html#4-localestatuschip">4. LocaleStatusChip</a></li> <li><a href="i18n/07_Component_Library.html#5-drifttablerow">5. DriftTableRow</a></li> <li><a href="i18n/07_Component_Library.html#6-localefieldrail-tabletdesktop">6. LocaleFieldRail (tablet/desktop)</a></li> <li><a href="i18n/07_Component_Library.html#7-reuse-map">7. Reuse map</a></li> <li><a href="i18n/07_Component_Library.html#8-typographylocale-notes">8. Typography/locale notes</a></li> </ul> <blockquote> <p>Components specific to the i18n surface; shared components come from <a href="i18n/../00-shared/03_Component_Library.html">00-shared/03_Component_Library.md</a> (AppTextField, AppCard, AppListTile, AppChips, AppBadge, AppSnackbar, AppDialog, AppDataTable, AppSkeleton, AppEmptyState, AppOfflineBanner, AppSearchBar, AppFAB). Design tokens per <a href="i18n/../00-shared/02_Design_Tokens.html">00-shared/02</a>; module behavior per <a href="i18n/06_Screen_Specifications.html">06_Screen_Specifications.md</a>.</p> </blockquote> <hr /> <h2 id="1-perlocalefieldgroup-module-specific"><a class="header" href="#1-perlocalefieldgroup-module-specific">1. PerLocaleFieldGroup (module-specific)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>One locale's value editor inside the Key Editor</td></tr> <tr><td>Props</td><td><code>locale</code>, <code>label</code> (native name), <code>status</code> (shipped/declared/missing), <code>value</code>, <code>params</code> (expected set), <code>onChanged</code>, <code>saving</code></td></tr> <tr><td>Children</td><td>AppTextField (multiline ≥3 rows, auto-grow, <code>TextCapitalization.none</code>) + status chip + placeholder checker line</td></tr> <tr><td>Placeholder checker</td><td>inline text: "params match source" (success) / "missing {days}" (amber) / "unexpected {x}" (error) — regex <code>\{(\w+)\}</code> (<code>i18n.service.ts:42-44</code>)</td></tr> <tr><td>States</td><td>idle / dirty (dot) / saving (spinner) / saved (check) / missing (amber outline) / error (red outline + message)</td></tr> <tr><td>a11y</td><td>single Semantics node "value in {locale}"; status via live region</td></tr> <tr><td>Directionality</td><td><code>directionality: TextDirection.ltr</code> for all current locales (all LTR). RTL locale <strong>(forward-looking)</strong> → per-field <code>TextDirection.rtl</code> driven by locale tag, never hardcoded</td></tr> </tbody></table> </div> <h2 id="2-coveragering"><a class="header" href="#2-coveragering">2. CoverageRing</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Locale coverage in Locale Management + key-list badge</td></tr> <tr><td>Props</td><td><code>present</code>, <code>total</code>, <code>size</code> (36 default)</td></tr> <tr><td>Behavior</td><td>progress arc; amber when < 100 %, green at 100 %; text <code>n/n</code> center</td></tr> <tr><td>a11y</td><td>semantic label "coverage 8 of 9 in hi" — never color-only</td></tr> </tbody></table> </div> <h2 id="3-paramschips"><a class="header" href="#3-paramschips">3. ParamsChips</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Display/enter <code>{param}</code> tokens</td></tr> <tr><td>Props</td><td><code>params</code> (string[]), <code>editable</code></td></tr> <tr><td>Behavior</td><td>chip text without braces (e.g. <code>days</code>); editing appends <code>{...}</code> tokens; invalid chars blocked (regex <code>\w+</code>)</td></tr> <tr><td>Used in</td><td>Key Editor header, New Key form, Key List rows</td></tr> </tbody></table> </div> <h2 id="4-localestatuschip"><a class="header" href="#4-localestatuschip">4. LocaleStatusChip</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>shipped vs declared vs missing state</td></tr> <tr><td>Variants</td><td><code>shipped</code> (filled), <code>declared</code> (outline), <code>missing</code> (amber), <code>stale</code> (danger outline)</td></tr> <tr><td>a11y</td><td>text label always present</td></tr> </tbody></table> </div> <h2 id="5-drifttablerow"><a class="header" href="#5-drifttablerow">5. DriftTableRow</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Sync view row: locale × missing keys with jump-to-editor</td></tr> <tr><td>Behavior</td><td>expandable; "Fix" button deep-links <code>Key Editor?filter=missing:<locale></code></td></tr> </tbody></table> </div> <h2 id="6-localefieldrail-tabletdesktop"><a class="header" href="#6-localefieldrail-tabletdesktop">6. LocaleFieldRail (tablet/desktop)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Vertical locale tab rail in Key Editor (desktop)</td></tr> <tr><td>Behavior</td><td>per-locale status dot on rail; scroll-lock to active field</td></tr> </tbody></table> </div> <h2 id="7-reuse-map"><a class="header" href="#7-reuse-map">7. Reuse map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Shared components</th><th>Module-specific</th></tr></thead><tbody> <tr><td>Key List</td><td>AppSearchBar, AppChips, AppBadge, AppDataTable, AppSkeleton, AppEmptyState, AppFAB</td><td>CoverageRing (badge), ParamsChips, LocaleStatusChip</td></tr> <tr><td>Key Editor</td><td>AppTextField, AppButton, AppSnackbar, AppDialog, AppMenu</td><td>PerLocaleFieldGroup, ParamsChips, LocaleFieldRail</td></tr> <tr><td>New Key</td><td>AppTextField, AppButton, AppChips, AppBottomSheet</td><td>ParamsChips</td></tr> <tr><td>Locale Management</td><td>AppCard, AppButton, AppBottomSheet</td><td>CoverageRing, LocaleStatusChip</td></tr> <tr><td>Sync</td><td>AppDataTable, AppButton, AppCard</td><td>DriftTableRow</td></tr> </tbody></table> </div> <h2 id="8-typographylocale-notes"><a class="header" href="#8-typographylocale-notes">8. Typography/locale notes</a></h2> <ul> <li>Devanagari, Tamil, Telugu, Kannada, Malayalam, Gujarati, Marathi, Bengali scripts render with system fonts; avoid forced <code>fontFamily</code> for these (Indic glyph fallback required). Keep <code>fontFamily</code> null for translation text.</li> <li>Text scale: translation fields respect <code>textScaleFactor</code> up to 2.0 (<a href="i18n/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>).</li> <li>Placeholders never auto-correct; autocorrect off.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-i18n-module"><a class="header" href="#08--form-specifications-i18n-module">08 — Form Specifications (i18n Module)</a></h1> <ul> <li><a href="i18n/08_Form_Specifications.html#1-new-key-form-admini18nkeysnew-planned">1. New Key form (<code>/admin/i18n/keys/new</code>) (planned)</a> <ul> <li><a href="i18n/08_Form_Specifications.html#submit">Submit</a></li> <li><a href="i18n/08_Form_Specifications.html#error-messages-translated-themselves">Error messages (translated themselves)</a></li> </ul> </li> <li><a href="i18n/08_Form_Specifications.html#2-key-editor--per-locale-field-validation-the-critical-contract">2. Key Editor — per-locale field validation (the critical contract)</a> <ul> <li><a href="i18n/08_Form_Specifications.html#save-flow">Save flow</a></li> </ul> </li> <li><a href="i18n/08_Form_Specifications.html#3-add-locale-form-admini18nlocales-planned">3. Add Locale form (<code>/admin/i18n/locales</code>) (planned)</a></li> <li><a href="i18n/08_Form_Specifications.html#4-sync-export-admini18nsync-planned">4. Sync export (<code>/admin/i18n/sync</code>) (planned)</a></li> <li><a href="i18n/08_Form_Specifications.html#5-runtime-parity-guarantee">5. Runtime parity guarantee</a></li> </ul> <blockquote> <p>Forms in the <strong>(planned)</strong> translation console: New Key, Key Editor (per-locale fields), Add Locale, Sync export. Validation derives from the live service contract (<code>i18n.service.ts:32-45</code>) so the editor can never author a string the runtime can't render.</p> </blockquote> <hr /> <h2 id="1-new-key-form-admini18nkeysnew-planned"><a class="header" href="#1-new-key-form-admini18nkeysnew-planned">1. New Key form (<code>/admin/i18n/keys/new</code>) <strong>(planned)</strong></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type</th><th>Required</th><th>Constraints / validation</th></tr></thead><tbody> <tr><td><code>key</code></td><td>AppTextField (mono)</td><td>yes</td><td><code>/^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$/</code>; unique; ≤ 120 chars; namespace hint <code>errors.</code> <code>leave.</code> <code>announcement.</code></td></tr> <tr><td><code>params</code></td><td>ParamsChips (editable)</td><td>no</td><td>tokens match <code>\w+</code>; deduped; order-insensitive</td></tr> <tr><td><code>en</code> value</td><td>AppTextField multiline</td><td>yes</td><td><strong>placeholder self-check</strong>: every <code>{token}</code> in value ⊆ declared params (else block); <code>{</code> <code>}</code> escaping not supported (literal <code>{{</code> unsupported — must match runtime regex <code>\{(\w+)\}</code>)</td></tr> <tr><td>other locales</td><td>AppTextField</td><td>no</td><td>same placeholder check once en exists; empty allowed → <code>missing</code></td></tr> </tbody></table> </div> <h3 id="submit"><a class="header" href="#submit">Submit</a></h3> <ul> <li><code>POST /api/v1/i18n/keys</code> <strong>(planned)</strong> → 201 → navigate to Key Editor.</li> <li>Duplicate key → 409 inline "key exists" + "Open existing".</li> <li>Offline → banner; values preserved (in-memory draft).</li> </ul> <h3 id="error-messages-translated-themselves"><a class="header" href="#error-messages-translated-themselves">Error messages (translated themselves)</a></h3> <p>Form errors must use i18n keys (<code>errors.validation</code> etc.) — the console is dogfooding the module.</p> <hr /> <h2 id="2-key-editor--per-locale-field-validation-the-critical-contract"><a class="header" href="#2-key-editor--per-locale-field-validation-the-critical-contract">2. Key Editor — per-locale field validation (the critical contract)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Enforcement</th><th>Source of truth</th></tr></thead><tbody> <tr><td>Placeholder parity</td><td>every locale's <code>{token}</code> set == <code>en</code> source set</td><td><code>i18n.service.ts:42-44</code> (<code>/\{(\w+)\}/g</code>)</td></tr> <tr><td>Missing param</td><td>value contains <code>{days}</code> not in source → <strong>error</strong>: runtime would print literal <code>{days}</code> (<code>i18n.service.ts:43</code>)</td><td>runtime fallback behavior</td></tr> <tr><td>Extra param</td><td>value uses <code>{x}</code> absent from source → <strong>amber warn</strong> (translator added token runtime won't fill)</td><td>same regex</td></tr> <tr><td>Empty value in shipped locale</td><td><strong>error</strong>, block save</td><td><code>translate()</code> would return <code>''</code>, breaking never-empty fallback (<code>i18n.service.ts:40</code>)</td></tr> <tr><td>Key immutability</td><td>key read-only in editor; rename = delete + create <strong>(planned)</strong></td><td>—</td></tr> <tr><td>Length</td><td>warn > 200 chars (push payload limits <strong>(forward-looking)</strong>)</td><td>—</td></tr> </tbody></table> </div> <h3 id="save-flow"><a class="header" href="#save-flow">Save flow</a></h3> <p><code>PUT /api/v1/i18n/keys/:key</code> <strong>(planned)</strong> with <code>{ values: { <locale>: string } }</code>. Optimistic; on 409 conflict (stale <code>version</code>) → dialog "Reload or overwrite" (<a href="i18n/06_Screen_Specifications.html">06 §2</a>).</p> <hr /> <h2 id="3-add-locale-form-admini18nlocales-planned"><a class="header" href="#3-add-locale-form-admini18nlocales-planned">3. Add Locale form (<code>/admin/i18n/locales</code>) <strong>(planned)</strong></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type</th><th>Required</th><th>Validation</th></tr></thead><tbody> <tr><td>locale tag</td><td>select from <code>SUPPORTED_LOCALES</code> minus shipped</td><td>yes</td><td>must be in <code>i18n.service.ts:11-21</code></td></tr> <tr><td>direction</td><td>read-only "LTR" (all current locales LTR)</td><td>—</td><td>RTL <strong>(forward-looking)</strong></td></tr> <tr><td>name</td><td>AppTextField (native endonym)</td><td>yes</td><td>non-empty, ≤ 40 chars</td></tr> </tbody></table> </div> <p>Submit → creates empty catalog → all keys <code>missing</code> → drift flow (<a href="i18n/03_User_Journey.html">03_User_Journey.md §J5</a>).</p> <hr /> <h2 id="4-sync-export-admini18nsync-planned"><a class="header" href="#4-sync-export-admini18nsync-planned">4. Sync export (<code>/admin/i18n/sync</code>) <strong>(planned)</strong></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Format</td><td>CSV: <code>key, en, hi, ta, …, status</code></td></tr> <tr><td>Filter</td><td>missing-only / all / placeholder-drift-only</td></tr> <tr><td>Delivery</td><td>download; Crowdin upload is <strong>(planned)</strong> per <code>IMPLEMENTATION_PLAN.md:845</code></td></tr> </tbody></table> </div> <hr /> <h2 id="5-runtime-parity-guarantee"><a class="header" href="#5-runtime-parity-guarantee">5. Runtime parity guarantee</a></h2> <p>Whatever the editor allows, runtime behavior is fixed:</p> <ul> <li>unknown key → returns key literal (<code>i18n.service.ts:40</code>, spec <code>i18n.service.spec.ts:44-46</code>)</li> <li>unknown locale → English catalog (<code>i18n.service.spec.ts:48-51</code>)</li> <li>missing param → literal <code>{name}</code> retained (<code>i18n.service.ts:43</code>)</li> </ul> <p>The console must therefore treat <code>en</code> as immutable source-of-truth for params and all other locales as derived — enforced by §2 rules and the CI drift check <strong>(planned)</strong>.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-i18n-module"><a class="header" href="#09--user-behaviour-i18n-module">09 — User Behaviour (i18n Module)</a></h1> <ul> <li><a href="i18n/09_User_Behaviour.html#1-consumer-behaviours">1. Consumer behaviours</a></li> <li><a href="i18n/09_User_Behaviour.html#2-manager-behaviours-planned-console">2. Manager behaviours (planned console)</a></li> <li><a href="i18n/09_User_Behaviour.html#3-failure-moment-matrix">3. Failure-moment matrix</a></li> <li><a href="i18n/09_User_Behaviour.html#4-behavioural-rules">4. Behavioural rules</a></li> </ul> <blockquote> <p>Observed behaviors, pain points, and design responses for translation consumers and (planned) managers. Personas in <a href="i18n/02_User_Personas.html">02</a>; journeys in <a href="i18n/03_User_Journey.html">03</a>.</p> </blockquote> <hr /> <h2 id="1-consumer-behaviours"><a class="header" href="#1-consumer-behaviours">1. Consumer behaviours</a></h2> <div class="table-wrapper"><table><thead><tr><th>Behaviour</th><th>Evidence</th><th>Design response</th></tr></thead><tbody> <tr><td>Locale comes from the device, not the app</td><td>locale resolved from <code>Accept-Language</code> only (<code>i18n.middleware.ts:13-18</code>); no in-app switcher</td><td>Keep header-first resolution; in-app switcher <strong>(forward-looking)</strong>; <code>?locale=</code> query override exists (<code>i18n.controller.ts:15</code>)</td></tr> <tr><td>Users ignore English fallback</td><td>missing hi key renders English silently (<code>i18n.service.ts:40</code>)</td><td>Acceptable at runtime; CI drift check <strong>(planned)</strong> prevents silent drift shipping (<code>IMPLEMENTATION_PLAN.md:845</code>)</td></tr> <tr><td>Users notice literal <code>{days}</code> when a param is missing</td><td>runtime keeps <code>{name}</code> literal (<code>i18n.service.ts:43</code>)</td><td>Param parity validation in editor (<a href="i18n/08_Form_Specifications.html">08 §2</a>) + tests</td></tr> <tr><td>Region tags confuse resolution</td><td><code>hi-IN</code> handled by <code>split('-')[0]</code> (<code>i18n.middleware.ts:16</code>)</td><td>Keep region-stripping; document that <code>en-GB</code> → <code>en</code></td></tr> <tr><td>App caches catalog aggressively</td><td>full map per fetch (<code>getMessages</code>, <code>i18n.service.ts:47-49</code>)</td><td>Cache-key = locale; version header <strong>(planned)</strong> for deltas <strong>(forward-looking)</strong></td></tr> </tbody></table> </div> <h2 id="2-manager-behaviours-planned-console"><a class="header" href="#2-manager-behaviours-planned-console">2. Manager behaviours (planned console)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Behaviour</th><th>Pain today</th><th>Console response</th></tr></thead><tbody> <tr><td>Hand-edits JSON and deploys</td><td><code>en.json</code>/<code>hi.json</code> manual edits; typo = runtime bug</td><td>Key Editor with live placeholder check (<a href="i18n/06_Screen_Specifications.html">06 §2</a>)</td></tr> <tr><td>Doesn't know which locales are behind</td><td>no coverage signal anywhere</td><td>CoverageRing + missing badges (<a href="i18n/05_Screen_Inventory.html">05 §1</a>, <a href="i18n/07_Component_Library.html">07 §2</a>)</td></tr> <tr><td>Adds a key to en only, ships it</td><td>silent English fallback for other locales</td><td>Save-time warning + CI gate <strong>(planned)</strong></td></tr> <tr><td>Reorders params while translating</td><td>runtime shows literal <code>{days}</code> (<code>i18n.service.ts:43</code>)</td><td>param parity error blocks save (<a href="i18n/08_Form_Specifications.html">08 §2</a>)</td></tr> <tr><td>Wants per-tenant copy</td><td>non-goal today (global catalogs, <a href="i18n/01_Product_Overview.html">01 §6</a>)</td><td>Documented; tenant-catalog variant <strong>(forward-looking)</strong></td></tr> </tbody></table> </div> <h2 id="3-failure-moment-matrix"><a class="header" href="#3-failure-moment-matrix">3. Failure-moment matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th>Moment</th><th>User sees</th><th>Correctness</th><th>Fix</th></tr></thead><tbody> <tr><td>Unknown key</td><td>key literal (e.g. <code>errors.internal2</code>)</td><td>degraded but honest</td><td>add key; editor search helps</td></tr> <tr><td>Unknown locale</td><td>English</td><td>correct fallback</td><td>—</td></tr> <tr><td>Missing param</td><td><code>Leave requested for {days} day(s).</code></td><td>broken copy visible</td><td>parity check blocks</td></tr> <tr><td>Empty value in hi</td><td>empty string</td><td><strong>worst case</strong> — empty UI</td><td>block save; never-empty guarantee <code>i18n.service.ts:40</code></td></tr> <tr><td>401 on catalog fetch</td><td>offline UI</td><td>correct</td><td>retry with backoff; cache last good catalog</td></tr> </tbody></table> </div> <h2 id="4-behavioural-rules"><a class="header" href="#4-behavioural-rules">4. Behavioural rules</a></h2> <ol> <li><strong>Never render a blank string.</strong> If catalog lookup fails, fall back to en, then to key — never <code>''</code> (matches <code>i18n.service.ts:40</code>).</li> <li><strong>English is the source of truth</strong> for keys and params; every other locale is derived.</li> <li><strong>Console edits are global</strong> (no tenant dimension) until per-tenant catalogs are introduced <strong>(forward-looking)</strong>.</li> <li><strong>No auto-correct</strong> on translation fields (Indic scripts, <a href="i18n/07_Component_Library.html">07 §8</a>).</li> <li><strong>Locale ≠ language of content.</strong> Tenant-authored announcements (<code>announcement.service.ts:47</code>) are content, not UI strings — they are never translated by this module; only system copy (<code>notifications.handler.ts:16-31</code> keys <strong>(planned)</strong>) is.</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-i18n-module"><a class="header" href="#10--interaction-specification-i18n-module">10 — Interaction Specification (i18n Module)</a></h1> <ul> <li><a href="i18n/10_Interaction_Specification.html#1-catalog-delivery-live">1. Catalog delivery (live)</a></li> <li><a href="i18n/10_Interaction_Specification.html#2-key-list-admini18nkeys-planned">2. Key List (<code>/admin/i18n/keys</code>) (planned)</a></li> <li><a href="i18n/10_Interaction_Specification.html#3-key-editor-planned">3. Key Editor (planned)</a></li> <li><a href="i18n/10_Interaction_Specification.html#4-locale-management-planned">4. Locale Management (planned)</a></li> <li><a href="i18n/10_Interaction_Specification.html#5-sync-view-planned">5. Sync view (planned)</a></li> <li><a href="i18n/10_Interaction_Specification.html#6-motion-tokens-from-00-shared08">6. Motion tokens (from 00-shared/08)</a></li> <li><a href="i18n/10_Interaction_Specification.html#7-haptictactile">7. Haptic/tactile</a></li> <li><a href="i18n/10_Interaction_Specification.html#8-error-feedback-matrix">8. Error feedback matrix</a></li> </ul> <blockquote> <p>Interaction, motion, and feedback rules for the i18n surface. Tokens and timing per <a href="i18n/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>; states legend per <a href="i18n/05_Screen_Inventory.html">05_Screen_Inventory.md</a>.</p> </blockquote> <hr /> <h2 id="1-catalog-delivery-live"><a class="header" href="#1-catalog-delivery-live">1. Catalog delivery (live)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Specification</th></tr></thead><tbody> <tr><td>App launch</td><td>fetch catalog for resolved locale (<code>GET /i18n/messages?locale=</code>, <code>i18n.controller.ts:13-17</code>) in parallel with session bootstrap; render with cached catalog first (offline-first), swap when fresh map arrives</td></tr> <tr><td>Locale change</td><td><strong>(forward-looking)</strong> refetch + rebuild widget tree; keep scroll positions</td></tr> <tr><td>Retry</td><td>3 attempts, exponential backoff 1 s / 2 s / 4 s; offline banner (<a href="i18n/../00-shared/03_Component_Library.html">00-shared/03</a>)</td></tr> <tr><td>Cache versioning <strong>(planned)</strong></td><td>catalog hash header on response; app refetches when hash differs; delta push <strong>(forward-looking)</strong></td></tr> </tbody></table> </div> <h2 id="2-key-list-admini18nkeys-planned"><a class="header" href="#2-key-list-admini18nkeys-planned">2. Key List (<code>/admin/i18n/keys</code>) <strong>(planned)</strong></a></h2> <div class="table-wrapper"><table><thead><tr><th>Gesture/input</th><th>Behavior</th></tr></thead><tbody> <tr><td>Tap row</td><td>open Key Editor (450 ms <code>m-base</code> slide)</td></tr> <tr><td>Long-press</td><td>multi-select mode; AppBar shows "3 selected" + bulk bar (delete / export)</td></tr> <tr><td><code>/</code></td><td>focus search; <code>Esc</code> clears filters</td></tr> <tr><td>Filter chip</td><td>immediate list filter with <code>m-base</code> fade (200 ms); badge counts animate</td></tr> <tr><td>Pull-to-refresh</td><td>re-pull from <code>GET /api/v1/i18n/keys</code>; silent on error</td></tr> <tr><td>Infinite scroll</td><td>"load more" at 25-row pages; skeleton 6 rows while fetching</td></tr> </tbody></table> </div> <p>Feedback: save/delete → AppSnackbar (module-scoped); errors → inline banner, never blocking.</p> <h2 id="3-key-editor-planned"><a class="header" href="#3-key-editor-planned">3. Key Editor (planned)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Behavior</th></tr></thead><tbody> <tr><td>Field focus</td><td>locale rail scrolls field into view (desktop); phone switches tab</td></tr> <tr><td>Typing</td><td>live placeholder parity check (debounced 300 ms); amber line appears without focus loss</td></tr> <tr><td>Save</td><td><code>Ctrl/Cmd+S</code> anywhere; button spinner; success = field check morph <code>m-fast</code> (120 ms)</td></tr> <tr><td>Dirty navigation</td><td>unsaved-changes dialog before leaving (discard/cancel)</td></tr> <tr><td>Conflict</td><td>409 → dialog "Reload or overwrite" (<a href="i18n/06_Screen_Specifications.html">06 §2</a>)</td></tr> <tr><td>Delete</td><td>destructive confirm; undo snackbar 5 s <strong>(planned)</strong></td></tr> <tr><td>Multiline growth</td><td>field auto-grows to 6 rows max, then scrolls internally</td></tr> </tbody></table> </div> <h2 id="4-locale-management-planned"><a class="header" href="#4-locale-management-planned">4. Locale Management (planned)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Behavior</th></tr></thead><tbody> <tr><td>Add locale</td><td>bottom sheet (phone) / dialog (desktop); on success → card appears with coverage ring animation <code>m-base</code> ease-out</td></tr> <tr><td>Coverage ring tap</td><td>drill into Key List filtered <code>missing:<locale></code></td></tr> <tr><td>Promote</td><td>enabled only at 100 % coverage + zero param drift; confirm dialog explaining runtime switch-over (new catalog becomes active for that locale)</td></tr> <tr><td>Demote</td><td>confirm; blocked if it holds the only copy of any key</td></tr> </tbody></table> </div> <h2 id="5-sync-view-planned"><a class="header" href="#5-sync-view-planned">5. Sync view (planned)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Behavior</th></tr></thead><tbody> <tr><td>Run now</td><td>spinner on button; results stream in rows as CI completes; stale state after 24 h</td></tr> <tr><td>Row expand</td><td>DriftTableRow expands missing-key list; "Fix" jumps to editor with filter preset</td></tr> <tr><td>Export</td><td>CSV download; success snackbar; failure toast</td></tr> </tbody></table> </div> <h2 id="6-motion-tokens-from-00-shared08"><a class="header" href="#6-motion-tokens-from-00-shared08">6. Motion tokens (from <a href="i18n/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Token</th><th>Use</th></tr></thead><tbody> <tr><td><code>m-fast</code> (120 ms)</td><td>field check, chip status change, badge update</td></tr> <tr><td><code>m-base</code> (200 ms)</td><td>list filter fade, pane slide, coverage ring</td></tr> <tr><td>ease-out</td><td>all entrances; no bounce effects on admin surfaces</td></tr> </tbody></table> </div> <h2 id="7-haptictactile"><a class="header" href="#7-haptictactile">7. Haptic/tactile</a></h2> <ul> <li>Phone: light haptic on save success; warning haptic on param-mismatch line (only on explicit save, not during typing).</li> <li>Desktop: none.</li> </ul> <h2 id="8-error-feedback-matrix"><a class="header" href="#8-error-feedback-matrix">8. Error feedback matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th>Error</th><th>Feedback</th></tr></thead><tbody> <tr><td>401 catalog fetch</td><td>silent retry ×1, then offline state; no nagging UI</td></tr> <tr><td>403 console</td><td>permission screen + "ask admin" copy (perm <code>i18n.*</code> <strong>(planned)</strong>)</td></tr> <tr><td>409 duplicate key</td><td>inline field error + "Open existing" link</td></tr> <tr><td>429 rate limit</td><td>countdown chip on Save (<a href="i18n/../00-shared/10_QA_Baseline.html">00-shared/10</a>)</td></tr> <tr><td>Network lost mid-save</td><td>banner "Saved locally, retrying"; draft preserved</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-i18n-module"><a class="header" href="#11--design-system-mapping-i18n-module">11 — Design System Mapping (i18n Module)</a></h1> <ul> <li><a href="i18n/11_Design_System_Mapping.html#1-token-usage">1. Token usage</a></li> <li><a href="i18n/11_Design_System_Mapping.html#2-component--token-mapping">2. Component → token mapping</a></li> <li><a href="i18n/11_Design_System_Mapping.html#3-state--token-mapping">3. State → token mapping</a></li> <li><a href="i18n/11_Design_System_Mapping.html#4-directionality">4. Directionality</a></li> <li><a href="i18n/11_Design_System_Mapping.html#5-iconography">5. Iconography</a></li> <li><a href="i18n/11_Design_System_Mapping.html#6-dark-mode--density">6. Dark mode / density</a></li> </ul> <blockquote> <p>Maps i18n surfaces to the shared design system (<a href="i18n/../00-shared/02_Design_Tokens.html">00-shared/02</a> tokens, <a href="i18n/../00-shared/04_Design_System_Mapping.html">00-shared/04</a> mapping). Everything here is <strong>(planned)</strong> console + mobile catalog consumption; the live API has no UI.</p> </blockquote> <hr /> <h2 id="1-token-usage-1"><a class="header" href="#1-token-usage-1">1. Token usage</a></h2> <div class="table-wrapper"><table><thead><tr><th>Token (<a href="i18n/../00-shared/02_Design_Tokens.html">00-shared/02</a>)</th><th>Where</th></tr></thead><tbody> <tr><td><code>color.surface/primary/error/warning</code></td><td>per-locale field states (idle / focus / error / amber mismatch)</td></tr> <tr><td><code>color.text.secondary</code></td><td>placeholder checker line, locale endonyms</td></tr> <tr><td><code>radius.sm/md</code></td><td>chips, field cards, coverage ring background</td></tr> <tr><td><code>spacing.3/4</code></td><td>per-locale field stacking (≥ 12 dp between locale cards)</td></tr> <tr><td><code>type.mono</code></td><td>keys, params, drift table</td></tr> <tr><td><code>type.body</code></td><td>translation values; <strong>no forced font family</strong> — Indic glyph fallback (<a href="i18n/07_Component_Library.html">07 §8</a>)</td></tr> <tr><td><code>shadow.elevation-1</code></td><td>editor field cards on tablet master-detail</td></tr> </tbody></table> </div> <h2 id="2-component--token-mapping"><a class="header" href="#2-component--token-mapping">2. Component → token mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Component</th><th>Tokens / variants</th></tr></thead><tbody> <tr><td>AppTextField (translation value)</td><td>multiline variant, <code>TextCapitalization.none</code>, autocorrect off, amber outline = mismatch, red = block error</td></tr> <tr><td>AppChips (params)</td><td><code>{days}</code> rendered as <code>days</code> chip, <code>outline</code> variant</td></tr> <tr><td>AppBadge</td><td><code>missing</code> = amber, <code>ok</code> = green, <code>stale</code> = danger outline</td></tr> <tr><td>AppButton</td><td>primary = Save, danger = Delete, secondary = Export</td></tr> <tr><td>AppDialog</td><td>destructive variant with danger button for delete/demote</td></tr> <tr><td>AppSnackbar</td><td>success/error variants; undo action for delete <strong>(planned)</strong></td></tr> <tr><td>AppDataTable</td><td>default density, sticky header, hover row highlight (desktop)</td></tr> </tbody></table> </div> <h2 id="3-state--token-mapping"><a class="header" href="#3-state--token-mapping">3. State → token mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Token</th></tr></thead><tbody> <tr><td>missing translation</td><td>amber outline + amber text label (never color-only, <a href="i18n/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>)</td></tr> <tr><td>error</td><td><code>color.error</code> + icon + text message</td></tr> <tr><td>success</td><td>green check + snackbar</td></tr> <tr><td>disabled</td><td>disabled palette; Save disabled while pending / conflict</td></tr> <tr><td>permission</td><td>locked icon + secondary copy</td></tr> </tbody></table> </div> <h2 id="4-directionality"><a class="header" href="#4-directionality">4. Directionality</a></h2> <ul> <li>All 9 supported locales are LTR (<code>SUPPORTED_LOCALES</code>, <code>i18n.service.ts:11-21</code>).</li> <li>UI is LTR today; <code>TextDirection</code> must be driven by locale tag, never hardcoded, so an RTL locale <strong>(forward-looking)</strong> lights up without layout rewrites (<a href="i18n/07_Component_Library.html">07 §1</a>).</li> <li>Number formatting: use <code>intl</code>/<code>NumberFormat</code> per locale in the app (<a href="i18n/15_Flutter_Implementation_Guide.html">15 §3</a>); catalog values must not hardcode digits.</li> </ul> <h2 id="5-iconography"><a class="header" href="#5-iconography">5. Iconography</a></h2> <div class="table-wrapper"><table><thead><tr><th>Icon</th><th>Meaning</th></tr></thead><tbody> <tr><td>translate</td><td>module icon (console nav)</td></tr> <tr><td>check-circle</td><td>per-field saved</td></tr> <tr><td>warning-amber</td><td>placeholder mismatch / stale</td></tr> <tr><td>error-red</td><td>blocked state</td></tr> <tr><td>cloud-done / cloud-off</td><td>sync status</td></tr> <tr><td>globe</td><td>locale cards</td></tr> </tbody></table> </div> <h2 id="6-dark-mode--density"><a class="header" href="#6-dark-mode--density">6. Dark mode / density</a></h2> <ul> <li>Console inherits <a href="i18n/../00-shared/04_Design_System_Mapping.html">00-shared/04</a>; coverage ring uses semantic colors only (works in dark).</li> <li>Compact density on desktop (data-heavy console): row height 40 dp in key list.</li> <li>Text scale to 2.0 enforced on translation fields (<a href="i18n/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-i18n-module"><a class="header" href="#12--api-mapping-i18n-module">12 — API Mapping (i18n Module)</a></h1> <ul> <li><a href="i18n/12_API_Mapping.html#1-live-endpoints">1. Live endpoints</a> <ul> <li><a href="i18n/12_API_Mapping.html#get-apiv1i18nmessages"><code>GET /api/v1/i18n/messages</code></a></li> <li><a href="i18n/12_API_Mapping.html#locale-resolution-server-side-all-routes">Locale resolution (server-side, all routes)</a></li> </ul> </li> <li><a href="i18n/12_API_Mapping.html#2-planned-endpoints-admin-console">2. Planned endpoints (admin console)</a></li> <li><a href="i18n/12_API_Mapping.html#3-consumer-api-mapping-server-side-translation">3. Consumer API mapping (server-side translation)</a></li> <li><a href="i18n/12_API_Mapping.html#4-integration-surface-for-the-flutter-app">4. Integration surface for the Flutter app</a></li> </ul> <blockquote> <p>Exact endpoints, contracts and RBAC. Envelope conventions per <a href="i18n/../00-shared/07_API_Conventions.html">00-shared/07_API_Conventions.md</a>; versioning <code>/api/v1</code> URI (<code>src/main.ts:44-46</code>).</p> </blockquote> <hr /> <h2 id="1-live-endpoints"><a class="header" href="#1-live-endpoints">1. Live endpoints</a></h2> <h3 id="get-apiv1i18nmessages"><a class="header" href="#get-apiv1i18nmessages"><code>GET /api/v1/i18n/messages</code></a></h3> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Auth</td><td>JWT (<code>JwtAuthGuard</code>, <code>i18n.controller.ts:8</code>) — Bearer token required</td></tr> <tr><td>RBAC permission</td><td><strong>none</strong> (guard only; no <code>i18n.*</code> perm exists in <code>permissions.constants.ts</code> — verified: all 97 perms, no i18n entries)</td></tr> <tr><td>Query</td><td><code>locale?: string</code> (optional; default = resolved locale)</td></tr> <tr><td>Behavior</td><td><code>locale</code> provided → <code>CATALOGS[locale]</code>; else resolved locale; unknown/missing → English <code>DEFAULT_CATALOG</code> (<code>i18n.service.ts:47-49</code>)</td></tr> <tr><td>Response</td><td>flat <code>Record<string,string></code> (key → localized template), e.g. <code>{ "app.welcome": "Welcome to StudyLyon", "leave.requested": "Leave requested for {days} day(s)." }</code></td></tr> <tr><td>Source</td><td><code>src/modules/i18n/i18n.controller.ts:13-17</code> → <code>I18nService.getMessages</code></td></tr> </tbody></table> </div> <h3 id="locale-resolution-server-side-all-routes"><a class="header" href="#locale-resolution-server-side-all-routes">Locale resolution (server-side, all routes)</a></h3> <p><code>Accept-Language: hi-IN, hi;q=0.9</code> → first tag → strip region → lowercase → whitelist check → <code>ctx.locale</code> (<code>i18n.middleware.ts:13-18</code>). Applied globally (<code>src/app/app.module.ts:139</code>). Read via <code>TenantContextService.getLocale()</code> (<code>src/shared/tenant/tenant-context.service.ts:58-60</code>).</p> <hr /> <h2 id="2-planned-endpoints-admin-console"><a class="header" href="#2-planned-endpoints-admin-console">2. Planned endpoints (admin console)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Method</th><th>Path</th><th>Purpose</th><th>RBAC <strong>(planned)</strong></th><th>Status</th></tr></thead><tbody> <tr><td>GET</td><td><code>/api/v1/i18n/keys</code></td><td>list keys + per-locale status (paginated, filter/search)</td><td><code>i18n.key.read</code></td><td><strong>(planned)</strong></td></tr> <tr><td>POST</td><td><code>/api/v1/i18n/keys</code></td><td>create key + en source</td><td><code>i18n.key.create</code></td><td><strong>(planned)</strong></td></tr> <tr><td>GET</td><td><code>/api/v1/i18n/keys/:key</code></td><td>one key, all locale values + params + version</td><td><code>i18n.key.read</code></td><td><strong>(planned)</strong></td></tr> <tr><td>PUT</td><td><code>/api/v1/i18n/keys/:key</code></td><td>update values (params parity enforced)</td><td><code>i18n.key.update</code></td><td><strong>(planned)</strong></td></tr> <tr><td>DELETE</td><td><code>/api/v1/i18n/keys/:key</code></td><td>delete key in all locales</td><td><code>i18n.key.delete</code></td><td><strong>(planned)</strong></td></tr> <tr><td>POST</td><td><code>/api/v1/i18n/keys/bulk</code></td><td>bulk create/update (import CSV)</td><td><code>i18n.key.update</code></td><td><strong>(planned)</strong></td></tr> <tr><td>GET</td><td><code>/api/v1/i18n/locales</code></td><td>list locales + coverage</td><td><code>i18n.locale.read</code></td><td><strong>(planned)</strong></td></tr> <tr><td>POST</td><td><code>/api/v1/i18n/locales</code></td><td>add/promote locale</td><td><code>i18n.locale.manage</code></td><td><strong>(planned)</strong></td></tr> <tr><td>GET</td><td><code>/api/v1/i18n/drift</code></td><td>missing-keys + param-drift report</td><td><code>i18n.locale.read</code></td><td><strong>(planned)</strong></td></tr> </tbody></table> </div> <blockquote> <p><strong>Gap:</strong> none of the <code>i18n.*</code> permissions exist yet — they must be added to <code>src/modules/rbac/permissions.constants.ts</code> before any console endpoint ships (today: 97 perms, <code>permissions.constants.ts:1-97</code>).</p> </blockquote> <hr /> <h2 id="3-consumer-api-mapping-server-side-translation"><a class="header" href="#3-consumer-api-mapping-server-side-translation">3. Consumer API mapping (server-side translation)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Consumer</th><th>Mechanism</th><th>Today</th><th>Source</th></tr></thead><tbody> <tr><td>Notifications</td><td>event → hardcoded EN title/body</td><td>EN-only; drift risk</td><td><code>notifications.handler.ts:16-31,41-49</code></td></tr> <tr><td>Announcement publish</td><td><code>AnnouncementPublished</code> event payload title</td><td>tenant-authored content — <strong>not</strong> translated</td><td><code>announcement.service.ts:86-91</code></td></tr> <tr><td>Leave request</td><td>key <code>leave.requested</code> with <code>{days}</code> exists</td><td>no consumer wired yet</td><td><code>en.json:9</code></td></tr> <tr><td>Error responses</td><td><code>errors.*</code> keys exist</td><td>HTTP filter not yet wired to <code>translate()</code></td><td><code>en.json:3-8</code></td></tr> <tr><td><strong>Planned</strong></td><td>notifications + errors consume <code>I18nService.translate()</code> with event/user locale</td><td>—</td><td><code>IMPLEMENTATION_PLAN.md:171</code></td></tr> </tbody></table> </div> <p>Interpolation contract (consumers must pass): <code>translate(key, { name: value })</code> → <code>{name}</code> replaced; missing params left literal (<code>i18n.service.ts:42-44</code>).</p> <h2 id="4-integration-surface-for-the-flutter-app"><a class="header" href="#4-integration-surface-for-the-flutter-app">4. Integration surface for the Flutter app</a></h2> <p>App fetches <code>GET /i18n/messages?locale=<current></code> → flat map → ARB merge (<a href="i18n/15_Flutter_Implementation_Guide.html">15_Flutter_Implementation_Guide.md §2</a>). Keep locale list in sync with <code>SUPPORTED_LOCALES</code> (<code>i18n.service.ts:11-21</code>): en, hi, ta, te, kn, ml, gu, mr, bn.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-i18n-module"><a class="header" href="#13--state-management-i18n-module">13 — State Management (i18n Module)</a></h1> <ul> <li><a href="i18n/13_State_Management.html#1-state-domain">1. State domain</a></li> <li><a href="i18n/13_State_Management.html#2-cubit-breakdown-console-planned">2. Cubit breakdown (console, (planned))</a></li> <li><a href="i18n/13_State_Management.html#3-state-flow--catalog-delivery-mobile">3. State flow — catalog delivery (mobile)</a></li> <li><a href="i18n/13_State_Management.html#4-state-flow--key-editor-save-console-planned">4. State flow — key editor save (console, (planned))</a></li> <li><a href="i18n/13_State_Management.html#5-rules">5. Rules</a></li> </ul> <blockquote> <p>Client state for catalog delivery (mobile) and the <strong>(planned)</strong> console. Framework conventions per <a href="i18n/../00-shared/06_State_Management.html">00-shared/06_State_Management.md</a>.</p> </blockquote> <hr /> <h2 id="1-state-domain"><a class="header" href="#1-state-domain">1. State domain</a></h2> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Lives where</th><th>Source</th></tr></thead><tbody> <tr><td>Locale</td><td>server request context (<code>ctx.locale</code>)</td><td><code>tenant-context.service.ts:58-60</code></td></tr> <tr><td>Catalog (flat map)</td><td>app-side cache (per locale)</td><td><code>getMessages</code> response, <code>i18n.service.ts:47-49</code></td></tr> <tr><td>Catalog version/hash <strong>(planned)</strong></td><td>app cache + response header</td><td>—</td></tr> <tr><td>Editor state (keys, dirty, parity)</td><td>console Cubits</td><td><a href="i18n/06_Screen_Specifications.html">06_Screen_Specifications.md</a></td></tr> <tr><td>Coverage/drift report</td><td>console Cubit, fetched on demand</td><td><code>GET /api/v1/i18n/drift</code> <strong>(planned)</strong></td></tr> </tbody></table> </div> <h2 id="2-cubit-breakdown-console-planned"><a class="header" href="#2-cubit-breakdown-console-planned">2. Cubit breakdown (console, <strong>(planned)</strong>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Cubit</th><th>State</th><th>Events</th></tr></thead><tbody> <tr><td><code>CatalogCubit</code></td><td><code>{ locale, catalog: Map<String,String>, version, status: loading/ready/error }</code></td><td><code>fetch(locale)</code>, <code>localeChanged</code>, <code>refresh</code></td></tr> <tr><td><code>KeyListCubit</code></td><td><code>{ keys, filters: {namespace, missingIn: String?, hasParams}, page, hasMore, status }</code></td><td><code>search(q)</code>, <code>toggleFilter(f)</code>, <code>loadMore()</code>, <code>bulkDelete(ids)</code></td></tr> <tr><td><code>KeyEditorCubit</code></td><td><code>{ key, params, values: Map<locale,String>, dirty: Set<locale>, parity: Map<locale,ParityStatus>, version, saving }</code></td><td><code>load(key)</code>, <code>editValue(locale,v)</code>, <code>save()</code>, <code>delete()</code>, <code>reload()</code></td></tr> <tr><td><code>LocaleCubit</code></td><td><code>{ locales: Map<tag,{coverage,direction,status}>, promoting }</code></td><td><code>load()</code>, <code>add(tag)</code>, <code>promote(tag)</code>, <code>demote(tag)</code></td></tr> <tr><td><code>SyncCubit</code></td><td><code>{ report, lastRunAt, exporting }</code></td><td><code>run()</code>, <code>export()</code></td></tr> </tbody></table> </div> <p>Parity computation (pure function, unit-testable): <code>parity(locale) = diff(valueParams(value), sourceParams(en))</code> using <code>/\{(\w+)\}/g</code> (<code>i18n.service.ts:42-44</code>).</p> <h2 id="3-state-flow--catalog-delivery-mobile"><a class="header" href="#3-state-flow--catalog-delivery-mobile">3. State flow — catalog delivery (mobile)</a></h2> <pre class="mermaid">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 </pre> <h2 id="4-state-flow--key-editor-save-console-planned"><a class="header" href="#4-state-flow--key-editor-save-console-planned">4. State flow — key editor save (console, <strong>(planned)</strong>)</a></h2> <pre class="mermaid">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] </pre> <h2 id="5-rules"><a class="header" href="#5-rules">5. Rules</a></h2> <ul> <li>Catalog map is <strong>immutable</strong> after fetch; updates replace the whole map (locale keyed) — no partial mutation.</li> <li>Editor keeps in-memory drafts; leaving with <code>dirty</code> non-empty triggers the unsaved-changes guard (<code>10 §3</code>).</li> <li>One locale per catalog fetch; locale switch swaps the map, never merges.</li> <li>Console state is local-only today (no server push); WebSocket refresh of coverage <strong>(forward-looking)</strong>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-i18n-module"><a class="header" href="#14--qa-checklist-i18n-module">14 — QA Checklist (i18n Module)</a></h1> <ul> <li><a href="i18n/14_QA_Checklist.html#1-runtime-contract-must-always-pass">1. Runtime contract (must always pass)</a></li> <li><a href="i18n/14_QA_Checklist.html#2-missing-keys--drift">2. Missing keys / drift</a></li> <li><a href="i18n/14_QA_Checklist.html#3-placeholder-mismatch-highest-value-bug-class">3. Placeholder mismatch (highest-value bug class)</a></li> <li><a href="i18n/14_QA_Checklist.html#4-encoding--unicode">4. Encoding & Unicode</a></li> <li><a href="i18n/14_QA_Checklist.html#5-sync-across-locales">5. Sync across locales</a></li> <li><a href="i18n/14_QA_Checklist.html#6-performance--offline">6. Performance & offline</a></li> <li><a href="i18n/14_QA_Checklist.html#7-security--rbac">7. Security & RBAC</a></li> <li><a href="i18n/14_QA_Checklist.html#8-regression-drivers-automated">8. Regression drivers (automated)</a></li> <li><a href="i18n/14_QA_Checklist.html#9-manual-smoke-10-min">9. Manual smoke (10 min)</a></li> </ul> <blockquote> <p>Quality gates for the i18n module. Baseline per <a href="i18n/../00-shared/10_QA_Baseline.html">00-shared/10_QA_Baseline.md</a>; runtime contract per <code>i18n.service.spec.ts</code> and <code>i18n.service.ts:32-49</code>.</p> </blockquote> <hr /> <h2 id="1-runtime-contract-must-always-pass"><a class="header" href="#1-runtime-contract-must-always-pass">1. Runtime contract (must always pass)</a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>translate(key)</code> with no locale → English (<code>spec:25-29</code>)</li> <li><input disabled="" type="checkbox"/> <code>translate(key)</code> with <code>hi</code> context → Hindi (<code>spec:31-36</code>)</li> <li><input disabled="" type="checkbox"/> interpolation: <code>translate('leave.requested', {days: 3})</code> → <code>"Leave requested for 3 day(s)."</code> / Hindi equivalent (<code>spec:38-42</code>)</li> <li><input disabled="" type="checkbox"/> unknown key → returns the <strong>key literal</strong> (<code>spec:44-46</code>)</li> <li><input disabled="" type="checkbox"/> <code>getMessages('xx')</code> → English catalog (<code>spec:48-51</code>)</li> <li><input disabled="" type="checkbox"/> <code>Accept-Language: hi-IN</code> → locale <code>hi</code>; <code>fr</code> → <code>en</code>; absent → <code>en</code> (<code>i18n.middleware.ts:16-17</code>)</li> <li><input disabled="" type="checkbox"/> <code>GET /api/v1/i18n/messages</code> returns 401 without Bearer token (<code>i18n.controller.ts:8</code>)</li> <li><input disabled="" type="checkbox"/> <code>?locale=hi</code> returns hi catalog regardless of header</li> </ul> <h2 id="2-missing-keys--drift"><a class="header" href="#2-missing-keys--drift">2. Missing keys / drift</a></h2> <ul> <li><input disabled="" type="checkbox"/> every shipped-locale file has the same key set (en vs hi parity)</li> <li><input disabled="" type="checkbox"/> CI drift check <strong>(planned)</strong> fails build when a key is missing in a shipped locale (<code>IMPLEMENTATION_PLAN.md:845</code>)</li> <li><input disabled="" type="checkbox"/> runtime missing key shows English, never blank, never crash</li> <li><input disabled="" type="checkbox"/> <strong>(planned)</strong> editor marks <code>missing</code> locales on the key list</li> </ul> <h2 id="3-placeholder-mismatch-highest-value-bug-class"><a class="header" href="#3-placeholder-mismatch-highest-value-bug-class">3. Placeholder mismatch (highest-value bug class)</a></h2> <ul> <li><input disabled="" type="checkbox"/> param set equality across locales for every key with params (<a href="i18n/08_Form_Specifications.html">08 §2</a>)</li> <li><input disabled="" type="checkbox"/> <code>{days}</code> present in value but not in params → runtime prints literal <code>{days}</code> (<code>i18n.service.ts:43</code>) — covered by parity check</li> <li><input disabled="" type="checkbox"/> extra <code>{x}</code> in a translation that source lacks → flagged amber</li> <li><input disabled="" type="checkbox"/> malformed braces (<code>{{days}}</code>, <code>{days </code>) — no crash, literal output; documented behavior</li> <li><input disabled="" type="checkbox"/> param <strong>order</strong> differs across locales (e.g. hi puts <code>{days}</code> first) — must still render correctly (order-insensitive regex)</li> </ul> <h2 id="4-encoding--unicode"><a class="header" href="#4-encoding--unicode">4. Encoding & Unicode</a></h2> <ul> <li><input disabled="" type="checkbox"/> all 9 target locales render: Devanagari (hi, mr), Tamil (ta), Telugu (te), Kannada (kn), Malayalam (ml), Gujarati (gu), Bengali (bn)</li> <li><input disabled="" type="checkbox"/> JSON files saved UTF-8; no escaped surrogate corruption (<code>translations/hi.json</code> verified: <code>"संसाधन नहीं मिला।"</code> at <code>hi.json:3</code>)</li> <li><input disabled="" type="checkbox"/> no mojibake in HTTP response (Content-Type <code>application/json; charset=utf-8</code>)</li> <li><input disabled="" type="checkbox"/> Indic text scale to 2.0 without clipping (<a href="i18n/11_Design_System_Mapping.html">11 §6</a>)</li> <li><input disabled="" type="checkbox"/> no forced Latin keyboard/font on translation fields (<a href="i18n/07_Component_Library.html">07 §8</a>)</li> <li><input disabled="" type="checkbox"/> numbers in translations use locale digits only if the locale does; <code>{days}</code> values are locale-formatted by the caller (App level)</li> </ul> <h2 id="5-sync-across-locales"><a class="header" href="#5-sync-across-locales">5. Sync across locales</a></h2> <ul> <li><input disabled="" type="checkbox"/> editing a key in one locale never touches others (except delete = all)</li> <li><input disabled="" type="checkbox"/> new key in en → appears as <code>missing</code> for all other shipped/declared locales</li> <li><input disabled="" type="checkbox"/> promote/demote locale (<strong>(planned)</strong>) recomputes coverage ring on all screens</li> <li><input disabled="" type="checkbox"/> bulk import (<strong>(planned)</strong>) validates: duplicate keys, empty values, param drift — rollback on any failure</li> <li><input disabled="" type="checkbox"/> concurrent edit conflict (409) → dialog, no silent overwrite (<a href="i18n/06_Screen_Specifications.html">06 §2</a>)</li> </ul> <h2 id="6-performance--offline"><a class="header" href="#6-performance--offline">6. Performance & offline</a></h2> <ul> <li><input disabled="" type="checkbox"/> catalog fetch < 500 ms p95 (small files: 9 keys today)</li> <li><input disabled="" type="checkbox"/> app renders from cache when offline; banner shown; no white screens</li> <li><input disabled="" type="checkbox"/> list pagination stable at 10k keys (<strong>(planned)</strong> console)</li> <li><input disabled="" type="checkbox"/> no re-fetch loop on repeated 401</li> </ul> <h2 id="7-security--rbac"><a class="header" href="#7-security--rbac">7. Security & RBAC</a></h2> <ul> <li><input disabled="" type="checkbox"/> <strong>no <code>i18n.*</code> perms exist today</strong> (<code>permissions.constants.ts:1-97</code>) — add <code>i18n.key.{read,create,update,delete}</code>, <code>i18n.locale.{read,manage}</code> <strong>(planned)</strong> before console ships</li> <li><input disabled="" type="checkbox"/> catalog endpoint requires valid JWT; no tenant data leak (catalogs are global by design)</li> <li><input disabled="" type="checkbox"/> console endpoints reject cross-tenant key injection (none — global; verify no tenantId path appears)</li> <li><input disabled="" type="checkbox"/> no XSS: catalog values rendered as text, never HTML (<code>{...}</code> interpolated as text)</li> </ul> <h2 id="8-regression-drivers-automated"><a class="header" href="#8-regression-drivers-automated">8. Regression drivers (automated)</a></h2> <ul> <li><code>i18n.service.spec.ts</code> — extend with: param order swap, empty value, malformed braces, region-tag resolution, <code>?locale</code> override.</li> <li>Add e2e: <code>GET /i18n/messages</code> auth, fallback, encoding (requires MongoDB + Redis, <code>npm run test:e2e</code>).</li> <li>Key-parity CI script <strong>(planned)</strong> compares key sets + params across <code>translations/*.json</code>.</li> </ul> <h2 id="9-manual-smoke-10-min"><a class="header" href="#9-manual-smoke-10-min">9. Manual smoke (10 min)</a></h2> <ol> <li>Set phone to <code>hi-IN</code> → open app → all strings Hindi.</li> <li>Set phone to <code>fr</code> → all strings English, no errors.</li> <li>Trigger a leave request notification → check <code>{days}</code> renders (consumer wiring <strong>(planned)</strong>).</li> <li>Admin edits <code>hi</code> value with wrong params → Save blocked/amber (<strong>(planned)</strong>).</li> <li>Verify <code>npm run typecheck</code> + <code>npm run lint</code> clean before merge.</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-i18n-module"><a class="header" href="#15--flutter-implementation-guide-i18n-module">15 — Flutter Implementation Guide (i18n Module)</a></h1> <ul> <li><a href="i18n/15_Flutter_Implementation_Guide.html#1-contract-recap-from-source">1. Contract recap (from source)</a></li> <li><a href="i18n/15_Flutter_Implementation_Guide.html#2-integration-strategy-arb--remote-overlay">2. Integration strategy: ARB + remote overlay</a> <ul> <li><a href="i18n/15_Flutter_Implementation_Guide.html#key--lookup-mapping">Key → lookup mapping</a></li> <li><a href="i18n/15_Flutter_Implementation_Guide.html#interpolation-mapping">Interpolation mapping</a></li> </ul> </li> <li><a href="i18n/15_Flutter_Implementation_Guide.html#3-locale-specifics">3. Locale specifics</a></li> <li><a href="i18n/15_Flutter_Implementation_Guide.html#4-cache--refresh">4. Cache & refresh</a></li> <li><a href="i18n/15_Flutter_Implementation_Guide.html#5-notifications-copy-planned-wiring">5. Notifications copy (planned wiring)</a></li> <li><a href="i18n/15_Flutter_Implementation_Guide.html#6-testing">6. Testing</a></li> <li><a href="i18n/15_Flutter_Implementation_Guide.html#7-file-checklist">7. File checklist</a></li> </ul> <blockquote> <p>Maps the backend i18n contract onto the Flutter app. Architecture baseline per <a href="i18n/../00-shared/11_Flutter_App_Architecture.html">00-shared/11_Flutter_App_Architecture.md</a>; state per <a href="i18n/13_State_Management.html">13_State_Management.md</a>.</p> </blockquote> <hr /> <h2 id="1-contract-recap-from-source"><a class="header" href="#1-contract-recap-from-source">1. Contract recap (from source)</a></h2> <ul> <li>Catalog endpoint: <code>GET /api/v1/i18n/messages?locale=</code> → flat <code>Map<String,String></code> (<code>i18n.controller.ts:13-17</code>); fallback English (<code>i18n.service.ts:47-49</code>).</li> <li>Locales: <code>en, hi, ta, te, kn, ml, gu, mr, bn</code> (<code>i18n.service.ts:11-21</code>); only en+hi shipped today.</li> <li>Interpolation: <code>{name}</code> regex <code>\{(\w+)\}</code> (<code>i18n.service.ts:42-44</code>); missing params stay literal <code>{name}</code>.</li> <li>Fallback chain on server: locale catalog → en → key.</li> </ul> <h2 id="2-integration-strategy-arb--remote-overlay"><a class="header" href="#2-integration-strategy-arb--remote-overlay">2. Integration strategy: ARB + remote overlay</a></h2> <pre class="mermaid">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 </pre> <ul> <li>Ship <strong>ARB files</strong> for offline + hot-reload dev (<code>flutter gen-l10n</code>).</li> <li>Add a <code>RemoteLookup</code> implementing <code>GlobalWidgetsLocalizations</code> / <code>AppLocalizationsDelegate</code> fallback chain: <strong>ARB first, remote overlay second, key literal last</strong> — mirroring server order (<code>i18n.service.ts:40</code>: key literal).</li> <li>Fetch via <code>CatalogCubit</code> (<code>13 §2</code>): fetch on launch after locale resolve; cache per locale with <code>shared_preferences</code>; version hash <strong>(planned)</strong> header triggers refetch.</li> <li>Locale resolution: <code>WidgetsBinding.instance.platformDispatcher.locale</code> → match against <code>SUPPORTED_LOCALES</code>-equivalent constant; fallback <code>en</code>.</li> </ul> <h3 id="key--lookup-mapping"><a class="header" href="#key--lookup-mapping">Key → lookup mapping</a></h3> <p>Backend keys are dotted (<code>errors.resourceNotFound</code>). ARB keys must be camelCase — map once in a generated <code>KeyMap</code>: <code>errors.resourceNotFound</code> → <code>errorsResourceNotFound</code>. <strong>Never duplicate strings</strong>; the ARB file holds source values and the remote catalog overrides at runtime.</p> <h3 id="interpolation-mapping"><a class="header" href="#interpolation-mapping">Interpolation mapping</a></h3> <pre><code class="language-dart">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; } </code></pre> <ul> <li>Regex parity: server uses <code>\{(\w+)\}</code> (<code>i18n.service.ts:42</code>); implement the same on the client so <code>{days}</code> behaves identically offline and online.</li> <li>Missing param → literal <code>{days}</code> (matches server <code>i18n.service.ts:43</code>).</li> </ul> <h2 id="3-locale-specifics"><a class="header" href="#3-locale-specifics">3. Locale specifics</a></h2> <div class="table-wrapper"><table><thead><tr><th>Item</th><th>Guide</th></tr></thead><tbody> <tr><td><code>intl</code> setup</td><td><code>Intl.defaultLocale</code> = resolved locale; <code>MaterialApp(localizationsDelegates: ...)</code></td></tr> <tr><td>Plurals</td><td><code>leave.requested</code> uses <code>{days}</code> with English "(s)" hack — replace with proper pluralization via ARB <code>plural</code> when the key is re-worked <strong>(planned)</strong></td></tr> <tr><td>Numbers/dates</td><td>use <code>NumberFormat</code>/<code>DateFormat</code> per locale, never hardcode digits in translation values</td></tr> <tr><td>Direction</td><td><code>textDirection</code> from locale tag (all current locales LTR; RTL <strong>(forward-looking)</strong>)</td></tr> <tr><td>Fonts</td><td>no forced <code>fontFamily</code> for Indic text; system fallback (<code>07 §8</code>); test Devanagari/Tamil at 2.0 scale</td></tr> <tr><td>IME</td><td>translation-adjacent inputs: <code>TextCapitalization.none</code>, autocorrect off</td></tr> </tbody></table> </div> <h2 id="4-cache--refresh"><a class="header" href="#4-cache--refresh">4. Cache & refresh</a></h2> <pre><code class="language-dart">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 </code></pre> <ul> <li>Cache key: <code>catalog_<locale></code>; TTL 24 h + version-hash refetch <strong>(planned)</strong>; delta push <strong>(forward-looking)</strong>.</li> </ul> <h2 id="5-notifications-copy-planned-wiring"><a class="header" href="#5-notifications-copy-planned-wiring">5. Notifications copy (planned wiring)</a></h2> <p>Server notification titles are hardcoded English today (<code>notifications.handler.ts:16-31</code>); <strong>planned</strong>: move to keys, e.g. <code>notification.passwordReset</code>, interpolated server-side per recipient locale (<code>12_API_Mapping.md §3</code>). Client just renders <code>title</code>/<code>body</code> from the <code>Notification</code> document (<code>schemas/notification.schema.ts:22-26</code>).</p> <h2 id="6-testing"><a class="header" href="#6-testing">6. Testing</a></h2> <ul> <li>Golden: catalog parity test — for every key in <code>en.arb</code>, assert <code>remoteCatalog</code> contains it (guards missing-key drift; CI script <strong>(planned)</strong> per <code>IMPLEMENTATION_PLAN.md:845</code>).</li> <li>Widget test: <code>tr('leave.requested', {'days': 3})</code> renders both en/hi.</li> <li><code>flutter test</code> + <code>npm run typecheck</code> + <code>npm run lint</code> before merge.</li> </ul> <h2 id="7-file-checklist"><a class="header" href="#7-file-checklist">7. File checklist</a></h2> <div class="table-wrapper"><table><thead><tr><th>File</th><th>Purpose</th></tr></thead><tbody> <tr><td><code>l10n.yaml</code></td><td>gen-l10n config, <code>en</code>/<code>hi</code> templates</td></tr> <tr><td><code>lib/l10n/app_en.arb</code>, <code>app_hi.arb</code></td><td>shipped locales</td></tr> <tr><td><code>lib/l10n/app_ta.arb</code> … <code>bn</code></td><td><strong>(planned)</strong> as catalogs ship server-side</td></tr> <tr><td><code>lib/i18n/key_map.dart</code></td><td>dotted → camelCase map</td></tr> <tr><td><code>lib/i18n/catalog_cubit.dart</code></td><td>fetch/cache/refresh (<code>13 §2</code>)</td></tr> <tr><td><code>lib/i18n/remote_lookup.dart</code></td><td>ARB→remote→key fallback</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-leave-module"><a class="header" href="#01--product-overview-leave-module">01 — Product Overview (Leave Module)</a></h1> <ul> <li><a href="leave/01_Product_Overview.html#1-what-the-module-is">1. What the module is</a></li> <li><a href="leave/01_Product_Overview.html#2-core-domain-facts-from-source">2. Core domain facts (from source)</a></li> <li><a href="leave/01_Product_Overview.html#3-who-uses-it-roles-from-roleschemats8-65">3. Who uses it (roles from <code>role.schema.ts:8-65</code>)</a></li> <li><a href="leave/01_Product_Overview.html#4-scope-in--out">4. Scope in / out</a></li> <li><a href="leave/01_Product_Overview.html#5-prd-native-app-exclusion-flagged">5. PRD native-app exclusion (flagged)</a></li> <li><a href="leave/01_Product_Overview.html#6-client-surface-screens--see-0506">6. Client surface (screens) — see 05/06</a></li> </ul> <blockquote> <p>Employee leave requests, balance tracking, and substitute-teacher assignment. Derived from <code>src/modules/leave/**</code> (controller, service, schemas, DTOs, events, repositories), <code>src/modules/rbac/schemas/role.schema.ts</code>, <code>src/modules/rbac/permissions.constants.ts</code>, <code>src/modules/users/schemas/user.schema.ts</code>, <code>studylyon-blueprint/03-Database/COLLECTIONS.md</code>, <code>studylyon-blueprint/04-Modules/Attendance.md</code>, and <code>docs/IMPLEMENTATION_PLAN.md</code>. Nothing here is invented; plan-only capability is marked <code>(planned)</code>, client-only or roadmap-only <code>(forward-looking)</code>, analytics <code>(proposed)</code>. Shared platform context: <a href="leave/../00-shared/01_Product_Overview.html">00-shared/01</a>.</p> </blockquote> <hr /> <h2 id="1-what-the-module-is-2"><a class="header" href="#1-what-the-module-is-2">1. What the module is</a></h2> <p>The Leave module lets an authenticated user of a tenant request time off, an <strong>org admin</strong> decide (approve/reject) those requests, and admins assign a <strong>substitute teacher</strong> for an approved absence. Balances are <strong>computed live</strong> from leave types plus approved requests — there is <strong>no stored balance collection</strong> (<code>leave.service.ts:87-88</code> — "live-computed balance from leave types + approved requests; no stored balance collection to drift from ground truth").</p> <p>The blueprint anticipated this module but does not specify it: <code>04-Modules/</code> has no Leave doc (only <code>Attendance.md:58</code> lists "Leave" as an attendance status value), and <code>COLLECTIONS.md:3643</code> places <code>leave_requests</code> under <strong>"27. Future Collections" → Human Resources</strong>. The implementation exists and is ahead of the blueprint; <code>docs/IMPLEMENTATION_PLAN.md:121-164</code> (Phase 2.2, "Leave & Substitution Module", 7.5 days) is the authoritative plan.</p> <h2 id="2-core-domain-facts-from-source"><a class="header" href="#2-core-domain-facts-from-source">2. Core domain facts (from source)</a></h2> <ul> <li><strong>Request model</strong> — <code>schemas/leave-request.schema.ts:16-48</code>: <code>userId</code> (ref User, required), <code>leaveTypeId</code> (ref LeaveType, required), <code>startDate</code>, <code>endDate</code> (required Date), <code>daysRequested</code> (number, <code>min: 1</code>, server-computed), <code>reason?</code>, <code>status</code> (default <code>pending</code>), <code>decidedBy?</code>, <code>decidedAt?</code>, <code>decisionNote?</code>. Collection <code>leave_requests</code>. Indexes <code>(tenantId, userId, startDate)</code> and <code>(tenantId, status)</code> (<code>:52-53</code>).</li> <li><strong>Status enum</strong> — <code>leave-request.schema.ts:7-12</code>: <code>pending | approved | rejected | cancelled</code>. Note: <code>cancelled</code> is defined but <strong>no cancel endpoint exists</strong> — the status is unreachable today (gap).</li> <li><strong>Leave type model</strong> — <code>schemas/leave-type.schema.ts:9-25</code>: <code>code</code>, <code>name</code>, <code>daysPerYear</code> (<code>min: 0</code>), <code>carryForward</code> (default false), <code>maxCarryForward?</code>, <code>isDefault</code>. Unique <code>(tenantId, code)</code> (<code>:29</code>).</li> <li><strong>Default types</strong> — <code>leave.service.ts:30-64</code>: <code>CL</code> Casual 12 (no carry), <code>SL</code> Sick 10 (carry, max 30), <code>EL</code> Earned 15 (carry, max 60), <code>ML</code> Maternity 180, <code>PL</code> Paternity 15. Seeded lazily and idempotently on first access via <code>bulkWrite</code> <code>$setOnInsert</code> upserts (<code>ensureDefaultTypes</code>, <code>leave.service.ts:299-306</code>).</li> <li><strong>Balance math</strong> — <code>leave.service.ts:89-124</code>: carried forward = <code>carryForward ? min(maxCarryForward, max(0, daysPerYear − usedPrevYear)) : 0</code> (<code>:107-112</code>); <code>daysRemaining = daysPerYear + carriedForward − daysUsed</code> (<code>:121</code>). Balances use <strong>calendar years</strong>, lookback to Jan 1 of the previous year (<code>:92-97</code>).</li> <li><strong>Day counting</strong> — <code>leave.service.ts:310-312</code>: <code>countDays</code> is an <strong>inclusive calendar-day count</strong> (<code>(end − start)/86 400 000 + 1</code>); weekends and holidays are counted as leave days (ponytail comment: "switch to working-day count if the school policy needs it").</li> <li><strong>Approval semantics</strong> — <code>leave.service.ts:171-212</code>: only <code>pending</code> requests can be decided (<code>:175-178</code>, else 409); a user <strong>cannot decide their own request</strong> (<code>:179-180</code>, 409); approval re-checks live balance and rejects with 409 "Insufficient leave balance." when <code>daysRemaining < daysRequested</code> (<code>:183-190</code>). Decision writes <code>status</code>, <code>decidedBy</code>, <code>decidedAt</code>, <code>decisionNote</code> (<code>:195-200</code>) and emits <code>LeaveApproved</code> / <code>LeaveRejected</code>.</li> <li><strong>Substitutions</strong> — <code>schemas/substitution.schema.ts:14-47</code>: <code>leaveRequestId</code>, <code>absentTeacherId</code>, <code>substituteTeacherId</code>, <code>classId</code>, <code>subjectId</code>, <code>date</code>, <code>startTime</code>, <code>endTime</code>, <code>status</code> (<code>assigned | completed | cancelled</code>, <code>:7-11</code>), <code>notes?</code>. Only <strong>approved</strong> requests can get a substitution (<code>leave.service.ts:227-230</code>, 409); the requester must have a <strong>Teacher</strong> record (<code>:232-238</code>, 404); time-slot clash for the substitute is rejected (<code>:241-250</code>, 409, <code>timeOverlaps</code> <code>:314-321</code>).</li> <li><strong>Scope rules</strong> — non-admins see <strong>only their own</strong> requests (<code>leave.service.ts:166-167</code>); org admins see all, optionally filtered by <code>userId</code> (<code>:163-167</code>). Requester identity always comes from the tenant context, never the body (<code>:138</code>).</li> </ul> <h2 id="3-who-uses-it-roles-from-roleschemats8-65"><a class="header" href="#3-who-uses-it-roles-from-roleschemats8-65">3. Who uses it (roles from <code>role.schema.ts:8-65</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Role</th><th>In leave module</th></tr></thead><tbody> <tr><td><code>org_admin</code> (priority 100, <code>ALL_PERMISSIONS</code> <code>role.schema.ts:22-24</code>)</td><td>Requests, lists all, decides, creates types, assigns substitutions</td></tr> <tr><td><code>teacher</code> (priority 50, <code>role.schema.ts:26-32</code>)</td><td>Requests leave; receives substitutions (needs a Teacher record)</td></tr> <tr><td><code>staff</code> (priority 40, <code>role.schema.ts:33-40</code>)</td><td>Can request leave (any authenticated user can)</td></tr> <tr><td><code>student</code> / <code>parent</code></td><td>No leave surface; schema allows but nothing gates it (gap)</td></tr> </tbody></table> </div> <p><strong>No <code>leave.*</code> permissions exist</strong> — <code>permissions.constants.ts:1-97</code> contains no leave entries, and leave endpoints carry only <code>JwtAuthGuard</code> (<code>leave.controller.ts:27</code>), with the org-admin check done inline via <code>ctx.roles</code> (<code>leave.service.ts:163</code>). This is a documented security gap.</p> <h2 id="4-scope-in--out"><a class="header" href="#4-scope-in--out">4. Scope in / out</a></h2> <div class="table-wrapper"><table><thead><tr><th>In scope (implemented)</th><th>Out of scope / gaps</th></tr></thead><tbody> <tr><td>Create request (<code>POST /leave/requests</code>)</td><td>Cancel request (<code>cancelled</code> status unreachable — no endpoint)</td></tr> <tr><td>List requests (own / all for admin, <code>status</code>+<code>userId</code> filters)</td><td>Edit/withdraw a pending request</td></tr> <tr><td>Approve / reject (balance-checked)</td><td><code>leave.*</code> RBAC permissions (<code>permissions.constants.ts</code> has none)</td></tr> <tr><td>Live-computed balance</td><td>Stored <code>leave-balance.schema.ts</code> — <code>(planned)</code> (<code>IMPLEMENTATION_PLAN.md:130</code>, not in code)</td></tr> <tr><td>Leave types CRUD (create + list; seeded defaults)</td><td>Update/delete leave type</td></tr> <tr><td>Substitutions (assign, list for teacher, clash check)</td><td>Substitution accept/decline by substitute, status transitions <code>completed/cancelled</code></td></tr> <tr><td>Calendar (approved only, month range)</td><td>Working-day counting (weekends/holidays) — see <code>leave.service.ts:310-312</code></td></tr> <tr><td>Events: <code>LeaveRequested</code>, <code>LeaveApproved/Rejected</code>, <code>SubstitutionAssigned</code> (<code>events/leave-events.ts:1-26</code>)</td><td>Substitution → Timetable entry + notifications — <code>(planned)</code> (<code>IMPLEMENTATION_PLAN.md:163</code>)</td></tr> </tbody></table> </div> <h2 id="5-prd-native-app-exclusion-flagged"><a class="header" href="#5-prd-native-app-exclusion-flagged">5. PRD native-app exclusion (flagged)</a></h2> <p>Per <a href="leave/../00-shared/01_Product_Overview.html">00-shared/01 §9</a>: <code>PRODUCT_REQUIREMENTS_DOCUMENT.md:144</code> 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 <strong>these docs specify a full-featured Flutter client now</strong>, to the complete leave API surface; any conflict with the web-first roadmap resolves in favor of these docs unless the roadmap is amended.</p> <h2 id="6-client-surface-screens--see-0506"><a class="header" href="#6-client-surface-screens--see-0506">6. Client surface (screens) — see 05/06</a></h2> <p>Request form · My requests list · Approvals queue (org admin) · Balance card · Leave types admin (org admin) · Substitutions (list + assign) · Calendar.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-leave-module"><a class="header" href="#02--user-personas-leave-module">02 — User Personas (Leave Module)</a></h1> <ul> <li><a href="leave/02_User_Personas.html#legend">Legend</a></li> <li><a href="leave/02_User_Personas.html#1-the-teacher--sneha-grade-8-science">1. The Teacher — "Sneha, Grade 8 Science"</a></li> <li><a href="leave/02_User_Personas.html#2-the-org-admin--hr--ramesh-administrator">2. The Org Admin / HR — "Ramesh, Administrator"</a></li> <li><a href="leave/02_User_Personas.html#3-the-substitute-teacher--imran-part-time-relief">3. The Substitute Teacher — "Imran, Part-time Relief"</a></li> <li><a href="leave/02_User_Personas.html#4-staff-non-teaching--priya-office-coordinator">4. Staff (non-teaching) — "Priya, Office Coordinator"</a></li> <li><a href="leave/02_User_Personas.html#5-system-actors-non-human">5. System actors (non-human)</a></li> <li><a href="leave/02_User_Personas.html#6-persona-priority">6. Persona priority</a></li> </ul> <blockquote> <p>Derived from <code>role.schema.ts:8-65</code> (system roles), <code>users/schemas/user.schema.ts:14-79</code>, and the leave service semantics (<code>leave.service.ts</code>). Behaviors that the API enforces are cited; anything else is product expectation <code>(proposed)</code>.</p> </blockquote> <hr /> <h2 id="legend-11"><a class="header" href="#legend-11">Legend</a></h2> <p><code>P</code> = permission/authority from source · <code>F</code> = friction/behavior note.</p> <hr /> <h2 id="1-the-teacher--sneha-grade-8-science"><a class="header" href="#1-the-teacher--sneha-grade-8-science">1. The Teacher — "Sneha, Grade 8 Science"</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Role</td><td><code>teacher</code> (<code>role.schema.ts:26-32</code>) — permissions <code>student.read</code>, <code>attendance.mark</code>, <code>attendance.edit</code></td></tr> <tr><td>Context</td><td>Full-time faculty; 30+ contact hours/week; mobile-first; marks attendance in app daily</td></tr> <tr><td>Leave needs</td><td>Sick days, casual time off, planned earned leave; must find a substitute before admin approves</td></tr> <tr><td>Key actions</td><td>Request leave (type, dates, reason); check balance before asking; watch request status; see substitutions assigned for their own classes</td></tr> <tr><td>API path</td><td><code>POST /leave/requests</code> (<code>leave.controller.ts:32-36</code>); <code>GET /leave/requests</code> (own only, <code>leave.service.ts:166-167</code>); <code>GET /leave/balance/:userId</code> (<code>:55-59</code>)</td></tr> <tr><td>Frustrations</td><td>Approvals that stall; balance surprises (weekend days count, <code>leave.service.ts:310-312</code>); no edit/withdraw path (gap)</td></tr> <tr><td>Success metric <code>(proposed)</code></td><td>Request-to-decision median < 24 h; zero requests rejected for "insufficient balance" after the user checked balance</td></tr> </tbody></table> </div> <h2 id="2-the-org-admin--hr--ramesh-administrator"><a class="header" href="#2-the-org-admin--hr--ramesh-administrator">2. The Org Admin / HR — "Ramesh, Administrator"</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Role</td><td><code>org_admin</code> — <code>ALL_PERMISSIONS</code> (<code>role.schema.ts:18-24</code>); detected inline in service via <code>ctx.roles.includes('org_admin')</code> (<code>leave.service.ts:163</code>)</td></tr> <tr><td>Context</td><td>Runs the institution's staffing; approves or rejects leave; configures leave types; assigns substitutes</td></tr> <tr><td>Key actions</td><td>Approvals queue (all requests, filter by <code>status</code>/<code>userId</code>, <code>leave.controller.ts:38-47</code>); decide with optional note (<code>PATCH /leave/requests/:id/approve</code>, <code>:49-53</code>); create leave types (<code>POST /leave/types</code>, <code>:61-65</code>); assign substitutes (<code>POST /leave/substitutions</code>, <code>:73-77</code>); view calendar of approved leave (<code>GET /leave/calendar</code>, <code>:85-91</code>)</td></tr> <tr><td>Decision rules enforced by API</td><td>Only <code>pending</code> can be decided (<code>leave.service.ts:175-178</code>); <strong>cannot approve own request</strong> (<code>:179-180</code>); approval blocked at 409 if balance insufficient (<code>:188-189</code>)</td></tr> <tr><td>Frustrations</td><td>Self-approval impossible by design (must delegate); no batch decisions; no leave-type edit/delete</td></tr> <tr><td>Success metric <code>(proposed)</code></td><td>Approvals resolved same-day; substitution assigned within 24 h of approval for multi-day leave</td></tr> </tbody></table> </div> <h2 id="3-the-substitute-teacher--imran-part-time-relief"><a class="header" href="#3-the-substitute-teacher--imran-part-time-relief">3. The Substitute Teacher — "Imran, Part-time Relief"</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Role</td><td><code>teacher</code> with a Teacher record (<code>teacherRepo.findOne</code>, <code>leave.service.ts:232-238</code>)</td></tr> <tr><td>Context</td><td>Covers classes for absent teachers; paid per covered slot</td></tr> <tr><td>Key actions</td><td>View assigned substitutions: <code>GET /leave/substitutions/teacher/:id</code> (<code>leave.controller.ts:79-83</code>) — list sorted by <code>date</code> ascending (<code>leave.service.ts:275-280</code>)</td></tr> <tr><td>API constraints</td><td>Admin assigns; substitute does <strong>not</strong> accept/decline (no endpoint); slot clash is rejected at assignment time (<code>leave.service.ts:241-250</code>)</td></tr> <tr><td>Frustrations</td><td>No accept/decline flow; no conflict visibility beyond admin checks; substitution status is stuck at <code>assigned</code> (no <code>completed/cancelled</code> transition in service)</td></tr> <tr><td>Success metric <code>(proposed)</code></td><td>Substitution discovery < 30 s after open; no-show rate tracked via Timetable <code>(planned, IMPLEMENTATION_PLAN.md:163)</code></td></tr> </tbody></table> </div> <h2 id="4-staff-non-teaching--priya-office-coordinator"><a class="header" href="#4-staff-non-teaching--priya-office-coordinator">4. Staff (non-teaching) — "Priya, Office Coordinator"</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Role</td><td><code>staff</code> (<code>role.schema.ts:33-40</code>, permission <code>student.read</code>)</td></tr> <tr><td>Context</td><td>Office staff; needs casual/sick leave like teachers; no substitution mechanics apply (no Teacher record → <code>404</code> on substitution, <code>leave.service.ts:232-238</code>)</td></tr> <tr><td>Key actions</td><td>Request leave; check balance; track status — same endpoints as Teacher</td></tr> <tr><td>Note</td><td>Any <strong>authenticated</strong> user can create a request (<code>userId</code> from tenant context, <code>leave.service.ts:138</code>); nothing in code restricts leave to staff/teachers (gap: <code>student</code>/<code>parent</code> roles are not excluded)</td></tr> </tbody></table> </div> <h2 id="5-system-actors-non-human"><a class="header" href="#5-system-actors-non-human">5. System actors (non-human)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Actor</th><th>Behavior</th></tr></thead><tbody> <tr><td>Event bus</td><td><code>LeaveRequested</code>, <code>LeaveApproved</code>/<code>LeaveRejected</code>, <code>SubstitutionAssigned</code> payloads (<code>events/leave-events.ts:1-26</code>) → notification workers <code>(planned)</code></td></tr> <tr><td>Audit log</td><td>All writes carry <code>createdBy/updatedBy</code> via <code>BaseSchema</code>; decision writes <code>decidedBy</code>, <code>decidedAt</code>, <code>decisionNote</code> (<code>leave-request.schema.ts:41-48</code>)</td></tr> <tr><td>Timetable <code>(planned)</code></td><td>Substitution creates a Timetable entry + notifies substitute and students (<code>IMPLEMENTATION_PLAN.md:163</code>)</td></tr> </tbody></table> </div> <h2 id="6-persona-priority"><a class="header" href="#6-persona-priority">6. Persona priority</a></h2> <ol> <li>Teacher (highest volume — requests + balance)</li> <li>Org Admin (decisions + substitutions)</li> <li>Substitute teacher</li> <li>Staff</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journey-leave-module"><a class="header" href="#03--user-journey-leave-module">03 — User Journey (Leave Module)</a></h1> <ul> <li><a href="leave/03_User_Journey.html#journey-1--teacher-requests-casual-leave-happy-path">Journey 1 — Teacher requests casual leave (happy path)</a></li> <li><a href="leave/03_User_Journey.html#journey-2--admin-assigns-a-substitute-post-approval">Journey 2 — Admin assigns a substitute (post-approval)</a></li> <li><a href="leave/03_User_Journey.html#journey-3--teacher-checks-coverage-before-requesting">Journey 3 — Teacher checks coverage before requesting</a></li> <li><a href="leave/03_User_Journey.html#journey-4--rejection-and-retry">Journey 4 — Rejection and retry</a></li> <li><a href="leave/03_User_Journey.html#journey-5--admin-configures-leave-types-org-admin-only">Journey 5 — Admin configures leave types <code>(org admin only)</code></a></li> <li><a href="leave/03_User_Journey.html#journey-6--edge-leave-overlaps-and-back-dating">Journey 6 — Edge: leave overlaps and back-dating</a></li> <li><a href="leave/03_User_Journey.html#journey-map-condensed">Journey map (condensed)</a></li> <li><a href="leave/03_User_Journey.html#metrics-proposed">Metrics <code>(proposed)</code></a></li> </ul> <blockquote> <p>End-to-end flows mapped to the exact endpoints they exercise. API-enforced branches are cited to source; <code>(planned)</code> = <code>IMPLEMENTATION_PLAN.md:121-164</code>; <code>(proposed)</code> = product expectation.</p> </blockquote> <hr /> <h2 id="journey-1--teacher-requests-casual-leave-happy-path"><a class="header" href="#journey-1--teacher-requests-casual-leave-happy-path">Journey 1 — Teacher requests casual leave (happy path)</a></h2> <ol> <li>Open <strong>Balance</strong> (<code>GET /leave/balance/:me</code> — <code>leave.controller.ts:55-59</code>) → see <code>daysRemaining</code> per type (<code>leave.service.ts:89-124</code>).</li> <li>Open <strong>Request form</strong> → pick leave type (<code>GET /leave/types</code>, <code>:67-71</code>; defaults seeded lazily <code>leave.service.ts:299-306</code>), start/end dates, reason.</li> <li>Submit <code>POST /leave/requests</code> (<code>:32-36</code>) → server computes <code>daysRequested</code> (<code>leave.service.ts:136</code>), stamps <code>userId</code> from token (<code>:138</code>), status <code>pending</code> (<code>:144</code>), emits <code>LeaveRequested</code> (<code>:146-154</code>). <ul> <li><strong>400</strong> if <code>endDate < startDate</code> (<code>:133-134</code>); <strong>404</strong> if leave type unknown (<code>:129</code>).</li> </ul> </li> <li>Request appears in <strong>My requests</strong> (<code>GET /leave/requests</code> — own only, <code>leave.service.ts:166-167</code>; sorted <code>createdAt</code> desc <code>:168</code>), badge <code>pending</code>.</li> <li>Org admin opens <strong>Approvals queue</strong> (<code>GET /leave/requests?status=pending</code>, <code>:38-47</code>), decides <code>PATCH /leave/requests/:id/approve</code> (<code>:49-53</code>). <ul> <li><strong>409</strong> if already decided (<code>leave.service.ts:175-178</code>), <strong>409</strong> if self-decision (<code>:179-180</code>), <strong>409</strong> if balance insufficient (<code>:188-189</code>).</li> </ul> </li> <li>Teacher sees status flip to <code>approved</code> (event <code>LeaveApproved</code>, <code>:210</code>) → balance now reflects deduction on next fetch.</li> </ol> <h2 id="journey-2--admin-assigns-a-substitute-post-approval"><a class="header" href="#journey-2--admin-assigns-a-substitute-post-approval">Journey 2 — Admin assigns a substitute (post-approval)</a></h2> <ol> <li>From an <strong>approved</strong> request, admin opens <strong>Assign substitution</strong> form.</li> <li><code>POST /leave/substitutions</code> (<code>leave.controller.ts:73-77</code>) with <code>leaveRequestId, substituteTeacherId, classId, subjectId, date, startTime, endTime, notes?</code>. <ul> <li><strong>409</strong> if request not <code>approved</code> (<code>leave.service.ts:227-230</code>).</li> <li><strong>404</strong> if requester has no Teacher record (<code>:232-238</code>).</li> <li><strong>409</strong> if substitute already booked in the slot (<code>:241-250</code>).</li> </ul> </li> <li>Substitution shows in substitute's <strong>My substitutions</strong> (<code>GET /leave/substitutions/teacher/:id</code>, <code>:79-83</code>; sorted by date <code>:275-280</code>).</li> <li><code>(planned)</code> Timetable entry created + notifications to substitute and students (<code>IMPLEMENTATION_PLAN.md:163</code>).</li> </ol> <h2 id="journey-3--teacher-checks-coverage-before-requesting"><a class="header" href="#journey-3--teacher-checks-coverage-before-requesting">Journey 3 — Teacher checks coverage before requesting</a></h2> <ol> <li>Open <strong>Calendar</strong> (<code>GET /leave/calendar?from&to</code>, <code>leave.controller.ts:85-91</code>) → approved requests overlapping the range (<code>leave.service.ts:282-296</code>); default = current month (<code>:283-287</code>).</li> <li>Decide dates avoiding collisions with colleagues (calendar is tenant-scoped via <code>BaseRepository</code>, not filtered by user).</li> </ol> <h2 id="journey-4--rejection-and-retry"><a class="header" href="#journey-4--rejection-and-retry">Journey 4 — Rejection and retry</a></h2> <ol> <li>Admin rejects with note (<code>PATCH /leave/requests/:id/approve</code>, action <code>reject</code>, <code>leave-decision.dto.ts:4-7</code>) → <code>decisionNote</code> stored (<code>leave.service.ts:199</code>), event <code>LeaveRejected</code> (<code>:210</code>).</li> <li>Teacher sees <code>rejected</code> + note in My requests.</li> <li>Teacher edits dates in a <strong>new</strong> request and resubmits (no edit/retry of the old request — no PATCH-request endpoint; gap).</li> <li><code>(planned)</code> notification of the decision reaches the teacher via the event bus (<code>LeaveDecidedEvent</code>, <code>events/leave-events.ts:10-18</code>).</li> </ol> <h2 id="journey-5--admin-configures-leave-types-org-admin-only"><a class="header" href="#journey-5--admin-configures-leave-types-org-admin-only">Journey 5 — Admin configures leave types <code>(org admin only)</code></a></h2> <ol> <li>Open <strong>Leave types</strong> → list (<code>GET /leave/types</code>, <code>:67-71</code>), defaults always present (<code>ensureDefaultTypes</code>, <code>leave.service.ts:299-306</code>).</li> <li>Create custom type <code>POST /leave/types</code> (<code>:61-65</code>) — <code>code, name, daysPerYear, carryForward?, maxCarryForward?</code> (<code>create-leave-type.dto.ts:4-28</code>). Unique <code>(tenantId, code)</code> (<code>leave-type.schema.ts:29</code>) → <strong>11000 duplicate-key error surfaces as a conflict</strong> (no dedicated handler in service — gap).</li> <li>Balance screen now includes the new type automatically (computed from types, <code>leave.service.ts:89-124</code>).</li> </ol> <h2 id="journey-6--edge-leave-overlaps-and-back-dating"><a class="header" href="#journey-6--edge-leave-overlaps-and-back-dating">Journey 6 — Edge: leave overlaps and back-dating</a></h2> <ul> <li>No <strong>same-user overlap</strong> validation exists at create time (<code>createRequest</code>, <code>leave.service.ts:126-156</code>); two overlapping <code>pending</code> requests can both exist. QA treats this as a known gap (see <a href="leave/14_QA_Checklist.html">14_QA_Checklist</a> §2); a future server rule or client-side warning <code>(forward-looking)</code>.</li> <li>Back-dated requests are allowed — no rule prevents <code>startDate</code> in the past.</li> </ul> <h2 id="journey-map-condensed"><a class="header" href="#journey-map-condensed">Journey map (condensed)</a></h2> <pre><code>[Balance check] → [Request] → pending → [Admin queue] → approve → [Calendar/status] ↘ reject → note → [Retry as new request] [Approved] → [Assign substitution] → [Substitute sees assignment] → (planned) timetable+notify </code></pre> <h2 id="metrics-proposed"><a class="header" href="#metrics-proposed">Metrics <code>(proposed)</code></a></h2> <p>Request → decision median; reject rate by reason; substitution coverage of approved leave; balance-sufficiency collisions.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-leave-module"><a class="header" href="#04--information-architecture-leave-module">04 — Information Architecture (Leave Module)</a></h1> <ul> <li><a href="leave/04_Information_Architecture.html#1-navigation-tree">1. Navigation tree</a></li> <li><a href="leave/04_Information_Architecture.html#2-role-gating-from-source">2. Role gating (from source)</a></li> <li><a href="leave/04_Information_Architecture.html#3-content-types">3. Content types</a></li> <li><a href="leave/04_Information_Architecture.html#4-screen-to-data-map">4. Screen-to-data map</a></li> <li><a href="leave/04_Information_Architecture.html#5-known-ia-gaps-from-source">5. Known IA gaps (from source)</a></li> <li><a href="leave/04_Information_Architecture.html#6-entry-points">6. Entry points</a></li> </ul> <blockquote> <p>App-level navigation pattern per <a href="leave/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a> (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 <code>(forward-looking)</code> until the Flutter client lands; the API surface is authoritative (<code>leave.controller.ts:25-28</code>, tag <code>leave</code>).</p> </blockquote> <hr /> <h2 id="1-navigation-tree-1"><a class="header" href="#1-navigation-tree-1">1. Navigation tree</a></h2> <pre><code>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 </code></pre> <h2 id="2-role-gating-from-source"><a class="header" href="#2-role-gating-from-source">2. Role gating (from source)</a></h2> <ul> <li><strong>Every user</strong> (any authenticated JWT, <code>leave.controller.ts:27</code>): create request, list own requests, balance, types list, calendar.</li> <li><strong>org_admin only</strong> (inline check <code>leave.service.ts:163</code>): see all requests (<code>:167</code>), decide (<code>leave.controller.ts:49-53</code>), create types (<code>:61-65</code>), assign substitutions (<code>:73-77</code>).</li> <li><strong>Substitute teacher</strong>: <code>GET /leave/substitutions/teacher/:id</code> (<code>leave.controller.ts:79-83</code>) — client calls with the substitute's own teacher id.</li> <li>No <code>leave.*</code> permissions exist (<code>permissions.constants.ts:1-97</code>); do <strong>not</strong> invent client-side gates stronger than the API. See gap in §5.</li> </ul> <h2 id="3-content-types"><a class="header" href="#3-content-types">3. Content types</a></h2> <div class="table-wrapper"><table><thead><tr><th>Content</th><th>Source</th><th>Owner</th></tr></thead><tbody> <tr><td>LeaveRequest</td><td><code>leave_requests</code> collection (<code>leave-request.schema.ts:14</code>)</td><td>requester + admin</td></tr> <tr><td>LeaveType</td><td><code>leave_types</code> (<code>leave-type.schema.ts:7</code>)</td><td>org admin (defaults seeded)</td></tr> <tr><td>Substitution</td><td><code>substitutions</code> (<code>substitution.schema.ts:13</code>)</td><td>org admin</td></tr> <tr><td>Balance</td><td>computed, no collection (<code>leave.service.ts:87-88</code>)</td><td>per user</td></tr> </tbody></table> </div> <h2 id="4-screen-to-data-map"><a class="header" href="#4-screen-to-data-map">4. Screen-to-data map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Data</th><th>Endpoint</th></tr></thead><tbody> <tr><td>My Requests</td><td>requests (own)</td><td><code>GET /leave/requests</code> (<code>leave.controller.ts:38-47</code>)</td></tr> <tr><td>New Request</td><td>types + post</td><td><code>GET /leave/types</code> + <code>POST /leave/requests</code> (<code>:67-71</code>, <code>:32-36</code>)</td></tr> <tr><td>Approvals Queue</td><td>requests (all)</td><td><code>GET /leave/requests?status=</code> (<code>:38-47</code>)</td></tr> <tr><td>My Balance</td><td>balance</td><td><code>GET /leave/balance/:userId</code> (<code>:55-59</code>)</td></tr> <tr><td>Calendar</td><td>approved</td><td><code>GET /leave/calendar?from&to</code> (<code>:85-91</code>)</td></tr> <tr><td>Substitutions</td><td>assigned</td><td><code>GET /leave/substitutions/teacher/:id</code> (<code>:79-83</code>)</td></tr> <tr><td>Leave Types</td><td>types</td><td><code>GET /leave/types</code> (<code>:67-71</code>)</td></tr> </tbody></table> </div> <h2 id="5-known-ia-gaps-from-source"><a class="header" href="#5-known-ia-gaps-from-source">5. Known IA gaps (from source)</a></h2> <ul> <li><strong>Cancel</strong> — status <code>cancelled</code> exists (<code>leave-request.schema.ts:11</code>) but no endpoint can produce it → no cancel action anywhere in IA.</li> <li><strong>Request detail</strong> — no <code>GET /leave/requests/:id</code>; the client must pass the whole object from the list (list is the only read surface).</li> <li><strong>RBAC</strong> — approvals UI must hide for non-admins by <strong>client</strong> role check; the API would still enforce at <code>leave.service.ts:163-167</code> (non-admin userId filter) and <code>:179-180</code> (self-decision).</li> <li><strong>Balance for other users</strong> — <code>GET /leave/balance/:userId</code> accepts any id; no server check restricts it (gap — expose only admin/own in client).</li> <li><strong>Substitutions list for absent teacher</strong> — endpoint only lists by <code>substituteTeacherId</code> (<code>leave.service.ts:275-280</code>); no "who covers me" list for the absent teacher (gap).</li> </ul> <h2 id="6-entry-points"><a class="header" href="#6-entry-points">6. Entry points</a></h2> <ul> <li>Home grid tile "Leave" (per <code>00-shared/05</code>).</li> <li>Notification tap → deep link to request detail <code>(planned)</code> — driven by <code>LeaveApproved</code>/<code>LeaveRejected</code> events (<code>events/leave-events.ts:10-18</code>).</li> <li>Calendar day tap → request detail sheet.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-leave-module"><a class="header" href="#05--screen-inventory-leave-module">05 — Screen Inventory (Leave Module)</a></h1> <ul> <li><a href="leave/05_Screen_Inventory.html#legend">Legend</a></li> <li><a href="leave/05_Screen_Inventory.html#1-leave-balance-screen-leavebalance">1. Leave Balance Screen (<code>/leave/balance</code>)</a></li> <li><a href="leave/05_Screen_Inventory.html#2-request-leave-screen-leaverequestsnew">2. Request Leave Screen (<code>/leave/requests/new</code>)</a></li> <li><a href="leave/05_Screen_Inventory.html#3-my-requests-list-leaverequests">3. My Requests List (<code>/leave/requests</code>)</a></li> <li><a href="leave/05_Screen_Inventory.html#4-request-detail-sheet-leaverequestsid">4. Request Detail Sheet (<code>/leave/requests/:id</code>)</a></li> <li><a href="leave/05_Screen_Inventory.html#5-approvals-queue-leaveapprovals--org-admin">5. Approvals Queue (<code>/leave/approvals</code>) — org admin</a></li> <li><a href="leave/05_Screen_Inventory.html#6-substitutions-list-leavesubstitutions--substitute-teacher">6. Substitutions List (<code>/leave/substitutions</code>) — substitute teacher</a></li> <li><a href="leave/05_Screen_Inventory.html#7-assign-substitution-sheet-leaveapprovalsassign--org-admin">7. Assign Substitution Sheet (<code>/leave/approvals/assign</code>) — org admin</a></li> <li><a href="leave/05_Screen_Inventory.html#8-leave-types-list--create-leavetypes--org-admin">8. Leave Types List + Create (<code>/leave/types</code>) — org admin</a></li> <li><a href="leave/05_Screen_Inventory.html#9-leave-calendar-leavecalendar">9. Leave Calendar (<code>/leave/calendar</code>)</a></li> <li><a href="leave/05_Screen_Inventory.html#10-notifications-planned">10. Notifications <code>(planned)</code></a></li> <li><a href="leave/05_Screen_Inventory.html#shared-components-used">Shared components used</a></li> <li><a href="leave/05_Screen_Inventory.html#analytics-events-proposed">Analytics events (proposed)</a></li> </ul> <blockquote> <p>Every screen of the Leave module, its intent, route, composition, states, permissions, and data source. Authoritative shared components in <a href="leave/../00-shared/03_Component_Library.html">00-shared/03</a>; this file enumerates which ones each screen uses with module specifics. Legend matches <code>design-docs/auth/05</code>.</p> </blockquote> <hr /> <h2 id="legend-12"><a class="header" href="#legend-12">Legend</a></h2> <p>States = idle / loading / success / empty / error(offline, server, conflict) / permission. Analytics follow <code>{module}.{screen}.{action}</code> <code>(proposed)</code>.</p> <hr /> <h2 id="1-leave-balance-screen-leavebalance"><a class="header" href="#1-leave-balance-screen-leavebalance">1. Leave Balance Screen (<code>/leave/balance</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Show <code>daysRemaining</code> per leave type for the current user</td></tr> <tr><td>Entry</td><td>home grid "Leave → Balance"; after a rejected request (contextual)</td></tr> <tr><td>Source</td><td><code>GET /leave/balance/:userId</code> (<code>leave.controller.ts:55-59</code>) → <code>LeaveBalanceEntry[]</code>: <code>leaveTypeId, code, name, daysPerYear, carriedForward, daysUsed, daysRemaining</code> (<code>leave.service.ts:66-74</code>)</td></tr> <tr><td>Composition</td><td><code>AppCard</code> per type: code badge, name, <code>daysRemaining</code> headline, <code>daysPerYear</code>, <code>carriedForward</code>, <code>daysUsed</code> sub-line; linear progress <code>used/entitlement</code></td></tr> <tr><td>States</td><td>loading (skeleton per card); empty (no types — server seeds defaults on first call, <code>leave.service.ts:299-306</code>, so practically unreachable); error (offline → cached copy + banner; 404 user → inline)</td></tr> <tr><td>Refresh</td><td>pull-to-refresh (<code>RefreshIndicator</code>)</td></tr> <tr><td>Analytics</td><td><code>leave.balance.open</code>, <code>leave.balance.refresh</code></td></tr> <tr><td>Permission</td><td>any authenticated user; client hides for viewing others' balance (API does not restrict — gap, <code>leave.controller.ts:55-59</code>)</td></tr> <tr><td>Notes</td><td>Balance is <strong>live-computed</strong> (<code>leave.service.ts:87-88</code>); never cache > 1 session. Weekend/holiday days count as used days (<code>:310-312</code>) — surface a caption on the card</td></tr> </tbody></table> </div> <h2 id="2-request-leave-screen-leaverequestsnew"><a class="header" href="#2-request-leave-screen-leaverequestsnew">2. Request Leave Screen (<code>/leave/requests/new</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Create a leave request</td></tr> <tr><td>Entry</td><td>My Requests FAB; Balance card action</td></tr> <tr><td>Source</td><td><code>GET /leave/types</code> (<code>leave.controller.ts:67-71</code>) for the picker; submit <code>POST /leave/requests</code> (<code>:32-36</code>)</td></tr> <tr><td>Form</td><td>leave type (required, dropdown from types), startDate (required), endDate (required), reason (optional, multiline) — contract per <code>create-leave-request.dto.ts:4-21</code>; <code>daysRequested</code> is <strong>server-computed</strong> (not in DTO)</td></tr> <tr><td>Primary CTA</td><td>"Submit request"</td></tr> <tr><td>States</td><td>loading (types + submit); validation (client: endDate ≥ startDate; mirror of <code>leave.service.ts:133-134</code>); error: 404 type (reload types), 409/offline</td></tr> <tr><td>Success</td><td>request created <code>pending</code> → toast → detail/list</td></tr> <tr><td>Analytics</td><td><code>leave.request.open</code>, <code>leave.request.submit</code>, <code>leave.request.success</code>, <code>leave.request.failure(code)</code></td></tr> <tr><td>Permission</td><td>any authenticated user (id from token, <code>leave.service.ts:138</code>)</td></tr> <tr><td>Notes</td><td>After submit show computed days + "pending" hint; warn on dates that include weekends (server counts them, <code>:310-312</code>)</td></tr> </tbody></table> </div> <h2 id="3-my-requests-list-leaverequests"><a class="header" href="#3-my-requests-list-leaverequests">3. My Requests List (<code>/leave/requests</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>List the user's own requests, newest first</td></tr> <tr><td>Source</td><td><code>GET /leave/requests</code> — non-admins are forced to own <code>userId</code> (<code>leave.service.ts:166-167</code>); sorted <code>createdAt</code> desc (<code>:168</code>)</td></tr> <tr><td>Composition</td><td><code>AppListTile</code> per request: type code badge, date range, <code>daysRequested</code>, status chip (pending/approved/rejected), decision note line when present</td></tr> <tr><td>Filters</td><td><code>status</code> client-side or server <code>?status=</code> (<code>leave.controller.ts:40</code>)</td></tr> <tr><td>States</td><td>loading / empty ("No leave requests yet" + CTA) / error / offline (cached)</td></tr> <tr><td>Refresh</td><td>pull-to-refresh</td></tr> <tr><td>Analytics</td><td><code>leave.requests.list</code>, <code>leave.requests.filter(status)</code></td></tr> <tr><td>Permission</td><td>any authenticated user</td></tr> </tbody></table> </div> <h2 id="4-request-detail-sheet-leaverequestsid"><a class="header" href="#4-request-detail-sheet-leaverequestsid">4. Request Detail Sheet (<code>/leave/requests/:id</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Read-only detail of one request (no <code>GET /:id</code> — data passed from list; gap noted in <a href="leave/04_Information_Architecture.html">04 §5</a>)</td></tr> <tr><td>Content</td><td>type, dates, days, reason, status, <code>decidedBy</code>, <code>decidedAt</code>, <code>decisionNote</code> (<code>leave-request.schema.ts:16-48</code>)</td></tr> <tr><td>Actions</td><td>none for requester (no cancel/edit endpoint); admin (if <code>approved</code>) → "Assign substitution" shortcut</td></tr> <tr><td>States</td><td>as list row, expanded</td></tr> </tbody></table> </div> <h2 id="5-approvals-queue-leaveapprovals--org-admin"><a class="header" href="#5-approvals-queue-leaveapprovals--org-admin">5. Approvals Queue (<code>/leave/approvals</code>) — org admin</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Review and decide all tenant requests</td></tr> <tr><td>Source</td><td><code>GET /leave/requests?status=pending</code> (admin sees all, <code>leave.service.ts:163-167</code>); optional <code>?userId=</code> (<code>leave.controller.ts:41-44</code>)</td></tr> <tr><td>Composition</td><td>filter chips (<code>pending</code> default, <code>approved</code>, <code>rejected</code>); list of request tiles with requester name (needs users lookup — no populate in service, gap), dates, days, reason</td></tr> <tr><td>Row action</td><td><code>AppMenu</code> → "Approve" / "Reject (add note)" → <code>PATCH /leave/requests/:id/approve</code> with <code>{action, note?}</code> (<code>leave-decision.dto.ts:9-17</code>)</td></tr> <tr><td>States</td><td>per row: deciding (button spinner); error: 409 already decided (<code>leave.service.ts:175-178</code>) → refresh list; 409 insufficient balance (<code>:188-189</code>) → inline "insufficient balance" chip; 409 self (<code>:179-180</code>) → hide own requests from queue in client</td></tr> <tr><td>Empty</td><td>"No pending requests"</td></tr> <tr><td>Analytics</td><td><code>leave.approvals.open</code>, <code>leave.approvals.decide(action)</code></td></tr> <tr><td>Permission</td><td>org admin only (client-side gate; server allows any non-self decision — gap, no RBAC guard, <code>leave.controller.ts:27</code>)</td></tr> </tbody></table> </div> <h2 id="6-substitutions-list-leavesubstitutions--substitute-teacher"><a class="header" href="#6-substitutions-list-leavesubstitutions--substitute-teacher">6. Substitutions List (<code>/leave/substitutions</code>) — substitute teacher</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Show assignments for the current teacher</td></tr> <tr><td>Source</td><td><code>GET /leave/substitutions/teacher/:id</code> (<code>leave.controller.ts:79-83</code>); sorted <code>date</code> asc (<code>leave.service.ts:275-280</code>)</td></tr> <tr><td>Composition</td><td>date-grouped <code>AppListTile</code>s: class, subject, start–end time, notes; status chip <code>assigned</code> (only reachable state from service)</td></tr> <tr><td>States</td><td>loading / empty ("No substitutions assigned") / error</td></tr> <tr><td>Refresh</td><td>pull-to-refresh</td></tr> <tr><td>Analytics</td><td><code>leave.substitutions.list</code></td></tr> </tbody></table> </div> <h2 id="7-assign-substitution-sheet-leaveapprovalsassign--org-admin"><a class="header" href="#7-assign-substitution-sheet-leaveapprovalsassign--org-admin">7. Assign Substitution Sheet (<code>/leave/approvals/assign</code>) — org admin</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Assign a substitute teacher for an approved request</td></tr> <tr><td>Source</td><td><code>POST /leave/substitutions</code> (<code>leave.controller.ts:73-77</code>); needs teacher list (teachers module), class/subject pickers</td></tr> <tr><td>Form</td><td><code>leaveRequestId</code> (from context), <code>substituteTeacherId</code>, <code>classId</code>, <code>subjectId</code>, <code>date</code>, <code>startTime</code>, <code>endTime</code>, <code>notes?</code> (<code>assign-substitution.dto.ts:4-37</code>)</td></tr> <tr><td>States</td><td>error: 409 not approved (<code>leave.service.ts:227-230</code>), 404 no teacher record (<code>:232-238</code>), 409 time clash (<code>:241-250</code>) → inline "already assigned in this slot"</td></tr> <tr><td>Permission</td><td>org admin</td></tr> </tbody></table> </div> <h2 id="8-leave-types-list--create-leavetypes--org-admin"><a class="header" href="#8-leave-types-list--create-leavetypes--org-admin">8. Leave Types List + Create (<code>/leave/types</code>) — org admin</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>View seeded/custom types; create new</td></tr> <tr><td>Source</td><td><code>GET /leave/types</code> (<code>:67-71</code>); <code>POST /leave/types</code> (<code>:61-65</code>)</td></tr> <tr><td>Form</td><td><code>code, name, daysPerYear, carryForward?, maxCarryForward?</code> (<code>create-leave-type.dto.ts:4-28</code>)</td></tr> <tr><td>States</td><td>duplicate code → MongoDB unique <code>(tenantId, code)</code> (<code>leave-type.schema.ts:29</code>) → client maps 11000 to "code already exists" (no server mapper — gap)</td></tr> <tr><td>Permission</td><td>org admin</td></tr> </tbody></table> </div> <h2 id="9-leave-calendar-leavecalendar"><a class="header" href="#9-leave-calendar-leavecalendar">9. Leave Calendar (<code>/leave/calendar</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Month grid of approved leave (tenant-wide)</td></tr> <tr><td>Source</td><td><code>GET /leave/calendar?from&to</code> (<code>leave.controller.ts:85-91</code>) — approved only, overlap query (<code>leave.service.ts:282-296</code>); default current month (<code>:283-287</code>)</td></tr> <tr><td>Composition</td><td>month pager; day cells with count/avatars of who is out; tap → detail sheet</td></tr> <tr><td>States</td><td>loading / empty / error</td></tr> <tr><td>Notes</td><td>Client should restrict <code>from/to</code> to a month window to match server default semantics</td></tr> </tbody></table> </div> <h2 id="10-notifications-planned"><a class="header" href="#10-notifications-planned">10. Notifications <code>(planned)</code></a></h2> <p>Decision pushed to requester via <code>LeaveApproved</code>/<code>LeaveRejected</code> events (<code>events/leave-events.ts:10-18</code>) → Notifications module; substitution notification to substitute + students <code>(planned, IMPLEMENTATION_PLAN.md:163)</code>.</p> <hr /> <h2 id="shared-components-used-14"><a class="header" href="#shared-components-used-14">Shared components used</a></h2> <p><code>AppTextField</code>, <code>AppButton</code>, <code>AppSnackbar</code>, <code>AppCard</code>, <code>AppListTile</code>, <code>AppBottomSheet</code>, <code>AppDialog</code>, <code>AppMenu</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppOfflineBanner</code>, <code>AppFAB</code>, <code>AppChips</code>, <code>AppBadge</code>, <code>AppAvatar</code>, <code>DatePicker</code>, <code>SegmentedButton</code>. Module-specific: <code>StatusChip</code>, <code>BalanceCard</code>, <code>LeaveTypeCard</code>, <code>SubstitutionTile</code>, <code>ApprovalActionSheet</code> — see <a href="leave/07_Component_Library.html">07_Component_Library.md</a>.</p> <h2 id="analytics-events-proposed-11"><a class="header" href="#analytics-events-proposed-11">Analytics events (proposed)</a></h2> <p><code>leave.balance.open|refresh</code>, <code>leave.request.open|submit|success|failure</code>, <code>leave.requests.list|filter</code>, <code>leave.approvals.open|decide</code>, <code>leave.substitutions.list</code>, <code>leave.calendar.open|month</code>, <code>leave.types.list|create</code>.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-leave-module"><a class="header" href="#06--screen-specifications-leave-module">06 — Screen Specifications (Leave Module)</a></h1> <ul> <li><a href="leave/06_Screen_Specifications.html#1-leave-balance-screen">1. Leave Balance Screen</a> <ul> <li><a href="leave/06_Screen_Specifications.html#11-layout-mobile-360-dp-reference">1.1 Layout (mobile, 360 dp reference)</a></li> <li><a href="leave/06_Screen_Specifications.html#12-data-contract">1.2 Data contract</a></li> <li><a href="leave/06_Screen_Specifications.html#13-states">1.3 States</a></li> <li><a href="leave/06_Screen_Specifications.html#14-interactions--motion">1.4 Interactions & motion</a></li> <li><a href="leave/06_Screen_Specifications.html#15-edge-cases">1.5 Edge cases</a></li> <li><a href="leave/06_Screen_Specifications.html#16-accessibility">1.6 Accessibility</a></li> </ul> </li> <li><a href="leave/06_Screen_Specifications.html#2-request-leave-screen">2. Request Leave Screen</a> <ul> <li><a href="leave/06_Screen_Specifications.html#21-layout">2.1 Layout</a></li> <li><a href="leave/06_Screen_Specifications.html#22-form-contract-create-leave-requestdtots4-21">2.2 Form contract (<code>create-leave-request.dto.ts:4-21</code>)</a></li> <li><a href="leave/06_Screen_Specifications.html#23-states--validation">2.3 States & validation</a></li> <li><a href="leave/06_Screen_Specifications.html#24-edge-cases">2.4 Edge cases</a></li> <li><a href="leave/06_Screen_Specifications.html#25-accessibility">2.5 Accessibility</a></li> </ul> </li> <li><a href="leave/06_Screen_Specifications.html#3-my-requests-list">3. My Requests List</a> <ul> <li><a href="leave/06_Screen_Specifications.html#31-layout">3.1 Layout</a></li> <li><a href="leave/06_Screen_Specifications.html#32-data-contract">3.2 Data contract</a></li> <li><a href="leave/06_Screen_Specifications.html#33-states--interactions">3.3 States & interactions</a></li> <li><a href="leave/06_Screen_Specifications.html#34-edge-cases">3.4 Edge cases</a></li> </ul> </li> <li><a href="leave/06_Screen_Specifications.html#4-request-detail-sheet">4. Request Detail Sheet</a></li> <li><a href="leave/06_Screen_Specifications.html#5-approvals-queue">5. Approvals Queue</a> <ul> <li><a href="leave/06_Screen_Specifications.html#51-layout">5.1 Layout</a></li> <li><a href="leave/06_Screen_Specifications.html#52-data-contract">5.2 Data contract</a></li> <li><a href="leave/06_Screen_Specifications.html#53-decision-flow">5.3 Decision flow</a></li> <li><a href="leave/06_Screen_Specifications.html#54-empty--edge">5.4 Empty & edge</a></li> </ul> </li> <li><a href="leave/06_Screen_Specifications.html#6-substitutions-list-substitute-teacher">6. Substitutions List (substitute teacher)</a></li> <li><a href="leave/06_Screen_Specifications.html#7-assign-substitution-sheet-org-admin">7. Assign Substitution Sheet (org admin)</a></li> <li><a href="leave/06_Screen_Specifications.html#8-leave-types-org-admin">8. Leave Types (org admin)</a> <ul> <li><a href="leave/06_Screen_Specifications.html#81-list">8.1 List</a></li> <li><a href="leave/06_Screen_Specifications.html#82-create-sheet">8.2 Create sheet</a></li> </ul> </li> <li><a href="leave/06_Screen_Specifications.html#9-leave-calendar">9. Leave Calendar</a></li> <li><a href="leave/06_Screen_Specifications.html#cross-screen-rules">Cross-screen rules</a></li> </ul> <blockquote> <p>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: <a href="leave/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>, <a href="leave/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>. This is the largest design doc in the package; every rule maps to source.</p> </blockquote> <hr /> <h2 id="1-leave-balance-screen"><a class="header" href="#1-leave-balance-screen">1. Leave Balance Screen</a></h2> <h3 id="11-layout-mobile-360-dp-reference"><a class="header" href="#11-layout-mobile-360-dp-reference">1.1 Layout (mobile, 360 dp reference)</a></h3> <pre><code>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." </code></pre> <h3 id="12-data-contract-1"><a class="header" href="#12-data-contract-1">1.2 Data contract</a></h3> <p><code>GET /leave/balance/:userId</code> → <code>200</code> array of <code>{ leaveTypeId, code, name, daysPerYear, carriedForward, daysUsed, daysRemaining }</code> (<code>leave.service.ts:66-74,89-124</code>). Sorted as returned (types sorted by repository default; <code>listTypes</code> uses <code>{code:1}</code> <code>leave.service.ts:216</code>).</p> <h3 id="13-states-4"><a class="header" href="#13-states-4">1.3 States</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Render</th></tr></thead><tbody> <tr><td>loading</td><td>skeleton <code>AppSkeleton</code> per expected card (default types known: CL, SL, EL, ML, PL — <code>leave.service.ts:30-64</code>)</td></tr> <tr><td>success</td><td>cards + progress bars; <code>daysRemaining</code> = <code>daysPerYear + carriedForward − daysUsed</code> (<code>leave.service.ts:121</code>)</td></tr> <tr><td>empty</td><td>should not occur (defaults seeded <code>:299-306</code>); fallback <code>AppEmptyState</code> "No leave types configured"</td></tr> <tr><td>error 404</td><td>"User not found" inline (API does not validate user — gap)</td></tr> <tr><td>offline</td><td>cached snapshot + <code>AppOfflineBanner</code>; data freshness ≤ 1 session (live-computed <code>:87-88</code>)</td></tr> <tr><td>permission</td><td>n/a server-side; client shows own only</td></tr> </tbody></table> </div> <h3 id="14-interactions--motion"><a class="header" href="#14-interactions--motion">1.4 Interactions & motion</a></h3> <ul> <li>Pull-to-refresh → refetch; <code>m-fast</code> opacity swap on refresh (per <code>00-shared/08</code>).</li> <li>Progress bar animates <code>m-base</code> on data arrival.</li> <li>Tap card → no navigation (or type detail popover <code>(proposed)</code>).</li> </ul> <h3 id="15-edge-cases"><a class="header" href="#15-edge-cases">1.5 Edge cases</a></h3> <ul> <li>Type with <code>carryForward=false</code> → hide "carried forward" line.</li> <li><code>daysRemaining</code> negative (over-use impossible by server check, <code>:188-189</code>, but back-dated requests could still make <code>daysUsed</code> exceed entitlement across months) → clamp progress bar at 0, show "0 remaining" in error color.</li> <li>Year boundary: balance uses <strong>calendar year</strong> + previous-year lookback (<code>:92-97</code>); on Jan 1 numbers reset — client must not cache across days.</li> </ul> <h3 id="16-accessibility"><a class="header" href="#16-accessibility">1.6 Accessibility</a></h3> <ul> <li>Each card: <code>Semantics(label: "Casual Leave, 8 of 12 days remaining")</code>.</li> <li>Progress bars expose <code>value</code> via semantics; color is never the only indicator (add percentage text).</li> <li>Contrast: status colors per <code>00-shared/02</code>.</li> </ul> <hr /> <h2 id="2-request-leave-screen"><a class="header" href="#2-request-leave-screen">2. Request Leave Screen</a></h2> <h3 id="21-layout-2"><a class="header" href="#21-layout-2">2.1 Layout</a></h3> <pre><code>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) </code></pre> <h3 id="22-form-contract-create-leave-requestdtots4-21"><a class="header" href="#22-form-contract-create-leave-requestdtots4-21">2.2 Form contract (<code>create-leave-request.dto.ts:4-21</code>)</a></h3> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type</th><th>Rules (source)</th></tr></thead><tbody> <tr><td><code>leaveTypeId</code></td><td>mongoId</td><td>required (<code>:6-7</code>)</td></tr> <tr><td><code>startDate</code></td><td>ISO date string</td><td>required (<code>:9-11</code>)</td></tr> <tr><td><code>endDate</code></td><td>ISO date string</td><td>required (<code>:13-15</code>); must be ≥ startDate (<code>leave.service.ts:133-134</code>, 400)</td></tr> <tr><td><code>reason</code></td><td>string</td><td>optional (<code>:17-20</code>)</td></tr> </tbody></table> </div> <p><code>userId</code> and <code>daysRequested</code> come from the server (<code>leave.service.ts:138,136</code>).</p> <h3 id="23-states--validation"><a class="header" href="#23-states--validation">2.3 States & validation</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Behavior</th></tr></thead><tbody> <tr><td>loading types</td><td>dropdown disabled with skeleton</td></tr> <tr><td>client validation</td><td>end < start → inline error (mirror 400); empty type/date → required errors; <code>daysRequested</code> preview updates live</td></tr> <tr><td>submit</td><td>button spinner; disable resubmit</td></tr> <tr><td>404</td><td>"Leave type not found" → refetch types</td></tr> <tr><td>offline</td><td>queued write with idempotency (per <code>00-shared/01 §8</code>) or block-and-inform — recommend block; balance checks need live data</td></tr> <tr><td>success</td><td>toast "Request submitted (pending)"; pop to My Requests</td></tr> </tbody></table> </div> <h3 id="24-edge-cases"><a class="header" href="#24-edge-cases">2.4 Edge cases</a></h3> <ul> <li><strong>Weekend span</strong>: show computed days + caption "includes weekends" (server counts calendar days, <code>:310-312</code>).</li> <li><strong>Overlapping own pending request</strong>: no server rule (gap — see <code>14 §2</code>); client warns "You have a pending request in this range" (best-effort scan of cached own requests).</li> <li><strong>Past start date</strong>: allowed by API; client optional warning.</li> <li><strong>Zero-day range</strong>: impossible (<code>endDate ≥ startDate</code> → ≥ 1 day).</li> <li><strong>No types</strong>: server seeds defaults on first access (<code>:299-306</code>); if list still empty (rare) show <code>AppEmptyState</code>.</li> </ul> <h3 id="25-accessibility"><a class="header" href="#25-accessibility">2.5 Accessibility</a></h3> <ul> <li>Date fields: labels + <code>Semantics</code> hint "opens date picker"; keyboard entry allowed (yyyy-mm-dd) for power users.</li> <li>Errors in live region; focus first invalid field.</li> <li>min tap target 48 dp for pickers/CTA.</li> </ul> <hr /> <h2 id="3-my-requests-list"><a class="header" href="#3-my-requests-list">3. My Requests List</a></h2> <h3 id="31-layout-1"><a class="header" href="#31-layout-1">3.1 Layout</a></h3> <pre><code>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) </code></pre> <h3 id="32-data-contract"><a class="header" href="#32-data-contract">3.2 Data contract</a></h3> <p><code>GET /leave/requests</code> (own enforced: <code>leave.service.ts:166-167</code>; sorted <code>createdAt</code> desc <code>:168</code>). Optional <code>?status=</code> (<code>leave.controller.ts:40</code>). <code>?userId=</code> is admin-only (<code>:167</code>); non-admin client never sends it.</p> <h3 id="33-states--interactions"><a class="header" href="#33-states--interactions">3.3 States & interactions</a></h3> <div class="table-wrapper"><table><thead><tr><th>Item</th><th>Spec</th></tr></thead><tbody> <tr><td>filter chips</td><td>server-side refetch per status (<code>AppChips</code> single-select); keep <code>All</code> default</td></tr> <tr><td>row</td><td>tap → detail sheet; swipe actions: none (no cancel endpoint)</td></tr> <tr><td>empty</td><td><code>AppEmptyState</code> "No leave requests yet" + CTA "Request leave"</td></tr> <tr><td>pull-to-refresh</td><td>refetch current filter</td></tr> <tr><td>offline</td><td>cached list + banner; decisions unavailable</td></tr> <tr><td>badge</td><td>chip colors: pending → <code>tertiary</code>, approved → <code>success</code>, rejected → <code>error</code> (semantic tokens, <code>00-shared/02</code>)</td></tr> </tbody></table> </div> <h3 id="34-edge-cases"><a class="header" href="#34-edge-cases">3.4 Edge cases</a></h3> <ul> <li>Request decided after row rendered → next fetch shows new status; no push needed for MVP <code>(planned)</code> notifications.</li> <li><code>reason</code> absent → omit line.</li> <li>Long reasons → 2-line ellipsis; full text in detail sheet.</li> </ul> <hr /> <h2 id="4-request-detail-sheet"><a class="header" href="#4-request-detail-sheet">4. Request Detail Sheet</a></h2> <p>Bottom sheet (or master-detail pane on tablet, <code>00-shared/05</code>).</p> <div class="table-wrapper"><table><thead><tr><th>Section</th><th>Content</th></tr></thead><tbody> <tr><td>header</td><td>type badge + status chip + <code>daysRequested</code></td></tr> <tr><td>body</td><td>dates, reason, timestamps (<code>createdAt</code>), decision block: <code>decidedBy</code>, <code>decidedAt</code>, <code>decisionNote</code> (<code>leave-request.schema.ts:41-48</code>)</td></tr> <tr><td>actions</td><td>none (requester); admin on <code>approved</code> → "Assign substitution"</td></tr> </tbody></table> </div> <p>Empty <code>decisionNote</code> → "No note provided" muted line.</p> <hr /> <h2 id="5-approvals-queue"><a class="header" href="#5-approvals-queue">5. Approvals Queue</a></h2> <h3 id="51-layout-1"><a class="header" href="#51-layout-1">5.1 Layout</a></h3> <pre><code>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] </code></pre> <h3 id="52-data-contract"><a class="header" href="#52-data-contract">5.2 Data contract</a></h3> <p><code>GET /leave/requests?status=pending</code> (+ optional <code>?userId=</code>). Admin path confirmed at <code>leave.service.ts:163-167</code> via <code>ctx.roles.includes('org_admin')</code>.</p> <h3 id="53-decision-flow"><a class="header" href="#53-decision-flow">5.3 Decision flow</a></h3> <ol> <li>Tap "Reject" → <code>AppBottomSheet</code> with note field (optional) + confirm.</li> <li><code>PATCH /leave/requests/:id/approve</code> body <code>{action: "approve"|"reject", note?}</code> (<code>leave-decision.dto.ts:9-17</code>).</li> <li>Response 200 → chip updates; remove row from <code>pending</code> filter.</li> <li>Errors: <ul> <li><strong>409 already <code>approved</code>/<code>rejected</code></strong> (<code>leave.service.ts:175-178</code>) → toast "Already decided" + refetch list.</li> <li><strong>409 insufficient balance</strong> (<code>:188-189</code>) → inline chip "Insufficient balance" on the row; row stays pending; admin may reject with note.</li> <li><strong>409 self-decision</strong> (<code>:179-180</code>) → client hides own requests in this screen (server would reject anyway).</li> </ul> </li> <li>Optimistic update: flip chip to "processing" (neutral), rollback on error — decision is money-adjacent (balance), so <strong>no</strong> silent optimistic success; wait for 200 (see <code>14 §5</code> race notes).</li> </ol> <h3 id="54-empty--edge"><a class="header" href="#54-empty--edge">5.4 Empty & edge</a></h3> <ul> <li>"No pending requests" with confetti-free calm copy.</li> <li>Reject without note allowed (<code>note?</code> optional, <code>leave-decision.dto.ts:14-17</code>).</li> <li>Requester names: API returns <code>userId</code> only — client resolves names via users module <code>(forward-looking)</code>; until then show short id.</li> </ul> <hr /> <h2 id="6-substitutions-list-substitute-teacher"><a class="header" href="#6-substitutions-list-substitute-teacher">6. Substitutions List (substitute teacher)</a></h2> <pre><code>AppBar: "My Substitutions" [Date group header] Mon 18 Aug [Physics 9A] 08:00–09:00 · for Sneha R. [assigned chip] notes: "Cover chapter 4" </code></pre> <ul> <li>Source: <code>GET /leave/substitutions/teacher/:id</code> (<code>leave.controller.ts:79-83</code>), sorted <code>date</code> asc (<code>leave.service.ts:275-280</code>); group client-side by date.</li> <li>States: loading / empty "No substitutions assigned" / error.</li> <li>Status chip: <code>assigned</code> is the only state the service ever sets (<code>leave.service.ts:261</code>); <code>completed|cancelled</code> are schema-defined but unreachable (<code>substitution.schema.ts:7-11</code>) — render only <code>assigned</code> for now.</li> <li>Refresh: pull-to-refresh; offline → cached + banner.</li> </ul> <hr /> <h2 id="7-assign-substitution-sheet-org-admin"><a class="header" href="#7-assign-substitution-sheet-org-admin">7. Assign Substitution Sheet (org admin)</a></h2> <p>Form fields (<code>assign-substitution.dto.ts:4-37</code>):</p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Control</th><th>Rules</th></tr></thead><tbody> <tr><td><code>substituteTeacherId</code></td><td>teacher picker (search)</td><td>required <code>:9-11</code></td></tr> <tr><td><code>classId</code></td><td>class picker</td><td>required <code>:13-15</code></td></tr> <tr><td><code>subjectId</code></td><td>subject picker</td><td>required <code>:17-19</code></td></tr> <tr><td><code>date</code></td><td>date picker</td><td>required <code>:21-23</code>; must fall within approved leave range (client hint; server does <strong>not</strong> check — gap)</td></tr> <tr><td><code>startTime</code>/<code>endTime</code></td><td>time pickers (<code>HH:mm</code>)</td><td>required <code>:25-31</code>; start < end (client; server clash check implies it <code>:246</code>)</td></tr> <tr><td><code>notes</code></td><td>multiline</td><td>optional <code>:33-36</code></td></tr> </tbody></table> </div> <p>Submit → <code>POST /leave/substitutions</code> → 201/200 → toast → open Substitutions.</p> <p>Errors: <strong>409 not approved</strong> (<code>leave.service.ts:227-230</code>), <strong>404 no teacher record for requester</strong> (<code>:232-238</code>), <strong>409 time clash</strong> (<code>:241-250</code>, message "Substitute teacher already assigned in this time slot."). Clash message shown inline under the time fields.</p> <hr /> <h2 id="8-leave-types-org-admin"><a class="header" href="#8-leave-types-org-admin">8. Leave Types (org admin)</a></h2> <h3 id="81-list"><a class="header" href="#81-list">8.1 List</a></h3> <p><code>GET /leave/types</code> (<code>leave.controller.ts:67-71</code>) — cards: <code>code</code> badge, <code>name</code>, <code>daysPerYear</code>, carry-forward chips (<code>carryForward</code> + <code>maxCarryForward</code>). Default types present on first access (<code>leave.service.ts:299-306</code>).</p> <h3 id="82-create-sheet"><a class="header" href="#82-create-sheet">8.2 Create sheet</a></h3> <p>Fields per <code>create-leave-type.dto.ts:4-28</code>: <code>code</code> (uppercase, 2-4 chars, hint), <code>name</code>, <code>daysPerYear</code> (<code>@Min(1)</code> <code>:14-16</code>), <code>carryForward</code> switch (<code>:18-21</code>), <code>maxCarryForward</code> (shown only when carry on, <code>:23-27</code>). Submit → <code>POST /leave/types</code>; duplicate <code>(tenantId, code)</code> → Mongo <code>E11000</code> (unique index <code>leave-type.schema.ts:29</code>) → map to "Code already exists" inline (server has no mapper — gap).</p> <hr /> <h2 id="9-leave-calendar"><a class="header" href="#9-leave-calendar">9. Leave Calendar</a></h2> <pre><code>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). </code></pre> <ul> <li>Source: <code>GET /leave/calendar?from=&to=</code> — approved only, range overlap (<code>leave.service.ts:282-296</code>); default month = server's current month (<code>:283-287</code>). Client always sends explicit <code>from/to</code> for the visible month.</li> <li>Paging: prev/next month triggers refetch.</li> <li>Colors: day with leave → primary-tinted cell; today → outlined.</li> <li>Offline: cache last month; show banner.</li> </ul> <hr /> <h2 id="cross-screen-rules"><a class="header" href="#cross-screen-rules">Cross-screen rules</a></h2> <ul> <li>All timestamps render in user <code>timezone</code> (<code>user.schema.ts:52-53</code>) with server dates parsed as UTC.</li> <li>Every list screen: skeleton → content/empty; errors never blank.</li> <li>Envelope: unwrap <code>{success, message, data, timestamp, requestId}</code> per <code>00-shared/07</code>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-leave-module"><a class="header" href="#07--component-library-leave-module">07 — Component Library (Leave Module)</a></h1> <ul> <li><a href="leave/07_Component_Library.html#1-statuschip--leave-request-status">1. <code>StatusChip</code> — leave request status</a></li> <li><a href="leave/07_Component_Library.html#2-balancecard--one-leave-type-balance-row">2. <code>BalanceCard</code> — one leave type balance row</a></li> <li><a href="leave/07_Component_Library.html#3-requesttile--list-row-for-a-request">3. <code>RequestTile</code> — list row for a request</a></li> <li><a href="leave/07_Component_Library.html#4-substitutiontile--substitution-list-row">4. <code>SubstitutionTile</code> — substitution list row</a></li> <li><a href="leave/07_Component_Library.html#5-approvalactionsheet--decision-ui">5. <code>ApprovalActionSheet</code> — decision UI</a></li> <li><a href="leave/07_Component_Library.html#6-leavetypecard--type-row-types-admin">6. <code>LeaveTypeCard</code> — type row (types admin)</a></li> <li><a href="leave/07_Component_Library.html#7-calendardaycell--monthgrid">7. <code>CalendarDayCell</code> + <code>MonthGrid</code></a></li> <li><a href="leave/07_Component_Library.html#8-composition-rules">8. Composition rules</a></li> <li><a href="leave/07_Component_Library.html#9-inventory-vs-shared">9. Inventory vs shared</a></li> </ul> <blockquote> <p>Module-specific widgets. Shared primitives (<code>AppCard</code>, <code>AppChips</code>, <code>AppBadge</code>, <code>AppListTile</code>, <code>AppButton</code>, <code>AppBottomSheet</code>, <code>AppMenu</code>, <code>AppSnackbar</code>, <code>AppEmptyState</code>, <code>AppSkeleton</code>, <code>DatePicker</code>, <code>TimePicker</code>, <code>SegmentedButton</code>) are owned by <a href="leave/../00-shared/03_Component_Library.html">00-shared/03</a>; this file only defines leave-specific compositions on top of them. Tokens: <a href="leave/../00-shared/02_Design_Tokens.html">00-shared/02</a>.</p> </blockquote> <hr /> <h2 id="1-statuschip--leave-request-status"><a class="header" href="#1-statuschip--leave-request-status">1. <code>StatusChip</code> — leave request status</a></h2> <div class="table-wrapper"><table><thead><tr><th>Prop</th><th>Value</th></tr></thead><tbody> <tr><td>Input</td><td><code>status: 'pending' | 'approved' | 'rejected' | 'cancelled'</code></td></tr> <tr><td>Render</td><td>small pill: dot + label (see 11_Design_System_Mapping for colors)</td></tr> <tr><td>Statuses</td><td><code>pending</code> (tertiary, filled dot), <code>approved</code> (success), <code>rejected</code> (error), <code>cancelled</code> (neutral/outlined — unreachable today, <code>leave-request.schema.ts:11</code>, rendered defensively)</td></tr> <tr><td>Semantics</td><td><code>label: "Status: pending"</code>; never color-only</td></tr> <tr><td>Tests</td><td>unit: renders each status; snapshot</td></tr> </tbody></table> </div> <h2 id="2-balancecard--one-leave-type-balance-row"><a class="header" href="#2-balancecard--one-leave-type-balance-row">2. <code>BalanceCard</code> — one leave type balance row</a></h2> <pre><code>[CL] Casual Leave 8 / 12 ├─ used 4 · carried 0 └─ [progress 66%] </code></pre> <div class="table-wrapper"><table><thead><tr><th>Prop</th><th>Value</th></tr></thead><tbody> <tr><td>Input</td><td><code>LeaveBalanceEntry</code> (<code>leave.service.ts:66-74</code>)</td></tr> <tr><td>Variants</td><td>carry-forward line shown only when <code>carryForward</code></td></tr> <tr><td>Behavior</td><td>progress = <code>daysUsed / daysPerYear</code> clamped 0–1; over-entitlement clamps bar at 1 with error color</td></tr> <tr><td>a11y</td><td>combined <code>Semantics</code> label "Casual Leave, 8 of 12 days remaining"</td></tr> </tbody></table> </div> <h2 id="3-requesttile--list-row-for-a-request"><a class="header" href="#3-requesttile--list-row-for-a-request">3. <code>RequestTile</code> — list row for a request</a></h2> <ul> <li>Data: <code>LeaveRequest</code> fields used — <code>leaveTypeId</code> (resolved to code via types cache), <code>startDate</code>, <code>endDate</code>, <code>daysRequested</code>, <code>reason</code>, <code>status</code>, <code>decisionNote</code> (<code>leave-request.schema.ts:16-48</code>).</li> <li>Layout: leading <code>AppAvatar</code>-style code badge, title <code><code> <range> · <days>d</code>, subtitle reason (2-line), trailing <code>StatusChip</code>.</li> <li>Slots: <code>onTap</code>, optional <code>trailingActions</code> (admin approve/reject menu).</li> <li>Variants: <code>ApprovalTile</code> (adds requester line + balance hint <code>(proposed)</code> + Approve/Reject buttons).</li> </ul> <h2 id="4-substitutiontile--substitution-list-row"><a class="header" href="#4-substitutiontile--substitution-list-row">4. <code>SubstitutionTile</code> — substitution list row</a></h2> <ul> <li>Data: <code>substitution.schema.ts:14-47</code> — <code>classId</code>, <code>subjectId</code>, <code>date</code>, <code>startTime</code>, <code>endTime</code>, <code>notes</code>, <code>status</code>.</li> <li>Layout: title <code><subject> · <class></code>, subtitle <code><date> <startTime>–<endTime></code>, trailing status chip (only <code>assigned</code> reachable, <code>leave.service.ts:261</code>).</li> <li>Resolve class/subject names via their modules' caches <code>(forward-looking)</code>.</li> </ul> <h2 id="5-approvalactionsheet--decision-ui"><a class="header" href="#5-approvalactionsheet--decision-ui">5. <code>ApprovalActionSheet</code> — decision UI</a></h2> <ul> <li>Opens from <code>ApprovalTile</code> menu or row action.</li> <li>Segmented choice <code>approve | reject</code> (<code>leave-decision.dto.ts:4-7</code>); when reject → <code>AppTextField</code> note (optional, <code>:14-17</code>).</li> <li>Confirm → <code>AppButton</code> "Confirm approve/reject" → callback; caller executes <code>PATCH /leave/requests/:id/approve</code>.</li> <li>Shows 409 error inline (already decided / insufficient balance / self-decision, <code>leave.service.ts:175-189</code>).</li> </ul> <h2 id="6-leavetypecard--type-row-types-admin"><a class="header" href="#6-leavetypecard--type-row-types-admin">6. <code>LeaveTypeCard</code> — type row (types admin)</a></h2> <ul> <li>Data: <code>leave-type.schema.ts:9-25</code> — <code>code</code>, <code>name</code>, <code>daysPerYear</code>, <code>carryForward</code>, <code>maxCarryForward</code>, <code>isDefault</code> (default types badge "Default").</li> <li>Sub-line: "Carries forward up to 60 days" only when <code>carryForward</code>.</li> </ul> <h2 id="7-calendardaycell--monthgrid"><a class="header" href="#7-calendardaycell--monthgrid">7. <code>CalendarDayCell</code> + <code>MonthGrid</code></a></h2> <ul> <li>Cell: date number, up to 3 mini avatars, <code>+N</code> overflow; states: today (outlined), leave-day (tinted), selected (filled).</li> <li>Tap → callback with date; parent shows day detail list.</li> <li>Data source is <code>GET /leave/calendar</code> payload (approved requests, <code>leave.service.ts:282-296</code>).</li> </ul> <h2 id="8-composition-rules"><a class="header" href="#8-composition-rules">8. Composition rules</a></h2> <ul> <li>Every component is stateless; state lives in Cubits (<code>13_State_Management.md</code>).</li> <li>No business logic in widgets (dates/counts computed in models/services).</li> <li>All components accept <code>theme</code> from <code>AppTheme</code> only; no hardcoded colors (tokens per <code>00-shared/02</code>).</li> <li>Placeholder resolution (type code ↔ name, teacher id ↔ name) via injected lookup services, never in-widget.</li> </ul> <h2 id="9-inventory-vs-shared"><a class="header" href="#9-inventory-vs-shared">9. Inventory vs shared</a></h2> <div class="table-wrapper"><table><thead><tr><th>Shared (<code>00-shared/03</code>)</th><th>Leave-specific (this file)</th></tr></thead><tbody> <tr><td>AppCard, AppListTile, AppChips, AppBadge, AppButton, AppMenu, AppBottomSheet, AppSnackbar, AppEmptyState, AppSkeleton, DatePicker, TimePicker, SegmentedButton, RefreshIndicator</td><td>StatusChip, BalanceCard, RequestTile/ApprovalTile, SubstitutionTile, ApprovalActionSheet, LeaveTypeCard, CalendarDayCell, MonthGrid</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-leave-module"><a class="header" href="#08--form-specifications-leave-module">08 — Form Specifications (Leave Module)</a></h1> <ul> <li><a href="leave/08_Form_Specifications.html#1-request-leave-form">1. Request Leave Form</a></li> <li><a href="leave/08_Form_Specifications.html#2-decision-form-approvalactionsheet">2. Decision Form (ApprovalActionSheet)</a></li> <li><a href="leave/08_Form_Specifications.html#3-create-leave-type-form-org-admin">3. Create Leave Type Form (org admin)</a></li> <li><a href="leave/08_Form_Specifications.html#4-assign-substitution-form-org-admin">4. Assign Substitution Form (org admin)</a></li> <li><a href="leave/08_Form_Specifications.html#5-form-system-rules-all-forms">5. Form system rules (all forms)</a></li> </ul> <blockquote> <p>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).</p> </blockquote> <hr /> <h2 id="1-request-leave-form"><a class="header" href="#1-request-leave-form">1. Request Leave Form</a></h2> <p>Contract: <code>create-leave-request.dto.ts:4-21</code>; submit <code>POST /leave/requests</code> (<code>leave.controller.ts:32-36</code>).</p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Control</th><th>Required</th><th>Client validation</th><th>Server validation (source)</th></tr></thead><tbody> <tr><td><code>leaveTypeId</code></td><td>dropdown (from <code>GET /leave/types</code>)</td><td>✓</td><td>non-empty</td><td><code>@IsMongoId()</code> <code>:6-7</code>; type must exist → 404 <code>leave.service.ts:129</code></td></tr> <tr><td><code>startDate</code></td><td>date picker (no past restriction)</td><td>✓</td><td>non-empty, valid ISO</td><td><code>@IsDateString()</code> <code>:9-11</code></td></tr> <tr><td><code>endDate</code></td><td>date picker</td><td>✓</td><td>≥ <code>startDate</code> (mirror)</td><td><code>@IsDateString()</code> <code>:13-15</code>; <code>endDate < startDate</code> → 400 <code>leave.service.ts:133-134</code></td></tr> <tr><td><code>reason</code></td><td>multiline (≤ 500 chars client)</td><td>–</td><td>–</td><td><code>@IsString()</code> optional <code>:17-20</code></td></tr> </tbody></table> </div> <p>Server adds (not client-sendable): <code>userId</code> from token (<code>leave.service.ts:138</code>), <code>daysRequested</code> computed (<code>:136</code>), <code>status: pending</code> (<code>:144</code>).</p> <p><strong>Read-only preview:</strong> days count client-side with the same inclusive formula (<code>leave.service.ts:310-312</code>) and label "includes weekends/holidays" when the range contains any.</p> <p><strong>Error mapping</strong></p> <div class="table-wrapper"><table><thead><tr><th>HTTP</th><th>Source</th><th>UI</th></tr></thead><tbody> <tr><td>400</td><td><code>endDate must be on or after startDate.</code> <code>:134</code></td><td>inline on endDate</td></tr> <tr><td>404</td><td><code>Leave type not found.</code> <code>:129</code></td><td>refetch types + retry</td></tr> <tr><td>401</td><td>JWT guard <code>leave.controller.ts:27</code></td><td>session refresh</td></tr> <tr><td>409</td><td>(no overlap rule exists today — gap)</td><td>client warning only</td></tr> </tbody></table> </div> <p><strong>Submit:</strong> disable while in-flight; success → toast + pop; failure → keep form values.</p> <hr /> <h2 id="2-decision-form-approvalactionsheet"><a class="header" href="#2-decision-form-approvalactionsheet">2. Decision Form (ApprovalActionSheet)</a></h2> <p>Contract: <code>leave-decision.dto.ts:4-17</code>; submit <code>PATCH /leave/requests/:id/approve</code> (<code>leave.controller.ts:49-53</code>).</p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Control</th><th>Required</th><th>Notes</th></tr></thead><tbody> <tr><td><code>action</code></td><td>segmented <code>approve | reject</code></td><td>✓</td><td><code>@IsEnum(LeaveDecisionAction)</code> <code>:11-12</code>; enum <code>:4-7</code></td></tr> <tr><td><code>note</code></td><td>multiline (≤ 500 client)</td><td>–</td><td><code>@IsOptional() @IsString()</code> <code>:14-17</code>; stored as <code>decisionNote</code> <code>leave.service.ts:199</code></td></tr> </tbody></table> </div> <p><strong>Server rules surfaced in form:</strong></p> <ul> <li>request must be <code>pending</code> else 409 (<code>leave.service.ts:175-178</code>) → toast "Already decided" + refetch.</li> <li>cannot decide own request → 409 (<code>:179-180</code>) — client hides own rows.</li> <li>approve with insufficient balance → 409 (<code>:188-189</code>) → block approve, offer reject-with-note.</li> </ul> <p><strong>Confirmation:</strong> reject with note → single confirm dialog ("Reject this leave request?"). Approve → immediate (fast path).</p> <hr /> <h2 id="3-create-leave-type-form-org-admin"><a class="header" href="#3-create-leave-type-form-org-admin">3. Create Leave Type Form (org admin)</a></h2> <p>Contract: <code>create-leave-type.dto.ts:4-28</code>; submit <code>POST /leave/types</code> (<code>leave.controller.ts:61-65</code>).</p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Control</th><th>Required</th><th>Server rule</th></tr></thead><tbody> <tr><td><code>code</code></td><td>text, uppercase transform</td><td>✓</td><td><code>@IsString()</code> <code>:6-7</code>; unique per tenant <code>leave-type.schema.ts:29</code></td></tr> <tr><td><code>name</code></td><td>text</td><td>✓</td><td><code>@IsString()</code> <code>:10-11</code></td></tr> <tr><td><code>daysPerYear</code></td><td>number stepper</td><td>✓</td><td><code>@IsInt() @Min(1)</code> <code>:14-16</code></td></tr> <tr><td><code>carryForward</code></td><td>switch</td><td>–</td><td><code>@IsBoolean()</code> default false <code>:18-21</code></td></tr> <tr><td><code>maxCarryForward</code></td><td>number (visible only when carry on)</td><td>–</td><td><code>@IsInt() @Min(0)</code> <code>:23-27</code></td></tr> </tbody></table> </div> <p><strong>Duplicate code:</strong> Mongo E11000 on unique index → client maps to inline "Code already exists" (no server mapper — gap).</p> <p><strong>Read-only note:</strong> <code>isDefault</code> types cannot be duplicated in practice (code uniqueness); defaults appear in list automatically (<code>leave.service.ts:299-306</code>).</p> <hr /> <h2 id="4-assign-substitution-form-org-admin"><a class="header" href="#4-assign-substitution-form-org-admin">4. Assign Substitution Form (org admin)</a></h2> <p>Contract: <code>assign-substitution.dto.ts:4-37</code>; submit <code>POST /leave/substitutions</code> (<code>leave.controller.ts:73-77</code>).</p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Control</th><th>Required</th><th>Server rule</th></tr></thead><tbody> <tr><td><code>leaveRequestId</code></td><td>hidden (from context)</td><td>✓</td><td><code>@IsMongoId()</code> <code>:6-7</code>; must exist 404 <code>:225-226</code>; must be <code>approved</code> 409 <code>:227-230</code></td></tr> <tr><td><code>substituteTeacherId</code></td><td>teacher search picker</td><td>✓</td><td><code>@IsMongoId()</code> <code>:10-11</code>; clash check <code>:241-250</code></td></tr> <tr><td><code>classId</code></td><td>class picker</td><td>✓</td><td><code>@IsMongoId()</code> <code>:14-15</code></td></tr> <tr><td><code>subjectId</code></td><td>subject picker</td><td>✓</td><td><code>@IsMongoId()</code> <code>:18-19</code></td></tr> <tr><td><code>date</code></td><td>date picker</td><td>✓</td><td><code>@IsDateString()</code> <code>:22-23</code>; should fall inside approved range (client hint only — gap)</td></tr> <tr><td><code>startTime</code></td><td>time picker <code>HH:mm</code></td><td>✓</td><td><code>@IsString()</code> <code>:26-27</code></td></tr> <tr><td><code>endTime</code></td><td>time picker <code>HH:mm</code></td><td>✓</td><td><code>@IsString()</code> <code>:29-30</code>; start < end (client; implied by <code>timeOverlaps</code> <code>:314-321</code>)</td></tr> <tr><td><code>notes</code></td><td>multiline</td><td>–</td><td><code>@IsOptional() @IsString()</code> <code>:34-36</code></td></tr> </tbody></table> </div> <p><strong>Error mapping</strong></p> <div class="table-wrapper"><table><thead><tr><th>HTTP</th><th>Source</th><th>UI</th></tr></thead><tbody> <tr><td>409</td><td><code>Leave request must be approved before assigning a substitution.</code> <code>:228-229</code></td><td>disable form; back to list</td></tr> <tr><td>404</td><td><code>No teacher record found for the leave requester.</code> <code>:236-237</code></td><td>toast, block</td></tr> <tr><td>409</td><td><code>Substitute teacher already assigned in this time slot.</code> <code>:247-248</code></td><td>inline under time fields</td></tr> </tbody></table> </div> <hr /> <h2 id="5-form-system-rules-all-forms"><a class="header" href="#5-form-system-rules-all-forms">5. Form system rules (all forms)</a></h2> <ul> <li>Validate on submit + on-field-blur; errors in live regions; focus first invalid.</li> <li>Disable submit while loading; never double-submit (idempotency on POSTs is server-side concern — clients retry only on explicit user action).</li> <li>Offline: request/decision forms <strong>block</strong> (they mutate server state); read-only screens degrade to cache.</li> <li>All text via i18n keys (<a href="leave/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>); dates localized per user locale.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-leave-module"><a class="header" href="#09--user-behaviour-leave-module">09 — User Behaviour (Leave Module)</a></h1> <ul> <li><a href="leave/09_User_Behaviour.html#1-requesting-leave">1. Requesting leave</a></li> <li><a href="leave/09_User_Behaviour.html#2-checking-balance">2. Checking balance</a></li> <li><a href="leave/09_User_Behaviour.html#3-approving-org-admin">3. Approving (org admin)</a></li> <li><a href="leave/09_User_Behaviour.html#4-substituting-substitute-teacher">4. Substituting (substitute teacher)</a></li> <li><a href="leave/09_User_Behaviour.html#5-reading-the-calendar">5. Reading the calendar</a></li> <li><a href="leave/09_User_Behaviour.html#6-behavioural-expectations-proposed">6. Behavioural expectations <code>(proposed)</code></a></li> <li><a href="leave/09_User_Behaviour.html#7-metrics-proposed">7. Metrics <code>(proposed)</code></a></li> </ul> <blockquote> <p>Observed and expected user behaviours around leave, mapped to what the API actually enforces. Behaviors are <code>(proposed)</code> product expectations unless cited to source; metrics are analytics <code>(proposed)</code>.</p> </blockquote> <hr /> <h2 id="1-requesting-leave"><a class="header" href="#1-requesting-leave">1. Requesting leave</a></h2> <ul> <li><strong>Frequency</strong>: teachers request a few times per term (sick + planned); admins configure once. High-trust, low-frequency writes.</li> <li><strong>Pre-request ritual</strong>: users check <strong>balance first</strong> (<code>GET /leave/balance</code>) because approval is balance-gated (<code>leave.service.ts:188-189</code>) — the client should surface balance inline on the request form <code>(proposed)</code> to collapse this step.</li> <li><strong>Date planning</strong>: users plan around weekends; the server counts them (<code>leave.service.ts:310-312</code>) — expect mismatch complaints; the request form shows the inclusive count up front (06 §2.4).</li> <li><strong>Overlap behavior</strong>: users may file overlapping pending requests (e.g., sick leave on top of planned leave). No server rule rejects this (gap — <code>createRequest</code> <code>leave.service.ts:126-156</code> has no overlap check); a client warning on submit is the mitigation <code>(proposed)</code>.</li> </ul> <h2 id="2-checking-balance"><a class="header" href="#2-checking-balance">2. Checking balance</a></h2> <ul> <li><strong>Cadence</strong>: before each request; after each approval (deduction is live-computed, <code>leave.service.ts:87-88</code> — always fresh).</li> <li><strong>Mental model</strong>: users think "12 days/year" and forget carry-forward; the card must visually separate <code>daysPerYear | carriedForward | daysUsed | daysRemaining</code> (<code>:66-74</code>).</li> <li><strong>Year boundary</strong>: balance resets per calendar year with prev-year carry (<code>:92-97,107-112</code>) — Jan behavior surprises users; caption it.</li> </ul> <h2 id="3-approving-org-admin"><a class="header" href="#3-approving-org-admin">3. Approving (org admin)</a></h2> <ul> <li><strong>Ritual</strong>: open queue, skim reasons, decide fast; <strong>self-approval is impossible</strong> (<code>leave.service.ts:179-180</code>) — admins delegate to a peer.</li> <li><strong>Balance surprise</strong>: approval of a request that exceeds balance 409s (<code>:188-189</code>); admin's mental model must be "reject or wait" — the UI offers reject-with-note as the fallback action.</li> <li><strong>Double-decision</strong>: second decision 409s (<code>:175-178</code>) — UI refreshes and greys the row.</li> <li><strong>Batch desire</strong>: admins want batch approve <code>(proposed)</code> — no server support (one PATCH per id, <code>leave.controller.ts:49-53</code>).</li> </ul> <h2 id="4-substituting-substitute-teacher"><a class="header" href="#4-substituting-substitute-teacher">4. Substituting (substitute teacher)</a></h2> <ul> <li><strong>Discovery</strong>: substitute checks "My Substitutions" (<code>GET /leave/substitutions/teacher/:id</code>, <code>leave.controller.ts:79-83</code>); no push today (<code>(planned)</code> notification, <code>IMPLEMENTATION_PLAN.md:163</code>).</li> <li><strong>Conflict avoidance</strong>: server rejects clashes at assign time (<code>leave.service.ts:241-250</code>); substitutes rely on admin accuracy — no accept/decline loop exists (gap).</li> <li><strong>No-show tracking</strong>: <code>completed/cancelled</code> states are schema-only (<code>substitution.schema.ts:7-11</code>); behavior tracking is <code>(planned)</code> via Timetable integration.</li> </ul> <h2 id="5-reading-the-calendar"><a class="header" href="#5-reading-the-calendar">5. Reading the calendar</a></h2> <ul> <li><strong>Use</strong>: planning coverage, checking who is out before scheduling meetings — calendar is <strong>approved-only</strong> (<code>leave.service.ts:290</code>) and tenant-wide.</li> </ul> <h2 id="6-behavioural-expectations-proposed"><a class="header" href="#6-behavioural-expectations-proposed">6. Behavioural expectations <code>(proposed)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Expectation</th><th>Design response</th></tr></thead><tbody> <tr><td>Users distrust stale balance</td><td>balance always refetched on screen open; <code>RefreshIndicator</code>; caption "live"</td></tr> <tr><td>Fast approval</td><td>Approvals queue default filter <code>pending</code>; approve = 1 tap, reject = 2 taps max</td></tr> <tr><td>Requesters check status often</td><td>status chips on list; <code>(planned)</code> push on decision event (<code>events/leave-events.ts:10-18</code>)</td></tr> <tr><td>Admins never re-decide</td><td>after decision, row leaves pending view</td></tr> <tr><td>Substitutes plan mornings</td><td>substitutions sorted by date asc (<code>leave.service.ts:275-280</code>), grouped by day</td></tr> </tbody></table> </div> <h2 id="7-metrics-proposed"><a class="header" href="#7-metrics-proposed">7. Metrics <code>(proposed)</code></a></h2> <p><code>leave.request.submit</code> → 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).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-leave-module"><a class="header" href="#10--interaction-specification-leave-module">10 — Interaction Specification (Leave Module)</a></h1> <ul> <li><a href="leave/10_Interaction_Specification.html#1-navigation--transitions">1. Navigation & transitions</a></li> <li><a href="leave/10_Interaction_Specification.html#2-list-screens-my-requests-approvals-substitutions">2. List screens (My Requests, Approvals, Substitutions)</a></li> <li><a href="leave/10_Interaction_Specification.html#3-request-form">3. Request form</a></li> <li><a href="leave/10_Interaction_Specification.html#4-decision-interaction-approvals-queue">4. Decision interaction (Approvals Queue)</a></li> <li><a href="leave/10_Interaction_Specification.html#5-assign-substitution-sheet">5. Assign substitution sheet</a></li> <li><a href="leave/10_Interaction_Specification.html#6-calendar">6. Calendar</a></li> <li><a href="leave/10_Interaction_Specification.html#7-feedback--haptics">7. Feedback & haptics</a></li> <li><a href="leave/10_Interaction_Specification.html#8-offline-matrix">8. Offline matrix</a></li> <li><a href="leave/10_Interaction_Specification.html#9-accessibility-interactions">9. Accessibility interactions</a></li> </ul> <blockquote> <p>Interaction patterns per screen: gestures, transitions, feedback, offline behavior, optimistic updates, and edge handling. Motion tokens (<code>m-fast</code>, <code>m-base</code>), haptics, and offline policy per <a href="leave/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>; QA baseline in <a href="leave/../00-shared/10_QA_Baseline.html">00-shared/10</a>.</p> </blockquote> <hr /> <h2 id="1-navigation--transitions"><a class="header" href="#1-navigation--transitions">1. Navigation & transitions</a></h2> <div class="table-wrapper"><table><thead><tr><th>Route</th><th>Transition</th></tr></thead><tbody> <tr><td>Home → Leave workspace</td><td>platform default push (per <a href="leave/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>)</td></tr> <tr><td>My Requests → New Request</td><td>push (<code>m-base</code> slide)</td></tr> <tr><td>List → Detail</td><td>bottom sheet (mobile) / right pane (tablet)</td></tr> <tr><td>Approvals → Assign Substitution</td><td>bottom sheet with form</td></tr> <tr><td>Balance ↔ Requests</td><td>horizontal tabs or back-stack; no bottom-nav duplication</td></tr> </tbody></table> </div> <h2 id="2-list-screens-my-requests-approvals-substitutions"><a class="header" href="#2-list-screens-my-requests-approvals-substitutions">2. List screens (My Requests, Approvals, Substitutions)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Gesture/event</th><th>Behavior</th></tr></thead><tbody> <tr><td>Pull-to-refresh</td><td><code>RefreshIndicator</code>; refetch active filter; <code>m-fast</code> fade-in on updated rows</td></tr> <tr><td>Tap row</td><td>open detail sheet (My Requests) / nothing extra (Approvals rows open action sheet)</td></tr> <tr><td>Filter chip tap</td><td>refetch <code>?status=</code> server-side (<code>leave.controller.ts:40</code>); keep scroll position</td></tr> <tr><td>Swipe</td><td>none (no destructive actions exist)</td></tr> <tr><td>Offline open</td><td>cached snapshot + <code>AppOfflineBanner</code>; CTA disabled for mutating actions</td></tr> <tr><td>Loading</td><td><code>AppSkeleton</code> rows (never spinners for whole list)</td></tr> <tr><td>Empty</td><td><code>AppEmptyState</code> with contextual CTA ("Request leave", "No substitutions")</td></tr> </tbody></table> </div> <h2 id="3-request-form"><a class="header" href="#3-request-form">3. Request form</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Spec</th></tr></thead><tbody> <tr><td>Type picker</td><td>dropdown; types cached in <code>LeaveTypesCubit</code> after first <code>GET /leave/types</code></td></tr> <tr><td>Date pickers</td><td><code>DatePicker</code>; select start → auto-suggest end ≥ start (clamp); days preview updates live</td></tr> <tr><td>Live days preview</td><td>recompute on each date change (inclusive count, <code>leave.service.ts:310-312</code>) with weekend hint</td></tr> <tr><td>Submit</td><td><code>AppButton</code> loading state; <strong>no optimistic creation</strong> (id is server-assigned); on 200 → toast "Submitted — pending approval", pop to list where new row appears at top (<code>createdAt desc</code>, <code>leave.service.ts:168</code>)</td></tr> <tr><td>404 type</td><td>inline + auto-refetch types</td></tr> <tr><td>Double-tap submit</td><td>guarded (in-flight flag)</td></tr> </tbody></table> </div> <h2 id="4-decision-interaction-approvals-queue"><a class="header" href="#4-decision-interaction-approvals-queue">4. Decision interaction (Approvals Queue)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Spec</th></tr></thead><tbody> <tr><td>Approve</td><td>1 tap → <code>PATCH</code> with <code>{action:"approve"}</code> → row shows neutral "processing" → on 200 flips to approved & leaves pending list; on 409 reverts + toast</td></tr> <tr><td>Reject</td><td>tap → sheet: optional note + confirm → <code>PATCH {action:"reject", note}</code></td></tr> <tr><td>Optimistic policy</td><td><strong>no</strong> optimistic status change — balance is affected (<code>leave.service.ts:183-190</code>); wait for 200, then animate chip swap (<code>m-fast</code>)</td></tr> <tr><td>409 insufficient balance</td><td>row gets inline error chip "Insufficient balance"; approve disabled for that row until refetch; reject remains available</td></tr> <tr><td>409 already decided</td><td>toast + row removed on refetch</td></tr> <tr><td>409 self-decision</td><td>row never rendered for self (client filter)</td></tr> </tbody></table> </div> <h2 id="5-assign-substitution-sheet"><a class="header" href="#5-assign-substitution-sheet">5. Assign substitution sheet</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Spec</th></tr></thead><tbody> <tr><td>Teacher picker</td><td>search-as-you-type against teachers module <code>(forward-looking)</code>; shows clash-free candidates only where known</td></tr> <tr><td>Date picker</td><td>clamped hint to approved leave range (client only — gap)</td></tr> <tr><td>Time pickers</td><td>start→end chained; end defaults start+1 h</td></tr> <tr><td>Submit</td><td>on 409 clash → inline error under time fields; on 404 → toast; on 200 → toast + switch to Substitutions list</td></tr> </tbody></table> </div> <h2 id="6-calendar"><a class="header" href="#6-calendar">6. Calendar</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Spec</th></tr></thead><tbody> <tr><td>Month pager</td><td>swipe (mobile) + chevrons; fetch <code>from/to</code> per month (<code>leave.controller.ts:85-91</code>)</td></tr> <tr><td>Day tap</td><td>bottom sheet listing approved requests that day</td></tr> <tr><td>Today</td><td>outlined cell; selected month highlighted</td></tr> <tr><td>Refresh</td><td>pull-to-refresh refetches visible month</td></tr> </tbody></table> </div> <h2 id="7-feedback--haptics"><a class="header" href="#7-feedback--haptics">7. Feedback & haptics</a></h2> <ul> <li>Success toasts: <code>AppSnackbar</code> (short); decision approve = subtle haptic (<code>HapticFeedback.selectionClick</code>); errors = <code>mediumImpact</code> + inline error (<a href="leave/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>).</li> <li>No confetti; no destructive-confirm for approve (only reject).</li> </ul> <h2 id="8-offline-matrix"><a class="header" href="#8-offline-matrix">8. Offline matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Read</th><th>Write</th></tr></thead><tbody> <tr><td>Balance</td><td>cached snapshot (≤ 1 session)</td><td>n/a</td></tr> <tr><td>My Requests</td><td>cached list; status may be stale → banner</td><td><strong>block</strong> new request (needs live type/balance truth)</td></tr> <tr><td>Approvals</td><td>cached list</td><td><strong>block</strong> decisions (balance check server-side, <code>leave.service.ts:184-190</code>)</td></tr> <tr><td>Substitutions</td><td>cached list</td><td>block</td></tr> <tr><td>Calendar</td><td>cached month</td><td>n/a</td></tr> </tbody></table> </div> <h2 id="9-accessibility-interactions-1"><a class="header" href="#9-accessibility-interactions-1">9. Accessibility interactions</a></h2> <ul> <li>Focus order: filters → list → FAB/CTA; sheets trap focus; dismiss via swipe-down + Esc (desktop) + scrim tap.</li> <li>All mutations require a visible confirmation state (no silent writes).</li> <li>Reduced motion: <code>m-fast</code> becomes instant (per <a href="leave/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-leave-module"><a class="header" href="#11--design-system-mapping-leave-module">11 — Design System Mapping (Leave Module)</a></h1> <ul> <li><a href="leave/11_Design_System_Mapping.html#1-color-semantics">1. Color semantics</a></li> <li><a href="leave/11_Design_System_Mapping.html#2-typography-from-00-shared02">2. Typography (from 00-shared/02)</a></li> <li><a href="leave/11_Design_System_Mapping.html#3-spacing--shape">3. Spacing & shape</a></li> <li><a href="leave/11_Design_System_Mapping.html#4-iconography">4. Iconography</a></li> <li><a href="leave/11_Design_System_Mapping.html#5-elevation--state-layers">5. Elevation & state layers</a></li> <li><a href="leave/11_Design_System_Mapping.html#6-dark-mode--density">6. Dark mode & density</a></li> <li><a href="leave/11_Design_System_Mapping.html#7-motion-mapping">7. Motion mapping</a></li> <li><a href="leave/11_Design_System_Mapping.html#8-component--design-system-traceability">8. Component → design-system traceability</a></li> </ul> <blockquote> <p>Maps every leave surface to the shared Material 3 token set (<a href="leave/../00-shared/02_Design_Tokens.html">00-shared/02</a> Design Tokens — <strong>proposed defaults</strong> until a designer finalizes them) and the shared component system (<a href="leave/../00-shared/03_Component_Library.html">00-shared/03</a>). No hardcoded colors; all module-specific semantic usage is enumerated here.</p> </blockquote> <hr /> <h2 id="1-color-semantics"><a class="header" href="#1-color-semantics">1. Color semantics</a></h2> <div class="table-wrapper"><table><thead><tr><th>Semantic</th><th>Token (00-shared/02)</th><th>Usage</th></tr></thead><tbody> <tr><td>Leave status <code>pending</code></td><td><code>color.tertiary</code> (+ <code>onTertiary</code>)</td><td>StatusChip dot/fill; balance "0 remaining" not used here</td></tr> <tr><td>Leave status <code>approved</code></td><td><code>color.success</code></td><td>StatusChip; calendar day tint; approval confirm button</td></tr> <tr><td>Leave status <code>rejected</code></td><td><code>color.error</code></td><td>StatusChip; rejected row accent; over-entitlement bar</td></tr> <tr><td>Leave status <code>cancelled</code></td><td><code>color.surfaceVariant</code> + <code>onSurfaceVariant</code></td><td>defensive render (unreachable, <code>leave-request.schema.ts:11</code>)</td></tr> <tr><td>Substitution <code>assigned</code></td><td><code>color.primary</code></td><td>SubstitutionTile chip (only reachable state, <code>leave.service.ts:261</code>)</td></tr> <tr><td>Calendar leave-day</td><td><code>color.primaryContainer</code> fill, <code>onPrimaryContainer</code> text</td><td>CalendarDayCell</td></tr> <tr><td>Calendar today</td><td><code>color.outline</code> border</td><td>today ring</td></tr> <tr><td>Insufficient balance</td><td><code>color.errorContainer</code> + <code>onErrorContainer</code></td><td>ApprovalTile inline chip</td></tr> <tr><td>Info caption ("includes weekends")</td><td><code>color.onSurfaceVariant</code></td><td>request form caption</td></tr> </tbody></table> </div> <h2 id="2-typography-from-00-shared02"><a class="header" href="#2-typography-from-00-shared02">2. Typography (from <a href="leave/../00-shared/02_Design_Tokens.html">00-shared/02</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Element</th><th>Style</th></tr></thead><tbody> <tr><td>Screen title (AppBar)</td><td><code>text.titleLarge</code></td></tr> <tr><td>Balance headline number</td><td><code>text.headlineSmall</code> (tabular figures <code>font-feature</code> to avoid width jump)</td></tr> <tr><td>Card title / request range</td><td><code>text.titleMedium</code></td></tr> <tr><td>Card sub-line (reason, notes, metadata)</td><td><code>text.bodyMedium</code> <code>onSurfaceVariant</code></td></tr> <tr><td>Chip label</td><td><code>text.labelMedium</code></td></tr> <tr><td>Caption ("includes weekends", "live balance")</td><td><code>text.bodySmall</code> <code>onSurfaceVariant</code></td></tr> </tbody></table> </div> <h2 id="3-spacing--shape"><a class="header" href="#3-spacing--shape">3. Spacing & shape</a></h2> <ul> <li>Card padding <code>space.m</code> (16); internal gaps <code>space.s</code> (8) / <code>space.xs</code> (4).</li> <li>Chip shape <code>shape.pill</code>; cards <code>shape.lg</code> (16) consistent with <a href="leave/../00-shared/02_Design_Tokens.html">00-shared/02</a>.</li> <li>List row height ≥ 64 dp (48 dp min target).</li> </ul> <h2 id="4-iconography-8"><a class="header" href="#4-iconography-8">4. Iconography</a></h2> <div class="table-wrapper"><table><thead><tr><th>Icon</th><th>Use</th></tr></thead><tbody> <tr><td><code>Icons.event_busy</code></td><td>request tile leading (or type-code badge text)</td></tr> <tr><td><code>Icons.balance</code> / <code>Icons.pie_chart</code></td><td>balance screen header</td></tr> <tr><td><code>Icons.check_circle_outline</code> / <code>Icons.cancel_outlined</code></td><td>approve / reject actions</td></tr> <tr><td><code>Icons.swap_horiz</code></td><td>substitutions</td></tr> <tr><td><code>Icons.calendar_month</code></td><td>calendar</td></tr> </tbody></table> </div> <h2 id="5-elevation--state-layers"><a class="header" href="#5-elevation--state-layers">5. Elevation & state layers</a></h2> <ul> <li>Cards: <code>elevation.level1</code> resting, <code>level2</code> hover (desktop), pressed overlay <code>stateLayer.pressed</code> per M3 (<a href="leave/../00-shared/02_Design_Tokens.html">00-shared/02</a>).</li> <li>Bottom sheets: <code>elevation.level3</code>; scrim <code>scrim@40%</code>.</li> </ul> <h2 id="6-dark-mode--density-1"><a class="header" href="#6-dark-mode--density-1">6. Dark mode & density</a></h2> <ul> <li>All tokens are theme-aware (<a href="leave/../00-shared/02_Design_Tokens.html">00-shared/02</a>); status colors use light/dark variants automatically.</li> <li>Density: list rows <code>density.comfortable</code> on mobile, <code>density.compact</code> on desktop tablets for the approvals queue (per <a href="leave/../00-shared/02_Design_Tokens.html">00-shared/02</a>).</li> </ul> <h2 id="7-motion-mapping"><a class="header" href="#7-motion-mapping">7. Motion mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Motion token</th><th>Where</th></tr></thead><tbody> <tr><td><code>m-fast</code> (120 ms)</td><td>chip state swap after decision; pull-to-refresh fade</td></tr> <tr><td><code>m-base</code> (200 ms)</td><td>screen push; sheet open/close; progress bar fill</td></tr> <tr><td><code>m-slow</code> (300 ms)</td><td>calendar month cross-fade on pager</td></tr> </tbody></table> </div> <h2 id="8-component--design-system-traceability"><a class="header" href="#8-component--design-system-traceability">8. Component → design-system traceability</a></h2> <div class="table-wrapper"><table><thead><tr><th>Leave component (07)</th><th>Shared primitives</th><th>Tokens</th></tr></thead><tbody> <tr><td>StatusChip</td><td>AppChips (variant)</td><td>color.success/error/tertiary, shape.pill, text.labelMedium</td></tr> <tr><td>BalanceCard</td><td>AppCard + LinearProgress</td><td>color.primary, text.headlineSmall</td></tr> <tr><td>RequestTile / ApprovalTile</td><td>AppListTile + AppBadge + AppMenu</td><td>spacing per 03, state layers</td></tr> <tr><td>SubstitutionTile</td><td>AppListTile + AppChips</td><td>color.primary chip</td></tr> <tr><td>ApprovalActionSheet</td><td>AppBottomSheet + SegmentedButton + AppTextField + AppButton</td><td>elevation.level3</td></tr> <tr><td>LeaveTypeCard</td><td>AppCard + AppBadge</td><td>text.titleMedium</td></tr> <tr><td>CalendarDayCell / MonthGrid</td><td>(module-specific)</td><td>color.primaryContainer, outline, shape.md</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-leave-module"><a class="header" href="#12--api-mapping-leave-module">12 — API Mapping (Leave Module)</a></h1> <ul> <li><a href="leave/12_API_Mapping.html#1-endpoint-table">1. Endpoint table</a></li> <li><a href="leave/12_API_Mapping.html#2-request-bodies">2. Request bodies</a></li> <li><a href="leave/12_API_Mapping.html#3-response-shapes">3. Response shapes</a></li> <li><a href="leave/12_API_Mapping.html#4-error-contract-all-4xx5xx-in-standard-envelope-00-shared07">4. Error contract (all 4xx/5xx in standard envelope, 00-shared/07)</a></li> <li><a href="leave/12_API_Mapping.html#5-events-emitted-side-effects">5. Events emitted (side effects)</a></li> <li><a href="leave/12_API_Mapping.html#6-query-semantics">6. Query semantics</a></li> <li><a href="leave/12_API_Mapping.html#7-gaps-no-endpoint-today">7. Gaps (no endpoint today)</a></li> </ul> <blockquote> <p>Exact endpoint contract for the Leave module. Source: <code>leave.controller.ts</code> (routes), <code>leave.service.ts</code> (semantics), DTOs (bodies), schemas (fields), <code>docs/IMPLEMENTATION_PLAN.md</code> (<code>(planned)</code>). Envelope and error conventions: <a href="leave/../00-shared/07_API_Conventions.html">00-shared/07</a>. All endpoints are under <code>@Controller('leave')</code> with <code>JwtAuthGuard</code> + <code>ApiBearerAuth</code> (<code>leave.controller.ts:25-28</code>); URI prefix <code>/api/v1</code> per <code>main.ts</code> versioning.</p> </blockquote> <hr /> <h2 id="1-endpoint-table-2"><a class="header" href="#1-endpoint-table-2">1. Endpoint table</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Method</th><th>Path</th><th>Summary</th><th>Source</th><th>Access</th></tr></thead><tbody> <tr><td>1</td><td>POST</td><td><code>/api/v1/leave/requests</code></td><td>Create leave request</td><td><code>leave.controller.ts:32-36</code></td><td>any authenticated user</td></tr> <tr><td>2</td><td>GET</td><td><code>/api/v1/leave/requests</code></td><td>List requests (<code>status</code>, <code>userId</code> filters)</td><td><code>leave.controller.ts:38-47</code></td><td>own only; all for <code>org_admin</code> (<code>leave.service.ts:163-167</code>)</td></tr> <tr><td>3</td><td>PATCH</td><td><code>/api/v1/leave/requests/:id/approve</code></td><td>Approve / reject</td><td><code>leave.controller.ts:49-53</code></td><td>any (server blocks self-decision <code>:179-180</code>); client: admin</td></tr> <tr><td>4</td><td>GET</td><td><code>/api/v1/leave/balance/:userId</code></td><td>Live balance for user</td><td><code>leave.controller.ts:55-59</code></td><td>any (no server restriction — gap)</td></tr> <tr><td>5</td><td>POST</td><td><code>/api/v1/leave/types</code></td><td>Create leave type</td><td><code>leave.controller.ts:61-65</code></td><td>org admin (client)</td></tr> <tr><td>6</td><td>GET</td><td><code>/api/v1/leave/types</code></td><td>List leave types</td><td><code>leave.controller.ts:67-71</code></td><td>any</td></tr> <tr><td>7</td><td>POST</td><td><code>/api/v1/leave/substitutions</code></td><td>Assign substitute</td><td><code>leave.controller.ts:73-77</code></td><td>org admin (client)</td></tr> <tr><td>8</td><td>GET</td><td><code>/api/v1/leave/substitutions/teacher/:id</code></td><td>Substitutions for teacher</td><td><code>leave.controller.ts:79-83</code></td><td>substitute teacher</td></tr> <tr><td>9</td><td>GET</td><td><code>/api/v1/leave/calendar</code></td><td>Approved leave in range</td><td><code>leave.controller.ts:85-91</code></td><td>any</td></tr> </tbody></table> </div> <p><code>(planned)</code> — <code>IMPLEMENTATION_PLAN.md:153-161</code> lists the same nine endpoints; no additional leave endpoints are planned in that doc.</p> <h2 id="2-request-bodies"><a class="header" href="#2-request-bodies">2. Request bodies</a></h2> <div class="table-wrapper"><table><thead><tr><th>Endpoint</th><th>DTO</th><th>Fields</th></tr></thead><tbody> <tr><td>1</td><td><code>CreateLeaveRequestDto</code> (<code>create-leave-request.dto.ts:4-21</code>)</td><td><code>leaveTypeId</code> (mongoId, req), <code>startDate</code> (ISO date, req), <code>endDate</code> (ISO date, req), <code>reason?</code></td></tr> <tr><td>3</td><td><code>LeaveDecisionDto</code> (<code>leave-decision.dto.ts:9-17</code>)</td><td><code>action</code> (<code>approve|reject</code>, enum <code>:4-7</code>, req), <code>note?</code></td></tr> <tr><td>5</td><td><code>CreateLeaveTypeDto</code> (<code>create-leave-type.dto.ts:4-28</code>)</td><td><code>code</code> (str, req), <code>name</code> (str, req), <code>daysPerYear</code> (int ≥ 1, req), <code>carryForward?</code> (bool, default false), <code>maxCarryForward?</code> (int ≥ 0)</td></tr> <tr><td>7</td><td><code>AssignSubstitutionDto</code> (<code>assign-substitution.dto.ts:4-37</code>)</td><td><code>leaveRequestId</code>, <code>substituteTeacherId</code>, <code>classId</code>, <code>subjectId</code> (mongoIds, req), <code>date</code> (ISO, req), <code>startTime</code>, <code>endTime</code> (str <code>HH:mm</code>, req), <code>notes?</code></td></tr> </tbody></table> </div> <h2 id="3-response-shapes"><a class="header" href="#3-response-shapes">3. Response shapes</a></h2> <ul> <li><strong>2/3</strong> → created/updated <code>LeaveRequest</code> document: <code>_id, tenantId, userId, leaveTypeId, startDate, endDate, daysRequested, reason?, status, decidedBy?, decidedAt?, decisionNote?, createdAt, updatedAt, version</code> (schema <code>leave-request.schema.ts:16-48</code> + <code>BaseSchema</code> audit fields).</li> <li><strong>2 (list)</strong> → <code>LeaveRequest[]</code> sorted <code>createdAt</code> desc (<code>leave.service.ts:168</code>).</li> <li><strong>4</strong> → <code>LeaveBalanceEntry[]</code>: <code>{leaveTypeId, code, name, daysPerYear, carriedForward, daysUsed, daysRemaining}</code> (<code>leave.service.ts:66-74</code>).</li> <li><strong>6</strong> → <code>LeaveType[]</code> sorted <code>code</code> asc (<code>leave.service.ts:216</code>).</li> <li><strong>8</strong> → <code>Substitution[]</code> sorted <code>date</code> asc (<code>leave.service.ts:275-280</code>).</li> <li><strong>9</strong> → <code>LeaveRequest[]</code> (approved only) sorted <code>startDate</code> asc (<code>leave.service.ts:288-295</code>); default range = current month (<code>:283-287</code>).</li> </ul> <h2 id="4-error-contract-all-4xx5xx-in-standard-envelope-00-shared07"><a class="header" href="#4-error-contract-all-4xx5xx-in-standard-envelope-00-shared07">4. Error contract (all 4xx/5xx in standard envelope, <a href="leave/../00-shared/07_API_Conventions.html">00-shared/07</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>HTTP</th><th>Trigger</th><th>Message</th><th>Source</th></tr></thead><tbody> <tr><td>400</td><td><code>endDate < startDate</code></td><td><code>endDate must be on or after startDate.</code></td><td><code>leave.service.ts:133-134</code></td></tr> <tr><td>401</td><td>missing/invalid JWT</td><td>—</td><td><code>leave.controller.ts:27</code></td></tr> <tr><td>404</td><td>unknown leave type</td><td><code>Leave type not found.</code></td><td><code>leave.service.ts:129</code></td></tr> <tr><td>404</td><td>unknown request</td><td><code>Leave request not found.</code></td><td><code>:174</code></td></tr> <tr><td>404</td><td>requester has no teacher record</td><td><code>No teacher record found for the leave requester.</code></td><td><code>:236-237</code></td></tr> <tr><td>409</td><td>request not <code>pending</code></td><td><code>Leave request is already <status>.</code></td><td><code>:175-178</code></td></tr> <tr><td>409</td><td>self-decision</td><td><code>You cannot decide your own leave request.</code></td><td><code>:179-180</code></td></tr> <tr><td>409</td><td>approve when balance insufficient</td><td><code>Insufficient leave balance.</code></td><td><code>:188-189</code></td></tr> <tr><td>409</td><td>substitution on non-approved request</td><td><code>Leave request must be approved before assigning a substitution.</code></td><td><code>:227-230</code></td></tr> <tr><td>409</td><td>substitute slot clash</td><td><code>Substitute teacher already assigned in this time slot.</code></td><td><code>:247-248</code></td></tr> <tr><td>11000*</td><td>duplicate leave-type <code>code</code> (Mongo unique <code>(tenantId, code)</code>)</td><td>not mapped server-side — gap</td><td><code>leave-type.schema.ts:29</code></td></tr> </tbody></table> </div> <p>*Surfaces as a generic 500-class error today; client maps duplicate <code>code</code> from the raw key error until server adds a mapper.</p> <h2 id="5-events-emitted-side-effects"><a class="header" href="#5-events-emitted-side-effects">5. Events emitted (side effects)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Payload (<code>events/leave-events.ts</code>)</th><th>Emitted at</th></tr></thead><tbody> <tr><td><code>LeaveRequested</code></td><td><code>leaveRequestId, userId, leaveTypeCode, startDate, endDate, daysRequested</code> (<code>:1-8</code>)</td><td><code>leave.service.ts:154</code></td></tr> <tr><td><code>LeaveApproved</code> / <code>LeaveRejected</code></td><td><code>leaveRequestId, userId, leaveTypeCode, startDate, endDate, action, note?</code> (<code>:10-18</code>)</td><td><code>:210</code></td></tr> <tr><td><code>SubstitutionAssigned</code></td><td><code>substitutionId, absentTeacherId, substituteTeacherId, classId, date</code> (<code>:20-26</code>)</td><td><code>:271</code></td></tr> </tbody></table> </div> <p>Consumers: Notifications <code>(planned, IMPLEMENTATION_PLAN.md:163)</code>; audit.</p> <h2 id="6-query-semantics"><a class="header" href="#6-query-semantics">6. Query semantics</a></h2> <ul> <li><strong>List (2)</strong>: <code>status</code> ∈ enum (<code>pending\|approved\|rejected\|cancelled</code>, <code>leave-request.schema.ts:7-12</code>); <code>userId</code> only effective for admins (<code>leave.service.ts:167</code>). Non-admin always receives own rows only.</li> <li><strong>Calendar (9)</strong>: <code>from</code>/<code>to</code> ISO dates; overlap query <code>startDate ≤ to AND endDate ≥ from</code> (<code>:291-292</code>).</li> </ul> <h2 id="7-gaps-no-endpoint-today"><a class="header" href="#7-gaps-no-endpoint-today">7. Gaps (no endpoint today)</a></h2> <p>Cancel request (<code>cancelled</code> status unreachable) · edit/withdraw pending request · request detail <code>GET /:id</code> · leave-type update/delete · substitution status transitions (<code>completed/cancelled</code>) · balance for "self" convenience (requires own <code>userId</code>) · <code>leave.*</code> RBAC permissions (<code>permissions.constants.ts:1-97</code> has none).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-leave-module"><a class="header" href="#13--state-management-leave-module">13 — State Management (Leave Module)</a></h1> <ul> <li><a href="leave/13_State_Management.html#1-cubit-map">1. Cubit map</a></li> <li><a href="leave/13_State_Management.html#2-state-shapes">2. State shapes</a></li> <li><a href="leave/13_State_Management.html#3-data-lifecycle-rules">3. Data lifecycle rules</a></li> <li><a href="leave/13_State_Management.html#4-status-state-machine-authoritative">4. Status state machine (authoritative)</a></li> <li><a href="leave/13_State_Management.html#5-cross-cubit-coordination">5. Cross-cubit coordination</a></li> <li><a href="leave/13_State_Management.html#6-error-handling">6. Error handling</a></li> </ul> <blockquote> <p>Cubit architecture for the leave feature, its state shapes, data lifecycle, and the authoritative status state machine. Client architecture (Bloc/Cubit) is a <strong>recommendation</strong> (<a href="leave/../00-shared/01_Product_Overview.html">00-shared/01 §9</a>; shared patterns <a href="leave/../00-shared/06_State_Management.html">00-shared/06</a>).</p> </blockquote> <hr /> <h2 id="1-cubit-map-3"><a class="header" href="#1-cubit-map-3">1. Cubit map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Cubit</th><th>Owns</th><th>Backing endpoint(s)</th></tr></thead><tbody> <tr><td><code>LeaveBalanceCubit</code></td><td>balance entries for current user</td><td><code>GET /leave/balance/:userId</code> (<code>leave.controller.ts:55-59</code>)</td></tr> <tr><td><code>LeaveTypesCubit</code></td><td>leave type catalog (incl. defaults)</td><td><code>GET /leave/types</code> (<code>:67-71</code>)</td></tr> <tr><td><code>LeaveRequestsCubit</code></td><td>current user's requests + filter</td><td><code>GET /leave/requests</code> (<code>:38-47</code>)</td></tr> <tr><td><code>LeaveRequestFormCubit</code></td><td>draft request + validation + submit</td><td><code>POST /leave/requests</code> (<code>:32-36</code>)</td></tr> <tr><td><code>ApprovalsCubit</code></td><td>admin queue + filters + decision</td><td><code>GET /leave/requests?status=</code> + <code>PATCH /leave/requests/:id/approve</code> (<code>:38-53</code>)</td></tr> <tr><td><code>SubstitutionsCubit</code></td><td>substitute's assignments</td><td><code>GET /leave/substitutions/teacher/:id</code> (<code>:79-83</code>)</td></tr> <tr><td><code>SubstitutionFormCubit</code></td><td>assign-substitution draft + submit</td><td><code>POST /leave/substitutions</code> (<code>:73-77</code>)</td></tr> <tr><td><code>LeaveCalendarCubit</code></td><td>approved month payload</td><td><code>GET /leave/calendar?from&to</code> (<code>:85-91</code>)</td></tr> <tr><td><code>LeaveTypesAdminCubit</code></td><td>create type</td><td><code>POST /leave/types</code> (<code>:61-65</code>)</td></tr> </tbody></table> </div> <p>Shared session/user id comes from <code>SessionCubit</code> (<a href="leave/../00-shared/06_State_Management.html">00-shared/06</a>).</p> <h2 id="2-state-shapes"><a class="header" href="#2-state-shapes">2. State shapes</a></h2> <pre><code class="language-dart">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(...) </code></pre> <p>Models mirror the wire contract exactly (<code>12_API_Mapping §3</code>) with <code>fromJson</code>; no derived fields stored — days preview is computed in the form cubit (inclusive count, <code>leave.service.ts:310-312</code>).</p> <h2 id="3-data-lifecycle-rules"><a class="header" href="#3-data-lifecycle-rules">3. Data lifecycle rules</a></h2> <ul> <li><strong>Balance is never long-cached</strong>: live-computed server-side (<code>leave.service.ts:87-88</code>); refetch on screen open + pull-to-refresh; discard on logout; year rollover needs no client logic (server computes per calendar year, <code>:92-97</code>).</li> <li><strong>Types</strong>: fetch once per session (defaults seeded server-side, <code>:299-306</code>); refresh after type creation.</li> <li><strong>Requests/Approvals</strong>: refetch on filter change and after decisions (decision 409 "already decided" → refetch, <code>:175-178</code>).</li> <li><strong>Calendar</strong>: refetch per visible month; keep last month for offline.</li> <li>All writes are <strong>non-optimistic</strong> for decisions (balance impact, <code>:183-190</code>) and creation (server-assigned <code>_id</code>); only UI-neutral loading state changes locally.</li> </ul> <h2 id="4-status-state-machine-authoritative"><a class="header" href="#4-status-state-machine-authoritative">4. Status state machine (authoritative)</a></h2> <pre class="mermaid">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) </pre> <p>Notes:</p> <ul> <li>Source of transitions: <code>leave.service.ts:171-212</code> (<code>decide</code> sets <code>approved</code>/<code>rejected</code> only from <code>pending</code>; <code>createRequest</code> sets <code>pending</code>, <code>:144</code>).</li> <li><code>cancelled</code> exists in the enum (<code>leave-request.schema.ts:11</code>) but no code path produces it — client renders it defensively only.</li> <li>Substitution mini-machine (<code>substitution.schema.ts:7-11</code>): <code>assigned</code> (set at creation, <code>leave.service.ts:261</code>) → <code>completed</code>/ <code>cancelled</code> — <strong>no service transitions exist</strong>; states unreachable today.</li> </ul> <h2 id="5-cross-cubit-coordination"><a class="header" href="#5-cross-cubit-coordination">5. Cross-cubit coordination</a></h2> <ul> <li><code>LeaveRequestsCubit</code> and <code>ApprovalsCubit</code> both listen to <code>LeaveRequested</code>/<code>LeaveApproved</code>/<code>LeaveRejected</code> events (via socket or refetch-on-navigation <code>(forward-looking)</code> — no push today).</li> <li>After <code>ApprovalsCubit.decide</code> success → invalidate <code>BalanceState</code> for that user (deduction, <code>:184-190</code>).</li> <li>After <code>LeaveRequestFormCubit.submit</code> success → prepend row in <code>LeaveRequestsCubit</code> (list is <code>createdAt</code> desc, <code>:168</code>).</li> </ul> <h2 id="6-error-handling"><a class="header" href="#6-error-handling">6. Error handling</a></h2> <p>Map HTTP → domain failure: <code>400</code> validation, <code>404</code> not found (refetch types), <code>409</code> conflict (surface message verbatim — they are user-actionable: <code>leave.service.ts:176-189,227-230,247-248</code>), <code>401</code> session refresh, <code>11000</code> duplicate code. Single <code>ApiFailure</code> type from <code>00-shared/06</code>.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-leave-module"><a class="header" href="#14--qa-checklist-leave-module">14 — QA Checklist (Leave Module)</a></h1> <ul> <li><a href="leave/14_QA_Checklist.html#1-request-creation-p0">1. Request creation (P0)</a></li> <li><a href="leave/14_QA_Checklist.html#2-overlap-validation-p1--known-gap">2. Overlap validation (P1 — known gap)</a></li> <li><a href="leave/14_QA_Checklist.html#3-day-count-math-p1">3. Day-count math (P1)</a></li> <li><a href="leave/14_QA_Checklist.html#4-balance-p1">4. Balance (P1)</a></li> <li><a href="leave/14_QA_Checklist.html#5-approval-p0">5. Approval (P0)</a></li> <li><a href="leave/14_QA_Checklist.html#6-list--filters-p1">6. List & filters (P1)</a></li> <li><a href="leave/14_QA_Checklist.html#7-substitutions-p1">7. Substitutions (P1)</a></li> <li><a href="leave/14_QA_Checklist.html#8-calendar-p2">8. Calendar (P2)</a></li> <li><a href="leave/14_QA_Checklist.html#9-cross-cutting">9. Cross-cutting</a></li> <li><a href="leave/14_QA_Checklist.html#10-known-gaps-to-track-regression-lock-as-current-behavior">10. Known gaps to track (regression-lock as current behavior)</a></li> </ul> <blockquote> <p>Test checklist anchored to the exact server semantics. Base QA baseline: <a href="leave/../00-shared/10_QA_Baseline.html">00-shared/10</a>. Priority: P0 = blocks release, P1 = high, P2 = low. Known gaps are listed as explicit "known behavior" rows, not failures.</p> </blockquote> <hr /> <h2 id="1-request-creation-p0"><a class="header" href="#1-request-creation-p0">1. Request creation (P0)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Case</th><th>Expected (source)</th></tr></thead><tbody> <tr><td>1.1</td><td>Happy path</td><td>200, status <code>pending</code>, <code>daysRequested</code> = inclusive day count, <code>userId</code> = token user (<code>leave.service.ts:136-144</code>)</td></tr> <tr><td>1.2</td><td><code>endDate < startDate</code></td><td>400 <code>endDate must be on or after startDate.</code> (<code>:133-134</code>)</td></tr> <tr><td>1.3</td><td>Unknown <code>leaveTypeId</code></td><td>404 <code>Leave type not found.</code> (<code>:129</code>)</td></tr> <tr><td>1.4</td><td>Same-day request (start = end)</td><td>200, <code>daysRequested = 1</code> (inclusive formula <code>:310-312</code>)</td></tr> <tr><td>1.5</td><td><code>leaveTypeId</code> spoof in body</td><td>ignored — <code>userId</code> always from token (<code>:138</code>); verify no cross-user creation</td></tr> <tr><td>1.6</td><td>Missing/blank reason</td><td>200 (optional, <code>create-leave-request.dto.ts:17-20</code>)</td></tr> <tr><td>1.7</td><td>No auth</td><td>401 (<code>leave.controller.ts:27</code>)</td></tr> <tr><td>1.8</td><td><code>daysRequested</code> sent in body</td><td>rejected by DTO whitelist (not a DTO field, <code>create-leave-request.dto.ts:4-21</code>)</td></tr> </tbody></table> </div> <h2 id="2-overlap-validation-p1--known-gap"><a class="header" href="#2-overlap-validation-p1--known-gap">2. Overlap validation (P1 — known gap)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Case</th><th>Expected (source)</th></tr></thead><tbody> <tr><td>2.1</td><td>Same user, overlapping ranges, both pending</td><td><strong>Both accepted</strong> — no overlap check in <code>createRequest</code> (<code>leave.service.ts:126-156</code>). Documented gap; client warns (06 §2.4). Test to lock current behavior + track gap</td></tr> <tr><td>2.2</td><td>Same user, overlapping ranges, one approved</td><td>Same — allowed; balance math handles both (<code>:99-103</code>)</td></tr> <tr><td>2.3</td><td>Cross-user overlap</td><td>Allowed by design (calendar is advisory)</td></tr> </tbody></table> </div> <h2 id="3-day-count-math-p1"><a class="header" href="#3-day-count-math-p1">3. Day-count math (P1)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Case</th><th>Expected (source)</th></tr></thead><tbody> <tr><td>3.1</td><td>Mon→Fri</td><td><code>daysRequested = 5</code> (calendar days)</td></tr> <tr><td>3.2</td><td>Fri→Mon (weekend inside)</td><td><code>= 4</code> — weekends <strong>count</strong> (<code>:310-312</code>, ponytail note: working-day count only if policy changes)</td></tr> <tr><td>3.3</td><td>Holiday inside range</td><td>counts as a day (no holiday calendar)</td></tr> <tr><td>3.4</td><td>Dec 31 → Jan 2 (year span)</td><td>3 days; balance attribution per <code>startDate</code> year (<code>:101-103</code>)</td></tr> <tr><td>3.5</td><td>DST boundary</td><td><code>Math.floor((end-start)/86400000)+1</code> (<code>:311</code>) — verify with DST-shifted timezones (tz stored on user, <code>user.schema.ts:52-53</code>)</td></tr> </tbody></table> </div> <h2 id="4-balance-p1"><a class="header" href="#4-balance-p1">4. Balance (P1)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Case</th><th>Expected (source)</th></tr></thead><tbody> <tr><td>4.1</td><td>Formula</td><td><code>daysRemaining = daysPerYear + carriedForward − daysUsed</code> (<code>leave.service.ts:121</code>)</td></tr> <tr><td>4.2</td><td>Carry cap</td><td><code>min(maxCarryForward, max(0, daysPerYear − usedPrevYear))</code> (<code>:107-112</code>)</td></tr> <tr><td>4.3</td><td>No carry (<code>CL</code>, <code>ML</code>, <code>PL</code>)</td><td><code>carriedForward = 0</code> always</td></tr> <tr><td>4.4</td><td>SL/EL carry cap</td><td>SL max 30, EL max 60 (<code>:37-51</code>)</td></tr> <tr><td>4.5</td><td>Balance reflects approval only</td><td>pending/rejected excluded (query filters <code>APPROVED</code>, <code>:94-98</code>)</td></tr> <tr><td>4.6</td><td>Deduction after approval</td><td>refetch balance post-approve shows reduced <code>daysRemaining</code></td></tr> <tr><td>4.7</td><td>Defaults seeding</td><td>first call seeds 5 defaults idempotently (<code>:299-306</code>); double-fire concurrent calls → single set (<code>$setOnInsert</code> upsert, <code>leave-type.repository.ts:17-31</code>)</td></tr> </tbody></table> </div> <h2 id="5-approval-p0"><a class="header" href="#5-approval-p0">5. Approval (P0)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Case</th><th>Expected (source)</th></tr></thead><tbody> <tr><td>5.1</td><td>Approve pending</td><td>200 → <code>status=approved</code>, <code>decidedBy</code>, <code>decidedAt</code>, <code>decisionNote</code> set (<code>:195-200</code>)</td></tr> <tr><td>5.2</td><td>Reject pending</td><td>200 → <code>status=rejected</code>; note optional (<code>leave-decision.dto.ts:14-17</code>)</td></tr> <tr><td>5.3</td><td>Decide already-decided</td><td>409 <code>Leave request is already <status>.</code> (<code>:175-178</code>)</td></tr> <tr><td>5.4</td><td>Decide own request</td><td>409 <code>You cannot decide your own leave request.</code> (<code>:179-180</code>)</td></tr> <tr><td>5.5</td><td>Approve over balance</td><td>409 <code>Insufficient leave balance.</code> (<code>:188-189</code>); reject still allowed</td></tr> <tr><td>5.6</td><td><strong>Approval race</strong> — two admins, same request, near-simultaneous PATCH</td><td>Both read <code>pending</code>; second write wins; at least one caller gets 409 (no atomic conditional update — verify behavior: second <code>updateById</code> overwrites; test + raise as gap <code>(planned)</code> optimistic-lock awareness, <code>base.schema.ts</code> <code>version</code>)</td></tr> <tr><td>5.7</td><td>Balance check race — two approvals crossing a shared balance</td><td>Both pass check, both approve → <code>daysUsed</code> exceeds entitlement. Live-computed, no lock — document as known limitation; mitigation <code>(forward-looking)</code></td></tr> <tr><td>5.8</td><td>Non-admin decision</td><td>Server permits (only JWT guard, <code>leave.controller.ts:27</code>) — <strong>client gates admin; server gap noted in 12 §7</strong></td></tr> </tbody></table> </div> <h2 id="6-list--filters-p1"><a class="header" href="#6-list--filters-p1">6. List & filters (P1)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Case</th><th>Expected (source)</th></tr></thead><tbody> <tr><td>6.1</td><td>Non-admin list</td><td>Only own rows (<code>leave.service.ts:166-167</code>)</td></tr> <tr><td>6.2</td><td>Admin list all</td><td>All rows; <code>userId</code> filter effective (<code>:163-167</code>)</td></tr> <tr><td>6.3</td><td><code>status</code> filter</td><td>enum-validated (<code>leave.controller.ts:40</code>); invalid → 400</td></tr> <tr><td>6.4</td><td>Sort</td><td><code>createdAt</code> desc (<code>:168</code>)</td></tr> <tr><td>6.5</td><td><code>userId</code> filter as non-admin</td><td>ignored (overridden to self, <code>:167</code>)</td></tr> </tbody></table> </div> <h2 id="7-substitutions-p1"><a class="header" href="#7-substitutions-p1">7. Substitutions (P1)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Case</th><th>Expected (source)</th></tr></thead><tbody> <tr><td>7.1</td><td>Assign on approved request</td><td>200, <code>status=assigned</code> (<code>:252-263</code>)</td></tr> <tr><td>7.2</td><td>Assign on pending/rejected</td><td>409 (<code>:227-230</code>)</td></tr> <tr><td>7.3</td><td>Requester without Teacher record</td><td>404 (<code>:232-238</code>)</td></tr> <tr><td>7.4</td><td>Exact-slot duplicate</td><td>409 clash (<code>:241-250</code>)</td></tr> <tr><td>7.5</td><td>Partial overlap (08:00-10:00 vs 09:00-11:00)</td><td>409 (<code>timeOverlaps</code>, <code>:314-321</code>)</td></tr> <tr><td>7.6</td><td>Adjacent slots (08:00-09:00 vs 09:00-10:00)</td><td>allowed (strict <code><</code>/<code>></code>, <code>:320</code>)</td></tr> <tr><td>7.7</td><td>Substitute list</td><td>sorted <code>date</code> asc (<code>:275-280</code>)</td></tr> <tr><td>7.8</td><td><code>completed</code>/<code>cancelled</code></td><td><strong>Unreachable</strong> — no endpoint (known gap; schema <code>substitution.schema.ts:7-11</code>)</td></tr> </tbody></table> </div> <h2 id="8-calendar-p2"><a class="header" href="#8-calendar-p2">8. Calendar (P2)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Case</th><th>Expected (source)</th></tr></thead><tbody> <tr><td>8.1</td><td>Range filter</td><td><code>startDate ≤ to && endDate ≥ from</code> (<code>:291-292</code>)</td></tr> <tr><td>8.2</td><td>Approved only</td><td>pending/rejected excluded (<code>:290</code>)</td></tr> <tr><td>8.3</td><td>No <code>from</code>/<code>to</code></td><td>current month default (<code>:283-287</code>)</td></tr> <tr><td>8.4</td><td>Tenant isolation</td><td>auto-scoped via <code>BaseRepository</code> (tenantId)</td></tr> </tbody></table> </div> <h2 id="9-cross-cutting"><a class="header" href="#9-cross-cutting">9. Cross-cutting</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Case</th><th>Expected</th></tr></thead><tbody> <tr><td>9.1</td><td>Tenant isolation</td><td>User A cannot see/decide tenant B rows (base-scoped queries)</td></tr> <tr><td>9.2</td><td>Soft-deleted rows</td><td>excluded by <code>BaseRepository</code></td></tr> <tr><td>9.3</td><td>Offline app</td><td>reads cached; writes blocked (10 §8)</td></tr> <tr><td>9.4</td><td>a11y sweep</td><td><a href="leave/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a> (chips not color-only, live regions for errors)</td></tr> <tr><td>9.5</td><td>Analytics <code>(proposed)</code></td><td><code>leave.*</code> events fire once per action</td></tr> </tbody></table> </div> <h2 id="10-known-gaps-to-track-regression-lock-as-current-behavior"><a class="header" href="#10-known-gaps-to-track-regression-lock-as-current-behavior">10. Known gaps to track (regression-lock as current behavior)</a></h2> <ol> <li>No same-user overlap rejection (2.1).</li> <li>No cancel/edit endpoint (<code>cancelled</code> unreachable, <code>leave-request.schema.ts:11</code>).</li> <li>No atomic approve (5.6) / balance-crossing race (5.7).</li> <li>No <code>leave.*</code> permissions (<code>permissions.constants.ts:1-97</code>); no RBAC guard on decide/substitution endpoints (<code>leave.controller.ts:27</code>).</li> <li>Duplicate <code>code</code> E11000 not mapped to 409 (12 §4).</li> <li>Substitution status transitions + absence-side list missing.</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-leave-module"><a class="header" href="#15--flutter-implementation-guide-leave-module">15 — Flutter Implementation Guide (Leave Module)</a></h1> <ul> <li><a href="leave/15_Flutter_Implementation_Guide.html#1-folder-layout">1. Folder layout</a></li> <li><a href="leave/15_Flutter_Implementation_Guide.html#2-models-wire-literal">2. Models (wire-literal)</a></li> <li><a href="leave/15_Flutter_Implementation_Guide.html#3-repository">3. Repository</a></li> <li><a href="leave/15_Flutter_Implementation_Guide.html#4-cubits-patterns-per-13">4. Cubits (patterns per 13)</a></li> <li><a href="leave/15_Flutter_Implementation_Guide.html#5-screens">5. Screens</a></li> <li><a href="leave/15_Flutter_Implementation_Guide.html#6-offline--caching">6. Offline & caching</a></li> <li><a href="leave/15_Flutter_Implementation_Guide.html#7-tests">7. Tests</a></li> <li><a href="leave/15_Flutter_Implementation_Guide.html#8-rollout-order">8. Rollout order</a></li> </ul> <blockquote> <p>Concrete build guide for the Flutter client against the leave API surface. App architecture, DI, and conventions per <a href="leave/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a> (Bloc/Cubit recommendation). All wire contracts here are literal to the NestJS module.</p> </blockquote> <hr /> <h2 id="1-folder-layout-1"><a class="header" href="#1-folder-layout-1">1. Folder layout</a></h2> <pre><code>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) </code></pre> <h2 id="2-models-wire-literal"><a class="header" href="#2-models-wire-literal">2. Models (wire-literal)</a></h2> <pre><code class="language-dart">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) } </code></pre> <ul> <li><strong>Never</strong> serialize <code>userId</code>/<code>daysRequested</code> in create payload — server owns them (<code>leave.service.ts:136-138</code>).</li> <li><code>LeaveBalanceEntry</code> fields: <code>leaveTypeId, code, name, daysPerYear, carriedForward, daysUsed, daysRemaining</code> — use tabular figures for the headline (11 §2).</li> </ul> <h2 id="3-repository"><a class="header" href="#3-repository">3. Repository</a></h2> <pre><code class="language-dart">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 } </code></pre> <p>Error mapping: parse envelope (<code>00-shared/07</code>); throw typed <code>LeaveApiException(statusCode, message)</code>; surface 400/404/409 messages verbatim (they are user-actionable, 12 §4); map Mongo <code>11000</code> to "Code already exists".</p> <h2 id="4-cubits-patterns-per-13"><a class="header" href="#4-cubits-patterns-per-13">4. Cubits (patterns per 13)</a></h2> <pre><code class="language-dart">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 } } </code></pre> <ul> <li><code>LeaveBalanceCubit.load()</code> on screen open — never reuse session cache for balance (live-computed, <code>leave.service.ts:87-88</code>).</li> <li><code>ApprovalsCubit.decide(id, action, note)</code> → on 409 "already decided" refetch queue; on 409 "insufficient balance" emit per-row error (10 §4).</li> <li><code>LeaveCalendarCubit.loadMonth(month)</code> → <code>from = yyyy-MM-01T00:00:00.000Z</code>, <code>to</code> = last day of month (server default semantics, <code>:283-287</code>).</li> </ul> <h2 id="5-screens-1"><a class="header" href="#5-screens-1">5. Screens</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Cubit</th><th>Notes</th></tr></thead><tbody> <tr><td>Balance</td><td><code>LeaveBalanceCubit</code> + <code>LeaveTypesCubit</code></td><td>skeleton per default type count (5, <code>leave.service.ts:30-64</code>); caption "weekends count"</td></tr> <tr><td>Request form</td><td><code>LeaveRequestFormCubit</code></td><td>dropdown types; date pickers chained (end ≥ start); live days preview; block offline (10 §8)</td></tr> <tr><td>My Requests</td><td><code>LeaveRequestsCubit</code></td><td>filter chips → server <code>?status=</code>; pull-to-refresh; detail bottom sheet</td></tr> <tr><td>Approvals</td><td><code>ApprovalsCubit</code></td><td>default <code>pending</code>; hide own rows (self-decision 409, <code>:179-180</code>); decision sheet = <code>ApprovalActionSheet</code></td></tr> <tr><td>Substitutions</td><td><code>SubstitutionsCubit</code></td><td>needs own teacherId from teachers module lookup</td></tr> <tr><td>Types admin</td><td><code>LeaveTypesAdminCubit</code></td><td>create sheet; E11000 inline</td></tr> <tr><td>Calendar</td><td><code>LeaveCalendarCubit</code></td><td>month grid; day tap → approved-request sheet</td></tr> </tbody></table> </div> <h2 id="6-offline--caching"><a class="header" href="#6-offline--caching">6. Offline & caching</a></h2> <ul> <li><code>Hive</code>/<code>drift</code> cache: requests list, substitutions, last calendar month, types (TTL: session). Balance: snapshot only, flagged stale.</li> <li>Writes blocked offline (mutations need server truth: balance check on decide <code>:184-190</code>, clash check <code>:241-250</code>).</li> </ul> <h2 id="7-tests"><a class="header" href="#7-tests">7. Tests</a></h2> <ul> <li>Unit: model <code>fromJson</code>; day-count function (incl. weekend + DST cases, 14 §3); cubit error mapping (404/409/11000).</li> <li>Widget: <code>StatusChip</code> renders all 4 statuses; <code>BalanceCard</code> progress clamp; form preview days.</li> <li>Integration: repository against mock HTTP with envelope fixtures (<code>00-shared/07</code>); contract fixtures generated from 12 §3 shapes.</li> <li>Golden: chips, cards, empty states (tokens per <code>00-shared/02</code>).</li> </ul> <h2 id="8-rollout-order"><a class="header" href="#8-rollout-order">8. Rollout order</a></h2> <ol> <li>Balance + My Requests (read surfaces, high frequency).</li> <li>Request form (write path with server validation).</li> <li>Approvals queue + decision (admin path, 409 handling).</li> <li>Substitutions (needs teachers lookup).</li> <li>Calendar; notifications <code>(planned)</code> last (event-driven, <code>IMPLEMENTATION_PLAN.md:163</code>).</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-library-module"><a class="header" href="#01--product-overview-library-module">01 — Product Overview (Library Module)</a></h1> <ul> <li><a href="library/01_Product_Overview.html#1-purpose">1. Purpose</a></li> <li><a href="library/01_Product_Overview.html#2-business-goals">2. Business goals</a></li> <li><a href="library/01_Product_Overview.html#3-user-goals">3. User goals</a></li> <li><a href="library/01_Product_Overview.html#4-stakeholders">4. Stakeholders</a></li> <li><a href="library/01_Product_Overview.html#5-why-this-exists">5. Why this exists</a></li> <li><a href="library/01_Product_Overview.html#6-dependencies">6. Dependencies</a></li> <li><a href="library/01_Product_Overview.html#7-success-metrics">7. Success metrics</a></li> <li><a href="library/01_Product_Overview.html#8-edge-cases-contract-level">8. Edge cases (contract level)</a></li> <li><a href="library/01_Product_Overview.html#9-assumptions-module">9. Assumptions (module)</a></li> <li><a href="library/01_Product_Overview.html#10-open-questions-module-grain-global-ledger-in-00-shared12">10. Open questions (module-grain; global ledger in 00-shared/12)</a></li> <li><a href="library/01_Product_Overview.html#11-glossary-this-module">11. Glossary (this module)</a></li> </ul> <blockquote> <p>StudyLyon — multi-tenant ERP / School Management API. This package designs the <strong>Library module client</strong> (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, domain events, permissions, and wire contracts are derived directly from <code>src/modules/library/**</code>, <code>src/modules/rbac/permissions.constants.ts</code>, and <code>docs/IMPLEMENTATION_PLAN.md</code>. No feature is invented — anything not present in source is flagged <code>(planned)</code> / <code>(proposed)</code> / <code>(forward-looking)</code> in <strong>Assumptions & Open Questions</strong>.</p> </blockquote> <p>Heads-up: per the PRD, the mobile client is <strong>out of Phase 1</strong>; 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 <em>current</em> backend surface.</p> <p>Blueprint note: <code>studylyon-blueprint/04-Modules/</code> has <strong>no dedicated Library doc</strong>; the module is only sketched as "Books / Issue / Return / Fines" responsibilities (<code>MODULE_ARCHITECTURE.md:546-557</code>) and appears in the fee-category list (<code>COLLECTIONS.md:2247</code>). The code is the source of truth.</p> <hr /> <h2 id="1-purpose-14"><a class="header" href="#1-purpose-14">1. Purpose</a></h2> <p>Library manages the school's physical book collection and the borrow lifecycle:</p> <ul> <li><strong>Catalog</strong>: books with title/author/ISBN, optional publisher/category/edition, copy counts (<code>totalCopies</code>/<code>availableCopies</code>), and a status (<code>available|borrowed|damaged|lost</code>).</li> <li><strong>Issue/return</strong>: staff hand a copy to a student with an explicit due date; returning closes the record, restores availability, and computes a fine when late.</li> <li><strong>Fines</strong>: computed server-side at return time at a fixed daily rate, tracked as <code>fineStatus (pending|paid|waived)</code>, payable via a dedicated endpoint.</li> <li><strong>Borrow visibility</strong>: full history and active-borrow lists per student.</li> </ul> <div class="table-wrapper"><table><thead><tr><th>Responsibility</th><th>Source</th></tr></thead><tbody> <tr><td>Book CRUD (create/list/search/get/patch/soft-delete)</td><td><code>library.controller.ts:27-59</code>, <code>library.service.ts:34-105</code></td></tr> <tr><td>Unique ISBN per tenant (409 on duplicate)</td><td><code>library.service.ts:35-40</code>, <code>book.schema.ts:51</code></td></tr> <tr><td>Issue book (availability + 5-book cap, decrement copies)</td><td><code>library.service.ts:107-150</code></td></tr> <tr><td>Return book (restore copies, compute fine, <code>BookReturned</code> event)</td><td><code>library.service.ts:152-193</code></td></tr> <tr><td>Borrow history / active borrows per student</td><td><code>library.service.ts:195-201</code>, <code>borrow-record.repository.ts:21-43</code></td></tr> <tr><td>Pay fine</td><td><code>library.service.ts:203-209</code></td></tr> <tr><td>Daily fine rate = 5 units/day (hardcoded)</td><td><code>library.service.ts:25,211-217</code></td></tr> <tr><td>Permissions vocabulary (<code>books.read/create/update/delete/issue/return</code>, <code>fines.pay</code>)</td><td><code>permissions.constants.ts:55-61</code></td></tr> <tr><td>Tenant scoping + soft-delete on every query</td><td><code>BaseRepository</code> (<code>base.repository.ts</code>), all repos extend it</td></tr> </tbody></table> </div> <h2 id="2-business-goals-13"><a class="header" href="#2-business-goals-13">2. Business goals</a></h2> <div class="table-wrapper"><table><thead><tr><th>Goal</th><th>Measure</th></tr></thead><tbody> <tr><td>No duplicate books</td><td>unique index <code>{tenantId, isbn}</code> (<code>book.schema.ts:51</code>) + service 409 (<code>library.service.ts:37-39</code>)</td></tr> <tr><td>Never over-issue</td><td><code>availableCopies >= 1</code> checked before decrement (<code>library.service.ts:109-111</code>)</td></tr> <tr><td>Never exceed student limit</td><td>max 5 active borrows enforced (<code>library.service.ts:116-120</code>)</td></tr> <tr><td>Fines always computable</td><td><code>fineAmount = overdueDays × 5</code> at return (<code>library.service.ts:211-217</code>)</td></tr> <tr><td>No orphaned deletes</td><td>book with active borrows cannot be deleted — 409 (<code>library.service.ts:97-102</code>)</td></tr> <tr><td>Cross-tenant isolation</td><td>every query tenant-scoped via <code>BaseRepository.scopedFilter</code></td></tr> <tr><td>Audit trail</td><td><code>BookIssued</code> / <code>BookReturned</code> domain events on the event bus (<code>library.service.ts:141-148,180-191</code>)</td></tr> </tbody></table> </div> <h2 id="3-user-goals-11"><a class="header" href="#3-user-goals-11">3. User goals</a></h2> <ul> <li><strong>Librarian / staff:</strong> search the catalog; add, edit, delete books; issue and return copies; see overdue borrowers; collect fine payments.</li> <li><strong>Student:</strong> browse/search the catalog; see which books are currently on loan to them and their history; know what's overdue.</li> <li><strong>Parent:</strong> (read-only, via student) see the child's active loans and any fine.</li> <li><strong>Org admin:</strong> catalog oversight, permission assignment (who may issue/return), configuration of limits <code>(planned)</code>.</li> </ul> <h2 id="4-stakeholders-11"><a class="header" href="#4-stakeholders-11">4. Stakeholders</a></h2> <p>Librarian/staff with <code>books.*</code> permissions, org admin (RBAC), students, parents, the RBAC module (<code>permissions.constants.ts</code>), events/notifications pipeline, QA + design + engineering.</p> <h2 id="5-why-this-exists-12"><a class="header" href="#5-why-this-exists-12">5. Why this exists</a></h2> <p>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.</p> <h2 id="6-dependencies-7"><a class="header" href="#6-dependencies-7">6. Dependencies</a></h2> <div class="table-wrapper"><table><thead><tr><th>Dependency</th><th>Role</th><th>Source</th></tr></thead><tbody> <tr><td>Students module</td><td><code>studentId</code> on borrow records (<code>ref: 'Student'</code>)</td><td><code>borrow-record.schema.ts:25-26</code></td></tr> <tr><td>Users module</td><td><code>issuedBy</code> actor (<code>ref: 'User'</code>, currently <strong>never written</strong>)</td><td><code>borrow-record.schema.ts:28-29</code></td></tr> <tr><td>Event bus</td><td><code>BookIssued</code>, <code>BookReturned</code> events</td><td><code>library.service.ts:141-148,180-191</code></td></tr> <tr><td>Files module</td><td>cover/attachment upload surface (not wired to books yet)</td><td><code>files.controller.ts:29-71</code></td></tr> <tr><td>RBAC</td><td><code>books.*</code>, <code>fines.pay</code> permissions (enforced later <code>(planned)</code>)</td><td><code>permissions.constants.ts:55-61</code></td></tr> </tbody></table> </div> <h2 id="7-success-metrics-9"><a class="header" href="#7-success-metrics-9">7. Success metrics</a></h2> <ul> <li>Catalog search (title/author/ISBN) round-trip < 2 s (regex search, indexed title/author).</li> <li>Duplicate-ISBN attempt handled as 409 100% of the time.</li> <li>Issue of an exhausted book always blocked (409), even under concurrent requests (see QA-1 in <a href="library/14_QA_Checklist.html">14_QA_Checklist.md</a> — read-then-write race today).</li> <li>Fine amount at return always equals <code>ceil(daysOverdue) × 5</code> unless staff overrides.</li> <li>Zero cross-tenant leaks (<code>BaseRepository</code> scope).</li> </ul> <h2 id="8-edge-cases-contract-level-1"><a class="header" href="#8-edge-cases-contract-level-1">8. Edge cases (contract level)</a></h2> <ul> <li><strong>Duplicate ISBN</strong> → 409 <code>DUPLICATE_RESOURCE</code> "Book with ISBN "..." already exists." (<code>library.service.ts:37-39</code>).</li> <li><strong>No copies available</strong> → 409 "No copies available for borrowing." (<code>library.service.ts:109-111</code>).</li> <li><strong>Student at 5 active loans</strong> → 409 "Student already has maximum number of borrowed books." (<code>library.service.ts:116-120</code>).</li> <li><strong>Delete book with active loans</strong> → 409 "Cannot delete book with active borrow records." (<code>library.service.ts:99-101</code>); delete is a <strong>soft delete</strong> (<code>book.repository.ts</code> via <code>BaseRepository.softDelete</code>, <code>library.service.ts:103</code>).</li> <li><strong>Return a non-active record</strong> → 409 "Book was not actively borrowed." (<code>library.service.ts:155-157</code>).</li> <li><strong>Unknown book/record</strong> → 404 <code>RESOURCE_NOT_FOUND</code> (<code>library.service.ts:80,92,103,154,170,207</code>).</li> <li><strong>Invalid Mongo id</strong> → <code>CastError</code> → 400 <code>VALIDATION_ERROR</code> "Invalid resource identifier." (<code>http-exception.filter.ts</code>).</li> <li><strong>Copy-count edits</strong> adjust <code>availableCopies</code> by the same delta, floored at 0 (<code>library.service.ts:87-90</code>).</li> <li><strong>Staff override fine</strong> — return DTO may set <code>fineAmount</code> explicitly (<code>return-book.dto.ts:9-13</code>); server uses <code>dto.fineAmount ?? calculateFine(dueDate)</code> (<code>library.service.ts:159</code>).</li> <li><strong><code>fineStatus</code> on time</strong> — records with <code>fineAmount = 0</code> keep <code>fineStatus: undefined</code> (<code>library.service.ts:166</code>); <code>WAIVED</code> is never produced by any endpoint.</li> </ul> <h2 id="9-assumptions-module-9"><a class="header" href="#9-assumptions-module-9">9. Assumptions (module)</a></h2> <ul> <li><strong>PRD: mobile client out of Phase 1</strong> — forward-looking spec; backend remains the contracts authority.</li> <li>Endpoints are guarded <strong>only by <code>JwtAuthGuard</code></strong> (<code>library.controller.ts:22</code>) — no <code>@Permissions()</code> metadata anywhere; <code>books.read/create/update/delete/issue/return</code> and <code>fines.pay</code> exist in <code>ALL_PERMISSIONS</code> (<code>permissions.constants.ts:55-61</code>) but are <strong>not enforced</strong>. Real RBAC is <code>(planned)</code> (OQ-1).</li> <li><strong>Overdue is not computed by the server.</strong> <code>BorrowStatus.OVERDUE</code> and repository <code>findOverdue()</code> exist (<code>borrow-record.schema.ts:9-11</code>, <code>borrow-record.repository.ts:34-43</code>) but no service endpoint/worker sets it. Overdue detection is client-derived today; a scheduled scan is <code>(planned)</code> (IMPLEMENTATION_PLAN.md:228) (OQ-2).</li> <li><strong><code>issuedBy</code> is never populated</strong> — the schema has the field (<code>borrow-record.schema.ts:28-29</code>) but <code>issueBook</code> doesn't set it (<code>library.service.ts:132-139</code>) (OQ-3).</li> <li>Fines are <strong>units</strong> (plain <code>Number</code>, <code>library.service.ts:25</code> rate = 5); no currency/format contract server-side.</li> <li><code>BookStatus.DAMAGED/LOST</code> and <code>BorrowStatus.LOST</code> exist in enums but no endpoint transitions to them — damage/loss workflow <code>(planned)</code> (OQ-4).</li> <li>No pagination on borrow endpoints (<code>library.service.ts:195-201</code>) — client paging <code>(proposed)</code>.</li> <li>Search covers <strong>title/author/ISBN only</strong> (<code>book.repository.ts:26-30</code>); category/ publisher/status filters must be client-side (OQ-5).</li> <li>Catalog import/export is <code>(planned)</code> (IMPLEMENTATION_PLAN.md:172,228); QR/barcode scanning is <code>(forward-looking)</code>; analytics <code>(proposed)</code>.</li> </ul> <h2 id="10-open-questions-module-grain-global-ledger-in-00-shared12-1"><a class="header" href="#10-open-questions-module-grain-global-ledger-in-00-shared12-1">10. Open questions (module-grain; global ledger in <a href="library/../00-shared/12_Assumptions_&_Open_Questions.html">00-shared/12</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Item</th><th>Impact</th></tr></thead><tbody> <tr><td>OQ-1</td><td>No RBAC decorators on library endpoints; perms exist but unenforced</td><td>Role-gated UI waits for guard wiring</td></tr> <tr><td>OQ-2</td><td>No overdue worker — <code>OVERDUE</code> status and <code>findOverdue()</code> unused</td><td>Overdue badge derived client-side; server scan <code>(planned)</code></td></tr> <tr><td>OQ-3</td><td><code>issuedBy</code> never written on issue</td><td>"Issued by" attribution unavailable</td></tr> <tr><td>OQ-4</td><td>No damage/lost transitions, no renew/reserve endpoints</td><td>Those workflows <code>(planned)</code></td></tr> <tr><td>OQ-5</td><td>Server search limited to title/author/ISBN; no status/category filters</td><td>Client-side filter only</td></tr> <tr><td>OQ-6</td><td><code>payFine</code> doesn't guard status — can pay a fine on a still-active loan, or twice (idempotent overwrite)</td><td>Pay button gating is client responsibility</td></tr> <tr><td>OQ-7</td><td>Issue is read-then-write (<code>availableCopies</code>) — no atomic conditional update</td><td>Concurrent double-issue race (QA-1)</td></tr> <tr><td>OQ-8</td><td>No book-cover field on <code>Book</code>; files module exists separately</td><td>Cover upload <code>(proposed)</code></td></tr> <tr><td>OQ-9</td><td>No <code>me</code> borrows endpoint — student view needs <code>studentId</code> from profile</td><td><code>GET /books/borrows/:studentId/active</code> requires profile lookup</td></tr> </tbody></table> </div> <h2 id="11-glossary-this-module-9"><a class="header" href="#11-glossary-this-module-9">11. Glossary (this module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Term</th><th>Meaning</th></tr></thead><tbody> <tr><td>Book</td><td><code>books</code> doc: title, author, isbn, publisher?, category?, edition?, <code>totalCopies</code>, <code>availableCopies</code>, status `available</td></tr> <tr><td>Borrow record</td><td><code>borrow_records</code> doc: bookId, studentId, issuedBy?, borrowedAt, dueDate, returnedAt?, status `active</td></tr> <tr><td>Issue</td><td>staff lends a copy: decrement availability, create ACTIVE record with due date</td></tr> <tr><td>Return</td><td>close an ACTIVE record: restore availability, set RETURNED + returnedAt, compute fine</td></tr> <tr><td>Fine</td><td><code>overdueDays × 5</code> units (<code>library.service.ts:25,211-217</code>), status `pending</td></tr> <tr><td>Envelope</td><td><code>{success, message, data, meta?, timestamp, requestId}</code> (<code>response-envelope.interceptor.ts:44-52</code>)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-library-module"><a class="header" href="#02--user-personas-library-module">02 — User Personas (Library Module)</a></h1> <ul> <li><a href="library/02_User_Personas.html#1-the-librarian-primary-catalog--circulation-user">1. The Librarian (primary catalog + circulation user)</a></li> <li><a href="library/02_User_Personas.html#2-catalog-admin--org-admin-setup--oversight">2. Catalog Admin / Org Admin (setup + oversight)</a></li> <li><a href="library/02_User_Personas.html#3-student-borrower">3. Student (borrower)</a></li> <li><a href="library/02_User_Personas.html#4-parent-read-only-observer">4. Parent (read-only observer)</a></li> <li><a href="library/02_User_Personas.html#5-accounts--bursar-fine-collector-occasional">5. Accounts / Bursar (fine collector, occasional)</a></li> <li><a href="library/02_User_Personas.html#permission--capability-matrix">Permission → capability matrix</a></li> </ul> <blockquote> <p>Who uses the library surface, what permissions they hold (<code>permissions.constants.ts:55-61</code>), and what the client must show each. RBAC enforcement is <code>(planned)</code> (OQ-1) — today any authenticated user can call any library endpoint; these personas are the target post-RBAC model.</p> </blockquote> <hr /> <h2 id="1-the-librarian-primary-catalog--circulation-user"><a class="header" href="#1-the-librarian-primary-catalog--circulation-user">1. The Librarian (primary catalog + circulation user)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Role</td><td>Staff member assigned library duty; holds <code>books.read/create/update/delete</code>, <code>books.issue</code>, <code>books.return</code>, <code>fines.pay</code></td></tr> <tr><td>Goals</td><td>Keep catalog accurate; move books in and out fast at the desk; collect fine payments; see what's overdue</td></tr> <tr><td>Pains</td><td>Double-issuing a copy that has no stock; losing track of who owes what; duplicate ISBN entries</td></tr> <tr><td>Key screens</td><td>Catalog list/search, book detail, issue form, return form, overdue view, borrowed-by-me (for lookups)</td></tr> <tr><td>Behaviour notes</td><td>Fast scanning + typing; wants availability visible in every list row; wants a confirm step on money actions (fine pay)</td></tr> <tr><td>Frequency</td><td>Daily, many transactions; optimizes for speed, tolerates confirmation dialogs</td></tr> </tbody></table> </div> <h2 id="2-catalog-admin--org-admin-setup--oversight"><a class="header" href="#2-catalog-admin--org-admin-setup--oversight">2. Catalog Admin / Org Admin (setup + oversight)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Role</td><td>Org admin or head librarian; <code>books.read/update/delete</code> (+ RBAC assignment in the future)</td></tr> <tr><td>Goals</td><td>Onboard the collection (bulk import <code>(planned)</code>), fix metadata, retire books, define who may issue</td></tr> <tr><td>Pains</td><td>Manual entry of hundreds of titles; deleting a book that is still loaned out (409 blocks it)</td></tr> <tr><td>Key screens</td><td>Catalog list, book detail, book form (create/edit)</td></tr> <tr><td>Behaviour notes</td><td>Desktop/tablet heavy; wants ISBN-uniqueness surfaced immediately (409)</td></tr> <tr><td>Notes</td><td>Bulk catalog import <code>(planned)</code> — IMPLEMENTATION_PLAN.md:172,228</td></tr> </tbody></table> </div> <h2 id="3-student-borrower"><a class="header" href="#3-student-borrower">3. Student (borrower)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Role</td><td>Student of the school; holds <code>books.read</code> in the future (<code>(planned)</code> RBAC)</td></tr> <tr><td>Goals</td><td>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</td></tr> <tr><td>Pains</td><td>Not knowing whether a book is in stock; losing track of due dates; surprise fines</td></tr> <tr><td>Key screens</td><td>Catalog list/search, book detail, borrowed-by-me (active + history), fine payment status</td></tr> <tr><td>Behaviour notes</td><td>Reads availability badges before walking to the shelf; needs due-date prominence</td></tr> <tr><td>Note</td><td>Student view needs their <code>studentId</code> — no <code>me</code> endpoint today (OQ-9); client must pull it from the Students profile state</td></tr> </tbody></table> </div> <h2 id="4-parent-read-only-observer"><a class="header" href="#4-parent-read-only-observer">4. Parent (read-only observer)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Role</td><td>Parent of a student</td></tr> <tr><td>Goals</td><td>See child's current loans and any pending fine</td></tr> <tr><td>Key screens</td><td>Borrowed-by-me (child's) view, read-only</td></tr> <tr><td>Behaviour notes</td><td>No write actions; fine amounts shown with status `pending</td></tr> <tr><td>Note</td><td>Depends on parent↔student linkage from the Students/Parents modules <code>(planned)</code> in this package</td></tr> </tbody></table> </div> <h2 id="5-accounts--bursar-fine-collector-occasional"><a class="header" href="#5-accounts--bursar-fine-collector-occasional">5. Accounts / Bursar (fine collector, occasional)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Role</td><td>Staff with <code>fines.pay</code> (and <code>student.read</code>)</td></tr> <tr><td>Goals</td><td>Take fine payments at return time or later; mark paid</td></tr> <tr><td>Key screens</td><td>Return form (fine computed inline), fine pay action from a borrow record</td></tr> <tr><td>Behaviour notes</td><td>Wants the server's computed fine shown verbatim; records payment against <code>fineStatus: pending</code> records</td></tr> </tbody></table> </div> <hr /> <h2 id="permission--capability-matrix"><a class="header" href="#permission--capability-matrix">Permission → capability matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th>Permission (<code>permissions.constants.ts:55-61</code>)</th><th>Capability</th></tr></thead><tbody> <tr><td><code>books.read</code></td><td>Catalog list/search/detail, borrow history</td></tr> <tr><td><code>books.create</code></td><td>Create book (add to catalog)</td></tr> <tr><td><code>books.update</code></td><td>Edit book metadata + copy counts</td></tr> <tr><td><code>books.delete</code></td><td>Soft-delete book (blocked while active loans exist)</td></tr> <tr><td><code>books.issue</code></td><td>Issue a copy to a student</td></tr> <tr><td><code>books.return</code></td><td>Return a copy, compute/override fine</td></tr> <tr><td><code>fines.pay</code></td><td>Mark a borrow record's fine paid</td></tr> </tbody></table> </div> <blockquote> <p>Enforcement is <code>(planned)</code>: no <code>@Permissions</code> decorators on <code>library.controller.ts</code> endpoints today (OQ-1). Until then the client can pre-emptively hide actions the user's role lacks, using this matrix.</p> </blockquote> <div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journey-library-module"><a class="header" href="#03--user-journey-library-module">03 — User Journey (Library Module)</a></h1> <ul> <li><a href="library/03_User_Journey.html#1-add-a-book-to-the-catalog-librariancatalog-admin">1. Add a book to the catalog (librarian/catalog admin)</a></li> <li><a href="library/03_User_Journey.html#2-find-a-book-student--librarian">2. Find a book (student / librarian)</a></li> <li><a href="library/03_User_Journey.html#3-issue-a-book-to-a-student-librarian">3. Issue a book to a student (librarian)</a></li> <li><a href="library/03_User_Journey.html#4-return-a-book-settle-the-fine-librarian--bursar">4. Return a book, settle the fine (librarian + bursar)</a></li> <li><a href="library/03_User_Journey.html#5-student-checks-their-loans-student--parent">5. Student checks their loans (student / parent)</a></li> <li><a href="library/03_User_Journey.html#6-librarians-overdue-sweep-daily">6. Librarian's overdue sweep (daily)</a></li> <li><a href="library/03_User_Journey.html#journey--screen-map">Journey → screen map</a></li> </ul> <blockquote> <p>End-to-end journeys mapped to exact endpoints. Write actions are server-confirmed (no optimistic mutations); conflicts (409) are surfaced as banners, never silent.</p> </blockquote> <hr /> <h2 id="1-add-a-book-to-the-catalog-librariancatalog-admin"><a class="header" href="#1-add-a-book-to-the-catalog-librariancatalog-admin">1. Add a book to the catalog (librarian/catalog admin)</a></h2> <pre><code>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) </code></pre> <ul> <li>Copies default to 1; <code>availableCopies</code> mirrors <code>totalCopies</code> (<code>library.service.ts:41-46</code>).</li> <li>Bulk entry (many titles) is <code>(planned)</code> via catalog import (IMPLEMENTATION_PLAN.md:172,228).</li> </ul> <h2 id="2-find-a-book-student--librarian"><a class="header" href="#2-find-a-book-student--librarian">2. Find a book (student / librarian)</a></h2> <pre><code>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 </code></pre> <ul> <li>No filter → full catalog sorted by title asc (<code>library.service.ts:68-74</code>).</li> <li>Category/publisher/status filtering is client-side only (OQ-5).</li> </ul> <h2 id="3-issue-a-book-to-a-student-librarian"><a class="header" href="#3-issue-a-book-to-a-student-librarian">3. Issue a book to a student (librarian)</a></h2> <pre><code>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 </code></pre> <ul> <li>Side effect: <code>BookIssued</code> domain event (<code>library.service.ts:141-148</code>).</li> </ul> <h2 id="4-return-a-book-settle-the-fine-librarian--bursar"><a class="header" href="#4-return-a-book-settle-the-fine-librarian--bursar">4. Return a book, settle the fine (librarian + bursar)</a></h2> <pre><code>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 </code></pre> <ul> <li>Side effect: <code>BookReturned</code> event with <code>fineAmount</code> (<code>library.service.ts:180-191</code>).</li> </ul> <h2 id="5-student-checks-their-loans-student--parent"><a class="header" href="#5-student-checks-their-loans-student--parent">5. Student checks their loans (student / parent)</a></h2> <pre><code>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.) </code></pre> <ul> <li><strong>Overdue detection is client-derived</strong> (server has no worker): <code>now > dueDate && status == active</code> → overdue badge + computed fine preview (OQ-2).</li> <li><code>studentId</code> comes from the student profile state, not a <code>me</code> endpoint (OQ-9).</li> </ul> <h2 id="6-librarians-overdue-sweep-daily"><a class="header" href="#6-librarians-overdue-sweep-daily">6. Librarian's overdue sweep (daily)</a></h2> <pre><code>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) </code></pre> <ul> <li>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.</li> </ul> <hr /> <h2 id="journey--screen-map-1"><a class="header" href="#journey--screen-map-1">Journey → screen map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Journey</th><th>Screens (<code>05_Screen_Inventory.md</code>)</th></tr></thead><tbody> <tr><td>Add/edit book</td><td>4 Catalog List → 5 Book Detail → 7 Book Form</td></tr> <tr><td>Find book</td><td>4 Catalog List (search) → 5 Book Detail</td></tr> <tr><td>Issue</td><td>5 Book Detail → 8 Issue Form</td></tr> <tr><td>Return + fine</td><td>6 Borrowed-by-Me → 9 Return Form → fine pay on record</td></tr> <tr><td>Student loans check</td><td>6 Borrowed-by-Me (active) → history tab</td></tr> <tr><td>Overdue sweep</td><td>10 Overdue View (derived)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-library-module"><a class="header" href="#04--information-architecture-library-module">04 — Information Architecture (Library Module)</a></h1> <ul> <li><a href="library/04_Information_Architecture.html#1-screen-tree">1. Screen tree</a></li> <li><a href="library/04_Information_Architecture.html#2-navigation-rules">2. Navigation rules</a></li> <li><a href="library/04_Information_Architecture.html#3-data-model-client-views-of-server-docs">3. Data model (client views of server docs)</a></li> <li><a href="library/04_Information_Architecture.html#4-labels--status-vocabulary">4. Labels & status vocabulary</a></li> <li><a href="library/04_Information_Architecture.html#5-metadata--search-surface">5. Metadata & search surface</a></li> <li><a href="library/04_Information_Architecture.html#6-cross-module-references">6. Cross-module references</a></li> </ul> <blockquote> <p>Screen tree, route map, and data model binding for the library surface. Routes are relative to the app's authenticated shell (see <a href="library/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a>). Server documents: <code>Book</code> (<code>book.schema.ts:15-48</code>), <code>BorrowRecord</code> (<code>borrow-record.schema.ts:20-58</code>).</p> </blockquote> <hr /> <h2 id="1-screen-tree"><a class="header" href="#1-screen-tree">1. Screen tree</a></h2> <pre><code>/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) </code></pre> <h2 id="2-navigation-rules-1"><a class="header" href="#2-navigation-rules-1">2. Navigation rules</a></h2> <ul> <li>Catalog List is the library home; search is in-place (no separate screen).</li> <li>Book Detail is the hub: view metadata, availability, active loans <code>(planned)</code> — it routes to Issue (if available), Edit (if permitted).</li> <li>Borrowed-by-Me is a two-tab screen: <strong>Active</strong> (<code>status=active</code>) / <strong>History</strong> (<code>status=returned</code>, newest first — <code>library.service.ts:196</code> sorts <code>borrowedAt</code> desc).</li> <li>Overdue View is a derived list of active records where <code>now > dueDate</code>; gated to staff (<code>books.issue</code>/<code>books.return</code>). Server-side scan <code>(planned)</code>.</li> <li>No deep-linking to a borrow record route exists server-side; client uses record id as the key.</li> </ul> <h2 id="3-data-model-client-views-of-server-docs"><a class="header" href="#3-data-model-client-views-of-server-docs">3. Data model (client views of server docs)</a></h2> <p><strong>Book</strong> (<code>book.schema.ts:15-48</code>, <code>base.schema.ts</code>):</p> <pre><code>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 } </code></pre> <ul> <li>Derived client fields: <code>availability = availableCopies</code>, <code>isAvailable = availableCopies > 0</code>, <code>copyBadge = "$availableCopies/$totalCopies"</code>.</li> </ul> <p><strong>BorrowRecord</strong> (<code>borrow-record.schema.ts:20-58</code>):</p> <pre><code>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? } </code></pre> <ul> <li>Derived client fields: <code>isOverdue = status==active && now > dueDate</code>, <code>finePreview = ceil((now - dueDate)/day) × 5</code> (mirrors server rule <code>library.service.ts:211-217</code>; authoritative only after return).</li> </ul> <h2 id="4-labels--status-vocabulary"><a class="header" href="#4-labels--status-vocabulary">4. Labels & status vocabulary</a></h2> <div class="table-wrapper"><table><thead><tr><th>Server enum</th><th>Value</th><th>UI badge</th><th>Where</th></tr></thead><tbody> <tr><td><code>BookStatus</code></td><td><code>available</code> / <code>borrowed</code> / <code>damaged</code> / <code>lost</code></td><td>green / amber (borrowed) / red / grey</td><td><code>book.schema.ts:7-12</code></td></tr> <tr><td><code>BorrowStatus</code></td><td><code>active</code> / <code>returned</code> / <code>overdue</code> / <code>lost</code></td><td>blue / grey / red / red</td><td><code>borrow-record.schema.ts:7-12</code></td></tr> <tr><td><code>FineStatus</code></td><td><code>pending</code> / <code>paid</code> / <code>waived</code></td><td>amber / green / grey</td><td><code>borrow-record.schema.ts:14-18</code></td></tr> </tbody></table> </div> <ul> <li><code>overdue</code> and <code>lost</code> on borrow records are <strong>never written by any endpoint today</strong> (OQ-2/OQ-4) — client derives overdue; lost is future.</li> <li>Fine amounts have <strong>no currency contract</strong> (plain <code>Number</code>, rate 5/day, <code>library.service.ts:25</code>) — render as plain units.</li> </ul> <h2 id="5-metadata--search-surface"><a class="header" href="#5-metadata--search-surface">5. Metadata & search surface</a></h2> <ul> <li>Server search: <code>q</code> regex over title/author/ISBN, case-insensitive (<code>book.repository.ts:26-30</code>); pagination <code>page</code>/<code>limit</code> via <code>buildPaginationMeta</code> (<code>library.service.ts:75</code>).</li> <li>Client-side filters only: category, publisher, status, availability (OQ-5).</li> <li>Sort: title asc server-side default (<code>book.repository.ts:36</code>); no <code>sort</code> query param on <code>GET /books</code> — client re-sorts in memory.</li> </ul> <h2 id="6-cross-module-references"><a class="header" href="#6-cross-module-references">6. Cross-module references</a></h2> <ul> <li><code>studentId</code> → Students module profile (client pulls id from student state; OQ-9).</li> <li><code>issuedBy</code> → Users module (field dead today, OQ-3).</li> <li>Events <code>BookIssued</code> / <code>BookReturned</code> (<code>library.service.ts:141-148,180-191</code>) → notifications pipeline <code>(proposed)</code>.</li> <li>Covers: Files module (<code>files.controller.ts:29-71</code>) exists; no cover field on Book (OQ-8) — cover attachments <code>(proposed)</code>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-library-module"><a class="header" href="#05--screen-inventory-library-module">05 — Screen Inventory (Library Module)</a></h1> <ul> <li><a href="library/05_Screen_Inventory.html#legend">Legend</a></li> <li><a href="library/05_Screen_Inventory.html#1-catalog-list--search-library">1. Catalog List + Search (<code>/library</code>)</a></li> <li><a href="library/05_Screen_Inventory.html#2-book-detail-librarybooksid">2. Book Detail (<code>/library/books/:id</code>)</a></li> <li><a href="library/05_Screen_Inventory.html#3-book-form--create-librarybooksnew--edit-librarybooksidedit">3. Book Form — create (<code>/library/books/new</code>) & edit (<code>/library/books/:id/edit</code>)</a></li> <li><a href="library/05_Screen_Inventory.html#4-issue-form-librarybooksidissue--sheet-on-mobile">4. Issue Form (<code>/library/books/:id/issue</code> — sheet on mobile)</a></li> <li><a href="library/05_Screen_Inventory.html#5-borrowed-by-me--active--history-librarymy-borrows">5. Borrowed-by-Me — Active & History (<code>/library/my-borrows</code>)</a></li> <li><a href="library/05_Screen_Inventory.html#6-return-form-librarymy-borrowsreturnrecordid--sheet">6. Return Form (<code>/library/my-borrows/return/:recordId</code> — sheet)</a></li> <li><a href="library/05_Screen_Inventory.html#7-overdue-view-libraryoverdue">7. Overdue View (<code>/library/overdue</code>)</a></li> <li><a href="library/05_Screen_Inventory.html#8-fine-pay-inline-on-any-borrow-record-with-finestatuspending">8. Fine Pay (inline on any borrow record with <code>fineStatus=pending</code>)</a></li> <li><a href="library/05_Screen_Inventory.html#shared-components-used">Shared components used</a></li> <li><a href="library/05_Screen_Inventory.html#analytics-events-proposed">Analytics events (proposed)</a></li> <li><a href="library/05_Screen_Inventory.html#keyboard-landscape-tablet-desktop">Keyboard, landscape, tablet, desktop</a></li> </ul> <blockquote> <p>Every screen of the Library module, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in <a href="library/../00-shared/03_Component_Library.html">00-shared/03</a>; this file enumerates which ones each screen uses with module specifics.</p> </blockquote> <hr /> <h2 id="legend-13"><a class="header" href="#legend-13">Legend</a></h2> <p>States = idle / loading / success / empty / error(offline, rate, invalid) / disabled / permission. Analytics events follow <code>{module}.{screen}.{action}</code> (proposed; SDK open — <a href="library/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a>).</p> <hr /> <h2 id="1-catalog-list--search-library"><a class="header" href="#1-catalog-list--search-library">1. Catalog List + Search (<code>/library</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Browse and search the book catalog with live availability</td></tr> <tr><td>Entry</td><td>library tab/home; after create/edit/issue/return (refresh on focus)</td></tr> <tr><td>Exit</td><td>→ book detail (row); → book form (FAB)</td></tr> <tr><td>Source</td><td><code>GET /api/v1/books?page&limit&q</code> (<code>library.controller.ts:33-41</code>) — server regex on title/author/ISBN (<code>book.repository.ts:26-30</code>)</td></tr> <tr><td>Widgets</td><td><code>AppSearchBar</code> (debounced <code>q</code>), <code>AppListTile</code>/<code>AppCard</code> per book (title, author, availability badge <code>$available/$total</code>), <code>AppFAB</code> "Add book", <code>AppSkeleton</code>, <code>AppEmptyState</code> ("No books match…"), <code>AppPagination</code> (infinite scroll)</td></tr> <tr><td>Filters</td><td>client-side chips only: category, status, availability (OQ-5)</td></tr> <tr><td>Permissions</td><td>read requires <code>books.read</code> (enforcement <code>(planned)</code>); FAB hidden without <code>books.create</code></td></tr> <tr><td>States</td><td>loading, empty, error(offline/5xx/429), idle; permission state (hidden FAB)</td></tr> <tr><td>Analytics</td><td><code>library.catalog.search</code>, <code>library.catalog.open_book</code> (proposed)</td></tr> <tr><td>Realtime</td><td>re-fetch on focus; WS <code>(planned)</code></td></tr> </tbody></table> </div> <h2 id="2-book-detail-librarybooksid"><a class="header" href="#2-book-detail-librarybooksid">2. Book Detail (<code>/library/books/:id</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Full metadata, availability, and entry point to issue/edit</td></tr> <tr><td>Source</td><td><code>GET /api/v1/books/:id</code> (<code>library.controller.ts:43-47</code>) → 404 <code>RESOURCE_NOT_FOUND</code> (<code>library.service.ts:80</code>)</td></tr> <tr><td>Widgets</td><td><code>AppCard</code> header (title, author, ISBN), metadata rows (publisher, category, edition, shelfLocation, description), <code>AppBadge</code> status + <code>$available/$total</code> copies, action buttons</td></tr> <tr><td>Actions</td><td>Issue (enabled iff <code>availableCopies >= 1</code>, <code>books.issue</code>), Edit (<code>books.update</code>), Delete (<code>books.delete</code>, confirm dialog)</td></tr> <tr><td>States</td><td>loading skeleton, 404 → <code>AppEmptyState</code> + back, permission-based action hiding</td></tr> <tr><td>Analytics</td><td><code>library.book.open</code>, <code>library.book.issue_start</code> (proposed)</td></tr> </tbody></table> </div> <h2 id="3-book-form--create-librarybooksnew--edit-librarybooksidedit"><a class="header" href="#3-book-form--create-librarybooksnew--edit-librarybooksidedit">3. Book Form — create (<code>/library/books/new</code>) & edit (<code>/library/books/:id/edit</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Add a book (FAB) or edit metadata/copies</td></tr> <tr><td>Source</td><td>create: <code>POST /api/v1/books</code> (<code>library.controller.ts:27-31</code>); edit: <code>PATCH /api/v1/books/:id</code> (<code>library.controller.ts:49-53</code>)</td></tr> <tr><td>Form</td><td>title*, author*, isbn*, publisher?, category?, edition?, totalCopies?, shelfLocation?, description? (<code>create-book.dto.ts:5-46</code>; update = same fields optional, <code>update-book.dto.ts:4</code>)</td></tr> <tr><td>Errors</td><td>409 duplicate ISBN inline (<code>library.service.ts:37-39</code>); 400 field errors; 404 on edit</td></tr> <tr><td>Permissions</td><td><code>books.create</code> / <code>books.update</code></td></tr> <tr><td>Analytics</td><td><code>library.book.create</code>, <code>library.book.update</code> (proposed)</td></tr> </tbody></table> </div> <h2 id="4-issue-form-librarybooksidissue--sheet-on-mobile"><a class="header" href="#4-issue-form-librarybooksidissue--sheet-on-mobile">4. Issue Form (<code>/library/books/:id/issue</code> — sheet on mobile)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Lend a copy to a student with an explicit due date</td></tr> <tr><td>Source</td><td><code>POST /api/v1/books/issue</code> (<code>library.controller.ts:61-65</code>)</td></tr> <tr><td>Form</td><td>student (picker from Students module), dueDate* (date picker, must be future), notes? (<code>issue-book.dto.ts:5-20</code>)</td></tr> <tr><td>Conflicts</td><td>409 no copies (<code>library.service.ts:109-111</code>), 409 max 5 active (<code>library.service.ts:116-120</code>) → banners + refresh</td></tr> <tr><td>Permissions</td><td><code>books.issue</code></td></tr> <tr><td>Side effect</td><td><code>BookIssued</code> event (<code>library.service.ts:141-148</code>)</td></tr> <tr><td>Analytics</td><td><code>library.issue.submit</code> (proposed)</td></tr> </tbody></table> </div> <h2 id="5-borrowed-by-me--active--history-librarymy-borrows"><a class="header" href="#5-borrowed-by-me--active--history-librarymy-borrows">5. Borrowed-by-Me — Active & History (<code>/library/my-borrows</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>A student's current loans and full history (staff use for lookups too)</td></tr> <tr><td>Source</td><td>active: <code>GET /api/v1/books/borrows/:studentId/active</code> (<code>library.controller.ts:79-83</code>); history: <code>GET /api/v1/books/borrows/:studentId</code> (<code>library.controller.ts:73-77</code>, sorted <code>borrowedAt</code> desc, <code>library.service.ts:196</code>)</td></tr> <tr><td>Widgets</td><td>two tabs; per record <code>AppCard</code>: book title (populated <code>bookId</code>), borrowedAt, dueDate (prominent), returnedAt, fine chip</td></tr> <tr><td>Derived</td><td>overdue badge + fine preview when <code>active && now > dueDate</code> (OQ-2)</td></tr> <tr><td>Permissions</td><td><code>books.read</code>; <code>studentId</code> from profile (OQ-9)</td></tr> <tr><td>Analytics</td><td><code>library.myborrows.open</code> (proposed)</td></tr> </tbody></table> </div> <h2 id="6-return-form-librarymy-borrowsreturnrecordid--sheet"><a class="header" href="#6-return-form-librarymy-borrowsreturnrecordid--sheet">6. Return Form (<code>/library/my-borrows/return/:recordId</code> — sheet)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Close an ACTIVE borrow; compute or override the fine</td></tr> <tr><td>Source</td><td><code>POST /api/v1/books/return</code> (<code>library.controller.ts:67-71</code>)</td></tr> <tr><td>Form</td><td>fineAmount? (prefilled with server-rule preview <code>ceil(days) × 5</code>, editable by staff), notes? (<code>return-book.dto.ts:5-18</code>)</td></tr> <tr><td>Conflicts</td><td>409 "Book was not actively borrowed." (<code>library.service.ts:155-157</code>) → refresh</td></tr> <tr><td>Permissions</td><td><code>books.return</code></td></tr> <tr><td>Side effect</td><td><code>BookReturned</code> event with fineAmount (<code>library.service.ts:180-191</code>)</td></tr> <tr><td>Analytics</td><td><code>library.return.submit</code>, <code>library.fine.pay</code> (proposed)</td></tr> </tbody></table> </div> <h2 id="7-overdue-view-libraryoverdue"><a class="header" href="#7-overdue-view-libraryoverdue">7. Overdue View (<code>/library/overdue</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Staff sweep of late loans</td></tr> <tr><td>Source</td><td><strong>client-derived today</strong>: from Borrowed-by-Me / per-student active lists, filter <code>now > dueDate</code>; server <code>findOverdue()</code> exists unused (<code>borrow-record.repository.ts:34-43</code>); dedicated endpoint + scan <code>(planned)</code> (IMPLEMENTATION_PLAN.md:228)</td></tr> <tr><td>Widgets</td><td>list of overdue cards: student, book, days late, fine preview, "Return" shortcut</td></tr> <tr><td>Permissions</td><td>staff (<code>books.issue</code>/<code>books.return</code>)</td></tr> <tr><td>Note</td><td>This screen is honest about its data: shows "(derived — server scan planned)"</td></tr> <tr><td>Analytics</td><td><code>library.overdue.open</code> (proposed)</td></tr> </tbody></table> </div> <h2 id="8-fine-pay-inline-on-any-borrow-record-with-finestatuspending"><a class="header" href="#8-fine-pay-inline-on-any-borrow-record-with-finestatuspending">8. Fine Pay (inline on any borrow record with <code>fineStatus=pending</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Mark a fine paid</td></tr> <tr><td>Source</td><td><code>POST /api/v1/books/fines/:borrowRecordId/pay</code> (<code>library.controller.ts:85-89</code>) → <code>fineStatus: PAID</code> (<code>library.service.ts:203-209</code>)</td></tr> <tr><td>Widgets</td><td>confirm <code>AppDialog</code> showing fineAmount → snackbar success</td></tr> <tr><td>Permissions</td><td><code>fines.pay</code></td></tr> <tr><td>Note</td><td>server does not guard state (OQ-6) — client only shows the button on <code>fineStatus == pending</code> records</td></tr> </tbody></table> </div> <hr /> <h2 id="shared-components-used-15"><a class="header" href="#shared-components-used-15">Shared components used</a></h2> <p><code>AppTextField</code>, <code>AppButton</code>, <code>AppSnackbar</code>, <code>AppCard</code>, <code>AppListTile</code>, <code>AppBottomSheet</code>, <code>AppDialog</code>, <code>AppMenu</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppOfflineBanner</code>, <code>AppFAB</code>, <code>AppBadge</code>, <code>AppChips</code>, <code>AppSearchBar</code>, <code>AppAvatar</code>, <code>AppDateField</code>, <code>AppPicker</code>. Module-specific: <code>availabilityBadge</code>, <code>fineChip</code>, <code>overdueTag</code>, <code>copyStepper</code> — defined in <a href="library/07_Component_Library.html">07_Component_Library.md</a>.</p> <h2 id="analytics-events-proposed-12"><a class="header" href="#analytics-events-proposed-12">Analytics events (proposed)</a></h2> <p><code>library.catalog.{search,open_book}</code>, <code>library.book.{create,update,delete,issue_start}</code>, <code>library.issue.{submit,conflict}</code>, <code>library.return.{submit,conflict}</code>, <code>library.fine.{pay,pay_conflict}</code>, <code>library.myborrows.open</code>, <code>library.overdue.open</code> (all proposed).</p> <h2 id="keyboard-landscape-tablet-desktop-14"><a class="header" href="#keyboard-landscape-tablet-desktop-14">Keyboard, landscape, tablet, desktop</a></h2> <ul> <li>Forms are sheets on phone, centered dialogs on tablet/desktop (≤ 480 dp, <a href="library/../00-shared/03_Component_Library.html">00-shared/03</a>).</li> <li>Catalog List: phone single column; tablet/desktop two-column list/detail master-detail.</li> <li>Date fields use <code>AppDateField</code> (native picker); no manual date typing.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-library-module"><a class="header" href="#06--screen-specifications-library-module">06 — Screen Specifications (Library Module)</a></h1> <ul> <li><a href="library/06_Screen_Specifications.html#s1-catalog-list--search-library">S1. Catalog List + Search (<code>/library</code>)</a> <ul> <li><a href="library/06_Screen_Specifications.html#layout-portrait-phone">Layout (portrait phone)</a></li> <li><a href="library/06_Screen_Specifications.html#behavior">Behavior</a></li> <li><a href="library/06_Screen_Specifications.html#states-table">States table</a></li> </ul> </li> <li><a href="library/06_Screen_Specifications.html#s2-book-detail-librarybooksid">S2. Book Detail (<code>/library/books/:id</code>)</a> <ul> <li><a href="library/06_Screen_Specifications.html#layout">Layout</a></li> <li><a href="library/06_Screen_Specifications.html#behavior-1">Behavior</a></li> </ul> </li> <li><a href="library/06_Screen_Specifications.html#s3-book-form--create--edit-librarybooksnew-librarybooksidedit">S3. Book Form — Create / Edit (<code>/library/books/new</code>, <code>/library/books/:id/edit</code>)</a> <ul> <li><a href="library/06_Screen_Specifications.html#layout-1">Layout</a></li> <li><a href="library/06_Screen_Specifications.html#behavior-2">Behavior</a></li> </ul> </li> <li><a href="library/06_Screen_Specifications.html#s4-issue-form-librarybooksidissue--bottom-sheet">S4. Issue Form (<code>/library/books/:id/issue</code> — bottom sheet)</a> <ul> <li><a href="library/06_Screen_Specifications.html#layout-2">Layout</a></li> <li><a href="library/06_Screen_Specifications.html#behavior-3">Behavior</a></li> </ul> </li> <li><a href="library/06_Screen_Specifications.html#s5-borrowed-by-me--active--history-librarymy-borrows">S5. Borrowed-by-Me — Active / History (<code>/library/my-borrows</code>)</a> <ul> <li><a href="library/06_Screen_Specifications.html#layout-3">Layout</a></li> <li><a href="library/06_Screen_Specifications.html#behavior-4">Behavior</a></li> </ul> </li> <li><a href="library/06_Screen_Specifications.html#s6-return-form-librarymy-borrowsreturnrecordid--bottom-sheet">S6. Return Form (<code>/library/my-borrows/return/:recordId</code> — bottom sheet)</a> <ul> <li><a href="library/06_Screen_Specifications.html#layout-4">Layout</a></li> <li><a href="library/06_Screen_Specifications.html#behavior-5">Behavior</a></li> </ul> </li> <li><a href="library/06_Screen_Specifications.html#s7-overdue-view-libraryoverdue">S7. Overdue View (<code>/library/overdue</code>)</a> <ul> <li><a href="library/06_Screen_Specifications.html#layout-5">Layout</a></li> <li><a href="library/06_Screen_Specifications.html#behavior-6">Behavior</a></li> </ul> </li> </ul> <blockquote> <p>Full functional spec per screen: layout, states, copy, keyboard, a11y, motion, data bindings and error mapping. Wire contracts in <a href="library/12_API_Mapping.html">12_API_Mapping.md</a>; components in <a href="library/07_Component_Library.html">07_Component_Library.md</a>; Cubit behavior in <a href="library/13_State_Management.html">13_State_Management.md</a>.</p> </blockquote> <hr /> <h2 id="s1-catalog-list--search-library"><a class="header" href="#s1-catalog-list--search-library">S1. Catalog List + Search (<code>/library</code>)</a></h2> <h3 id="layout-portrait-phone"><a class="header" href="#layout-portrait-phone">Layout (portrait phone)</a></h3> <pre><code>┌───────────────────────────────────────────┐ │ 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 +] │ │ └───────────────────────────────────────────┘ </code></pre> <h3 id="behavior-1"><a class="header" href="#behavior-1">Behavior</a></h3> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Spec</th></tr></thead><tbody> <tr><td>Search</td><td><code>AppSearchBar</code>, debounce 300 ms → <code>q</code>; server regex title/author/ISBN (<code>book.repository.ts:26-30</code>); clearing returns full list (<code>library.service.ts:57-74</code>)</td></tr> <tr><td>Row</td><td><code>BookRow</code>: title (2 lines max), author · ISBN, right-aligned <code>availabilityBadge</code> (<code>$available/$totalCopies</code>) + <code>BookStatus</code> badge</td></tr> <tr><td>Pagination</td><td>infinite scroll, <code>page</code>/<code>limit=20</code>; <code>meta.hasNext</code> from <code>buildPaginationMeta</code> (<code>library.service.ts:75</code>) stops the loader</td></tr> <tr><td>Filters</td><td>chips filter <strong>in memory</strong> (server supports no status/category filter — OQ-5); combos: category, status, availability (<code>availableCopies > 0</code>)</td></tr> <tr><td>Empty</td><td>"No books found" + clear-search action; distinguish "no search results" vs "catalog empty"</td></tr> <tr><td>Error</td><td>offline → <code>AppOfflineBanner</code> + cached list; 429 → countdown banner; 5xx → error state + retry (requestId in snackbar)</td></tr> <tr><td>Permission</td><td>no <code>books.read</code> → empty state with lock copy (RBAC <code>(planned)</code>, OQ-1); no <code>books.create</code> → FAB hidden</td></tr> <tr><td>Refresh</td><td>pull-to-refresh → re-fetch page 1; focus-return also refreshes (issue/return may have changed availability)</td></tr> <tr><td>a11y</td><td>search focused on screen open (large catalogs); results announced via live region; row semantics = button</td></tr> <tr><td>Motion</td><td>list uses standard <code>AppListTile</code> transitions; search debounce uses <code>m-fast</code> progress shimmer <code>(00-shared/08)</code></td></tr> <tr><td>Analytics</td><td><code>library.catalog.search</code>, <code>library.catalog.open_book</code> (proposed)</td></tr> </tbody></table> </div> <h3 id="states-table"><a class="header" href="#states-table">States table</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>UI</th></tr></thead><tbody> <tr><td>idle</td><td>cached list or empty</td></tr> <tr><td>loading</td><td><code>AppSkeleton</code> rows (first load); shimmer under search (subsequent)</td></tr> <tr><td>empty</td><td><code>AppEmptyState</code> "No books found"</td></tr> <tr><td>error</td><td>error panel + retry; offline banner when no cache</td></tr> <tr><td>disabled</td><td>chips disabled while loading filters</td></tr> <tr><td>permission</td><td>lock empty-state (RBAC planned)</td></tr> </tbody></table> </div> <hr /> <h2 id="s2-book-detail-librarybooksid"><a class="header" href="#s2-book-detail-librarybooksid">S2. Book Detail (<code>/library/books/:id</code>)</a></h2> <h3 id="layout-6"><a class="header" href="#layout-6">Layout</a></h3> <pre><code>┌───────────────────────────────────────────┐ │ ← 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) │ └───────────────────────────────────────────┘ </code></pre> <h3 id="behavior-2"><a class="header" href="#behavior-2">Behavior</a></h3> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Spec</th></tr></thead><tbody> <tr><td>Load</td><td><code>GET /books/:id</code> (<code>library.controller.ts:43-47</code>); 404 → <code>AppEmptyState</code> "Book not found" + back (<code>library.service.ts:80</code>)</td></tr> <tr><td>Availability</td><td><code>availableCopies > 0</code> → issue enabled; <code>0</code> → disabled + copy "All copies are on loan" (issue is a server 409 too, <code>library.service.ts:109-111</code>)</td></tr> <tr><td>Status copy</td><td><code>damaged</code>/<code>lost</code> (<code>book.schema.ts:7-12</code>) → issue disabled + warning badge (no endpoint transitions to these — OQ-4)</td></tr> <tr><td>Edit</td><td>PATCH flow (S4); after save, detail re-fetches</td></tr> <tr><td>Delete</td><td>menu → <code>AppDialog</code> confirm → <code>DELETE /books/:id</code> (<code>library.controller.ts:55-59</code>); 409 "Cannot delete book with active borrow records." (<code>library.service.ts:99-101</code>) → dialog explains active loans; 200 → snackbar + pop to catalog (soft delete, <code>library.service.ts:103</code>)</td></tr> <tr><td>Edit copies</td><td>copy delta adjusts <code>availableCopies</code> (floored at 0) (<code>library.service.ts:87-90</code>) — surface "available will change by same amount" note</td></tr> <tr><td>Active loans</td><td><strong>not exposed by any endpoint</strong> — skip section <code>(planned)</code></td></tr> <tr><td>a11y</td><td>status badges read as full sentences ("3 of 5 copies available"); actions grouped</td></tr> <tr><td>Motion</td><td>hero transition from list row (cover/title) <code>m-base</code> <code>(00-shared/08)</code></td></tr> <tr><td>Analytics</td><td><code>library.book.open</code>, <code>library.book.issue_start</code>, <code>library.book.delete</code> (proposed)</td></tr> </tbody></table> </div> <hr /> <h2 id="s3-book-form--create--edit-librarybooksnew-librarybooksidedit"><a class="header" href="#s3-book-form--create--edit-librarybooksnew-librarybooksidedit">S3. Book Form — Create / Edit (<code>/library/books/new</code>, <code>/library/books/:id/edit</code>)</a></h2> <h3 id="layout-7"><a class="header" href="#layout-7">Layout</a></h3> <pre><code>┌───────────────────────────────────────────┐ │ ← New book / Edit book [Save] │ │ Title* ────────────── │ │ Author* ────────────── │ │ ISBN* ────────────── │ │ Publisher ────────────── │ │ Category ────────────── │ │ Edition ────────────── │ │ Copies [− 3 +] (create only*: total) │ │ Shelf ────────────── │ │ Description ────────────── │ │ (multiline, optional) │ └───────────────────────────────────────────┘ </code></pre> <h3 id="behavior-3"><a class="header" href="#behavior-3">Behavior</a></h3> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Spec</th></tr></thead><tbody> <tr><td>Fields</td><td><code>CreateBookDto</code> (<code>create-book.dto.ts:5-46</code>): title/author/isbn required strings; publisher/category/edition/shelfLocation/description optional; <code>totalCopies</code> optional number <code>@Min(1)</code> default 1 (<code>:32-36</code>); edit uses <code>UpdateBookDto = PartialType</code> (<code>update-book.dto.ts:4</code>) — all optional, copy-count editable</td></tr> <tr><td>Copies semantics</td><td>create: <code>availableCopies = totalCopies</code> (<code>library.service.ts:41-46</code>); edit: server shifts <code>availableCopies</code> by the delta, floor 0 (<code>library.service.ts:87-90</code>) — show the projected availability inline</td></tr> <tr><td>Validation</td><td>client mirrors server: required title/author/isbn; ISBN numeric-ish; copies ≥ 1; 400 maps <code>details[].message</code> → fields</td></tr> <tr><td>Conflict</td><td>409 duplicate ISBN (<code>library.service.ts:37-39</code>) → inline under ISBN: "A book with this ISBN already exists" + link to the existing book if client has it in cache</td></tr> <tr><td>Save</td><td>create <code>POST /books</code> → 201 → push detail; edit <code>PATCH /books/:id</code> → 200 → pop with updated doc; <strong>no optimistic writes</strong></td></tr> <tr><td>Draft</td><td>unsaved-changes guard on back; draft persist <code>(proposed)</code></td></tr> <tr><td>a11y</td><td>every field <code>labelFor</code>-linked; first invalid field focused on 400</td></tr> <tr><td>Analytics</td><td><code>library.book.create</code>, <code>library.book.update</code> (proposed)</td></tr> </tbody></table> </div> <hr /> <h2 id="s4-issue-form-librarybooksidissue--bottom-sheet"><a class="header" href="#s4-issue-form-librarybooksidissue--bottom-sheet">S4. Issue Form (<code>/library/books/:id/issue</code> — bottom sheet)</a></h2> <h3 id="layout-8"><a class="header" href="#layout-8">Layout</a></h3> <pre><code>┌───────────────────────────────────────────┐ │ Issue: "Book Title" [Confirm] │ │ Student* [picker ▾] (search + select) │ │ Due date* [date ▾] (default +14 days) │ │ Notes ────────────── │ │ ──────────────────────────────────────── │ │ Availability: 3 of 5 copies available │ └───────────────────────────────────────────┘ </code></pre> <h3 id="behavior-4"><a class="header" href="#behavior-4">Behavior</a></h3> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Spec</th></tr></thead><tbody> <tr><td>Student picker</td><td>from Students module profile state; free-text search, one selection; disabled while no student selected</td></tr> <tr><td>Due date</td><td><code>AppDateField</code>; must be future (client blocks past dates; server has no min — server accepts any date <code>issue-book.dto.ts:14-15</code>, so client must enforce)</td></tr> <tr><td>Submit</td><td><code>POST /books/issue {bookId, studentId, dueDate, notes?}</code> (<code>library.controller.ts:61-65</code>)</td></tr> <tr><td>201</td><td>record <code>status=ACTIVE, borrowedAt=now</code> (<code>library.service.ts:132-139</code>) → close sheet, snackbar "Issued to {student}", refresh detail + my-borrows</td></tr> <tr><td>409 no copies</td><td>banner "No copies available for borrowing." + re-fetch book (<code>library.service.ts:109-111</code>)</td></tr> <tr><td>409 max 5</td><td>banner "Student already has maximum number of borrowed books." + show their active count from cached my-borrows (<code>library.service.ts:116-120</code>)</td></tr> <tr><td>404</td><td>book deleted meanwhile → close sheet, pop detail with snackbar</td></tr> <tr><td>Busy</td><td>Confirm shows spinner; sheet not dismissible during submit (prevents double-tap — see QA-2)</td></tr> <tr><td>a11y</td><td>sheet is modal, focus first field; server messages read via live region</td></tr> <tr><td>Analytics</td><td><code>library.issue.submit</code>, <code>library.issue.conflict(kind)</code> (proposed)</td></tr> </tbody></table> </div> <hr /> <h2 id="s5-borrowed-by-me--active--history-librarymy-borrows"><a class="header" href="#s5-borrowed-by-me--active--history-librarymy-borrows">S5. Borrowed-by-Me — Active / History (<code>/library/my-borrows</code>)</a></h2> <h3 id="layout-9"><a class="header" href="#layout-9">Layout</a></h3> <pre><code>┌───────────────────────────────────────────┐ │ ← My borrows [overdue▾]│ │ [Active (2)] [History (11)] │ │ ──────────────────────────────────────── │ │ [Card] Book Title │ │ borrowed 12 Jul · due 26 Jul ← 4d [!!] │ │ fine 20 (pending) [Return] │ │ [Card] … │ └───────────────────────────────────────────┘ </code></pre> <h3 id="behavior-5"><a class="header" href="#behavior-5">Behavior</a></h3> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Spec</th></tr></thead><tbody> <tr><td>Tabs</td><td>Active = <code>GET /books/borrows/:studentId/active</code> (<code>library.controller.ts:79-83</code>); History = <code>GET /books/borrows/:studentId</code> (<code>library.controller.ts:73-77</code>) — includes returned + any active; not paginated (<code>library.service.ts:195-201</code>); client shows first N with "load more" <code>(proposed)</code></td></tr> <tr><td>Populate</td><td>repo populates <code>bookId</code> (<code>borrow-record.repository.ts:31,42</code>) → title/author render; <code>studentId</code> is <strong>not populated</strong> — student identity comes from the profile context (OQ-9)</td></tr> <tr><td>Due date</td><td>prominent on active cards; <code>overdueTag</code> when <code>active && now > dueDate</code> (derived, OQ-2); fine preview <code>ceil(days)×5</code> shown but marked "estimate"</td></tr> <tr><td>Fine chip</td><td><code>fineStatus == pending</code> → amber chip with amount + "Pay" (<code>fines.pay</code>); <code>paid</code> → green; <code>waived</code> → grey (enum <code>borrow-record.schema.ts:14-18</code>)</td></tr> <tr><td>Return</td><td>staff sees <code>[Return]</code> on active cards → Return Form (S6); student/parent do not</td></tr> <tr><td>History rows</td><td>returnedAt + final fineAmount; no actions</td></tr> <tr><td>Empty</td><td>active: "Nothing on loan right now"; history: "No past borrows"</td></tr> <tr><td>Refresh</td><td>pull-to-refresh re-fetches both tabs</td></tr> <tr><td>a11y</td><td>overdue announced ("overdue by 4 days"); fine chips full-sentence semantics</td></tr> <tr><td>Analytics</td><td><code>library.myborrows.open</code> (proposed)</td></tr> </tbody></table> </div> <hr /> <h2 id="s6-return-form-librarymy-borrowsreturnrecordid--bottom-sheet"><a class="header" href="#s6-return-form-librarymy-borrowsreturnrecordid--bottom-sheet">S6. Return Form (<code>/library/my-borrows/return/:recordId</code> — bottom sheet)</a></h2> <h3 id="layout-10"><a class="header" href="#layout-10">Layout</a></h3> <pre><code>┌───────────────────────────────────────────┐ │ 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) │ └───────────────────────────────────────────┘ </code></pre> <h3 id="behavior-6"><a class="header" href="#behavior-6">Behavior</a></h3> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Spec</th></tr></thead><tbody> <tr><td>Prefill</td><td>days overdue = <code>ceil((now − dueDate)/day)</code>; fine preview = <code>days × 5</code> — <strong>client mirrors server rule</strong> <code>library.service.ts:211-217</code>; server recomputes anyway (<code>:159</code>)</td></tr> <tr><td>Override</td><td><code>fineAmount?</code> editable <code>@Min(0)</code> (<code>return-book.dto.ts:9-13</code>) — server prefers <code>dto.fineAmount ?? calculateFine(dueDate)</code> (<code>library.service.ts:159</code>); keep override only for staff (hide for student/parent — they can't return anyway)</td></tr> <tr><td>Submit</td><td><code>POST /books/return {borrowRecordId, fineAmount?, notes?}</code> (<code>library.controller.ts:67-71</code>)</td></tr> <tr><td>200</td><td>record <code>status=RETURNED, returnedAt=now, fineAmount, fineStatus</code> = <code>pending</code> if fine > 0 else unset (<code>library.service.ts:161-169</code>); book <code>availableCopies+1</code>, status <code>available</code> (<code>library.service.ts:172-178</code>)</td></tr> <tr><td>409</td><td>"Book was not actively borrowed." (<code>library.service.ts:155-157</code>) → refresh record, banner "Already returned"</td></tr> <tr><td>Fine pay</td><td>if <code>fineAmount > 0</code> → inline secondary action "Pay fine" → confirm dialog → <code>POST /books/fines/:id/pay</code> (<code>library.controller.ts:85-89</code>) → chip → paid (<code>library.service.ts:203-209</code>)</td></tr> <tr><td>Busy</td><td>spinner on Confirm; no dismiss during submit</td></tr> <tr><td>a11y</td><td>fine amount + status live-regioned after submit</td></tr> <tr><td>Analytics</td><td><code>library.return.submit</code>, <code>library.fine.pay</code> (proposed)</td></tr> </tbody></table> </div> <hr /> <h2 id="s7-overdue-view-libraryoverdue"><a class="header" href="#s7-overdue-view-libraryoverdue">S7. Overdue View (<code>/library/overdue</code>)</a></h2> <h3 id="layout-11"><a class="header" href="#layout-11">Layout</a></h3> <pre><code>┌───────────────────────────────────────────┐ │ ← Overdue [refresh] │ │ ⚠ Derived from client data — server │ │ overdue scan planned │ │ [Card] Student · Book Title │ │ due 12 Jul · 15 days late · fine 75 │ │ [Return] │ └───────────────────────────────────────────┘ </code></pre> <h3 id="behavior-7"><a class="header" href="#behavior-7">Behavior</a></h3> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Spec</th></tr></thead><tbody> <tr><td>Data</td><td>derived by filtering active borrows where <code>now > dueDate</code> (per-student active lists; no list-all endpoint — OQ-2). Server <code>findOverdue()</code> exists but is unused (<code>borrow-record.repository.ts:34-43</code>); a dedicated endpoint + scheduled scan is <code>(planned)</code> (IMPLEMENTATION_PLAN.md:228)</td></tr> <tr><td>Honesty</td><td>persistent info banner: "Derived from client data — server overdue scan planned"</td></tr> <tr><td>Rows</td><td>student (from profile state), book title, dueDate, days late, fine preview <code>ceil(days)×5</code>, <code>[Return]</code> shortcut → S6</td></tr> <tr><td>Empty</td><td>"Nothing overdue"</td></tr> <tr><td>Refresh</td><td>re-fetches the underlying active lists</td></tr> <tr><td>Permission</td><td>staff only (<code>books.issue</code>/<code>books.return</code>); hidden otherwise</td></tr> <tr><td>Analytics</td><td><code>library.overdue.open</code> (proposed)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-library-module"><a class="header" href="#07--component-library-library-module">07 — Component Library (Library Module)</a></h1> <ul> <li><a href="library/07_Component_Library.html#1-availabilitybadge">1. <code>AvailabilityBadge</code></a></li> <li><a href="library/07_Component_Library.html#2-finechip">2. <code>FineChip</code></a></li> <li><a href="library/07_Component_Library.html#3-overduetag">3. <code>OverdueTag</code></a></li> <li><a href="library/07_Component_Library.html#4-copystepper">4. <code>CopyStepper</code></a></li> <li><a href="library/07_Component_Library.html#5-bookrow">5. <code>BookRow</code></a></li> <li><a href="library/07_Component_Library.html#6-borrowrecordcard">6. <code>BorrowRecordCard</code></a></li> <li><a href="library/07_Component_Library.html#7-studentpicker">7. <code>StudentPicker</code></a></li> <li><a href="library/07_Component_Library.html#8-duedatefield">8. <code>DueDateField</code></a></li> <li><a href="library/07_Component_Library.html#9-finesummarypanel">9. <code>FineSummaryPanel</code></a></li> <li><a href="library/07_Component_Library.html#shared-components-reused-verbatim-00-shared03">Shared components reused verbatim (00-shared/03)</a></li> <li><a href="library/07_Component_Library.html#component--screen-matrix">Component → screen matrix</a></li> </ul> <blockquote> <p>Module-specific widgets on top of <a href="library/../00-shared/03_Component_Library.html">00-shared/03</a>. Each lists the server binding and the shared components it composes. All are pure-presentational; state lives in the Cubits (<a href="library/13_State_Management.html">13_State_Management.md</a>).</p> </blockquote> <hr /> <h2 id="1-availabilitybadge"><a class="header" href="#1-availabilitybadge">1. <code>AvailabilityBadge</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>Show copy availability at a glance</td></tr> <tr><td>Inputs</td><td><code>availableCopies</code>, <code>totalCopies</code>, <code>status: BookStatus</code></td></tr> <tr><td>Renders</td><td><code>AppBadge</code>; color by state: <code>availableCopies > 0</code> → green "3/5"; <code>0</code> → red "0/5 — all on loan"; `status == damaged</td></tr> <tr><td>Source</td><td><code>Book.availableCopies/totalCopies/status</code> (<code>book.schema.ts:34-41</code>)</td></tr> <tr><td>Used in</td><td>S1 rows, S2 header, S4 sheet footer</td></tr> </tbody></table> </div> <h2 id="2-finechip"><a class="header" href="#2-finechip">2. <code>FineChip</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>Show fine amount + status on borrow records</td></tr> <tr><td>Inputs</td><td><code>fineAmount</code>, <code>fineStatus?: FineStatus</code></td></tr> <tr><td>Renders</td><td>amber "20 · pending" (with optional Pay action), green "20 · paid", grey "waived" (<code>borrow-record.schema.ts:14-18</code>); no chip when <code>fineStatus</code> is unset (on-time returns, <code>library.service.ts:166</code>)</td></tr> <tr><td>Source</td><td><code>BorrowRecord.fineAmount/fineStatus</code> (<code>borrow-record.schema.ts:47-54</code>)</td></tr> <tr><td>Used in</td><td>S5 cards, S6 result, S7 rows</td></tr> </tbody></table> </div> <h2 id="3-overduetag"><a class="header" href="#3-overduetag">3. <code>OverdueTag</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>Flag a late loan</td></tr> <tr><td>Inputs</td><td><code>dueDate</code>, <code>borrowStatus</code></td></tr> <tr><td>Logic</td><td>visible iff <code>borrowStatus == 'active' && now > dueDate</code> — <strong>client-derived</strong>; server never writes <code>BorrowStatus.OVERDUE</code> today (OQ-2, <code>borrow-record.schema.ts:9-11</code>)</td></tr> <tr><td>Renders</td><td>red <code>AppBadge</code> "Overdue by N d" (+ optional fine estimate)</td></tr> <tr><td>a11y</td><td>semantics "overdue by N days"</td></tr> <tr><td>Used in</td><td>S5, S7</td></tr> </tbody></table> </div> <h2 id="4-copystepper"><a class="header" href="#4-copystepper">4. <code>CopyStepper</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>Adjust <code>totalCopies</code> in the book form</td></tr> <tr><td>Inputs</td><td><code>totalCopies</code>, <code>availableCopies</code>, `mode: create</td></tr> <tr><td>Renders</td><td><code>[−] 3 [+]</code> with live projection: create → <code>available = total</code> (<code>library.service.ts:41-46</code>); edit → <code>available = max(0, available + Δ)</code> (<code>library.service.ts:87-90</code>)</td></tr> <tr><td>Validation</td><td><code>@Min(1)</code> mirrors <code>create-book.dto.ts:34-35</code>; floor 0 on available</td></tr> <tr><td>Used in</td><td>S3</td></tr> </tbody></table> </div> <h2 id="5-bookrow"><a class="header" href="#5-bookrow">5. <code>BookRow</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>Catalog list row</td></tr> <tr><td>Composition</td><td>cover thumb (or letter avatar), title (max 2 lines), "author · ISBN", <code>AvailabilityBadge</code> + status badge</td></tr> <tr><td>Source</td><td><code>Book</code> doc (<code>book.schema.ts:15-48</code>)</td></tr> <tr><td>Used in</td><td>S1, S7 (with student line)</td></tr> </tbody></table> </div> <h2 id="6-borrowrecordcard"><a class="header" href="#6-borrowrecordcard">6. <code>BorrowRecordCard</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>One loan on the Borrowed-by-Me / Overdue screens</td></tr> <tr><td>Composition</td><td><code>AppCard</code>: book title (populated <code>bookId</code>, <code>borrow-record.repository.ts:31,42</code>), borrowedAt, dueDate (prominent), <code>OverdueTag</code>, <code>FineChip</code>, actions <code>[Return]</code> (staff) / <code>[Pay]</code></td></tr> <tr><td>Source</td><td><code>BorrowRecord</code> doc (<code>borrow-record.schema.ts:20-58</code>)</td></tr> <tr><td>Used in</td><td>S5, S6 trigger, S7</td></tr> </tbody></table> </div> <h2 id="7-studentpicker"><a class="header" href="#7-studentpicker">7. <code>StudentPicker</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>Select a student when issuing</td></tr> <tr><td>Composition</td><td><code>AppPicker</code> over the Students profile cache; free-text search, single select</td></tr> <tr><td>Source</td><td>Students module state (id → <code>studentId</code> in <code>IssueBookDto</code>, <code>issue-book.dto.ts:7-8</code>); no dedicated picker endpoint</td></tr> <tr><td>Used in</td><td>S4</td></tr> </tbody></table> </div> <h2 id="8-duedatefield"><a class="header" href="#8-duedatefield">8. <code>DueDateField</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>Pick a due date for issue</td></tr> <tr><td>Composition</td><td><code>AppDateField</code> (native date picker); client enforces future dates (server accepts any <code>IsDateString</code> — <code>issue-book.dto.ts:14-15</code>); default +14 days</td></tr> <tr><td>Used in</td><td>S4, S6 (read-only display)</td></tr> </tbody></table> </div> <h2 id="9-finesummarypanel"><a class="header" href="#9-finesummarypanel">9. <code>FineSummaryPanel</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>Return-form fine computation explainer</td></tr> <tr><td>Composition</td><td>"Days overdue: N → N × 5/day = 20" plus editable <code>fineAmount</code> (staff only) and note "server recomputes at return"</td></tr> <tr><td>Source</td><td>rule <code>library.service.ts:211-217</code>, override <code>return-book.dto.ts:9-13</code></td></tr> <tr><td>Used in</td><td>S6</td></tr> </tbody></table> </div> <hr /> <h2 id="shared-components-reused-verbatim-00-shared03"><a class="header" href="#shared-components-reused-verbatim-00-shared03">Shared components reused verbatim (<a href="library/../00-shared/03_Component_Library.html">00-shared/03</a>)</a></h2> <p><code>AppButton</code>, <code>AppTextField</code>, <code>AppSnackbar</code>, <code>AppCard</code>, <code>AppListTile</code>, <code>AppBottomSheet</code>, <code>AppDialog</code>, <code>AppMenu</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppOfflineBanner</code>, <code>AppFAB</code>, <code>AppBadge</code>, <code>AppChips</code>, <code>AppSearchBar</code>, <code>AppAvatar</code>, <code>AppDateField</code>, <code>AppPicker</code>.</p> <h2 id="component--screen-matrix"><a class="header" href="#component--screen-matrix">Component → screen matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th>S1</th><th>S2</th><th>S3</th><th>S4</th><th>S5</th><th>S6</th><th>S7</th></tr></thead><tbody> <tr><td>AvailabilityBadge</td><td>✓</td><td>✓</td><td></td><td>✓</td><td></td><td></td><td></td></tr> <tr><td>FineChip</td><td></td><td></td><td></td><td></td><td>✓</td><td>✓</td><td>✓</td></tr> <tr><td>OverdueTag</td><td></td><td></td><td></td><td></td><td>✓</td><td>✓</td><td>✓</td></tr> <tr><td>CopyStepper</td><td></td><td></td><td>✓</td><td></td><td></td><td></td><td></td></tr> <tr><td>BookRow</td><td>✓</td><td></td><td></td><td></td><td></td><td></td><td>✓</td></tr> <tr><td>BorrowRecordCard</td><td></td><td></td><td></td><td></td><td>✓</td><td>✓</td><td>✓</td></tr> <tr><td>StudentPicker</td><td></td><td></td><td></td><td>✓</td><td></td><td></td><td></td></tr> <tr><td>DueDateField</td><td></td><td></td><td></td><td>✓</td><td></td><td></td><td></td></tr> <tr><td>FineSummaryPanel</td><td></td><td></td><td></td><td></td><td></td><td>✓</td><td></td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-library-module"><a class="header" href="#08--form-specifications-library-module">08 — Form Specifications (Library Module)</a></h1> <ul> <li><a href="library/08_Form_Specifications.html#f1-book-form-create--edit">F1. Book form (create / edit)</a></li> <li><a href="library/08_Form_Specifications.html#f2-issue-form">F2. Issue form</a></li> <li><a href="library/08_Form_Specifications.html#f3-return-form">F3. Return form</a></li> <li><a href="library/08_Form_Specifications.html#f4-validation--error-ux-all-forms">F4. Validation & error UX (all forms)</a></li> </ul> <blockquote> <p>Field-level contracts for the three forms. Server validation is authoritative; client validation mirrors it for latency only. DTO sources: <code>create-book.dto.ts</code>, <code>update-book.dto.ts</code>, <code>issue-book.dto.ts</code>, <code>return-book.dto.ts</code>.</p> </blockquote> <hr /> <h2 id="f1-book-form-create--edit"><a class="header" href="#f1-book-form-create--edit">F1. Book form (create / edit)</a></h2> <p><strong>Create body = <code>CreateBookDto</code> (<code>create-book.dto.ts:4-47</code>); edit body = <code>UpdateBookDto</code> = <code>PartialType(CreateBookDto)</code> (<code>update-book.dto.ts:4</code>) — every field optional on edit.</strong></p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type</th><th>Required</th><th>Constraints (server)</th><th>Notes</th></tr></thead><tbody> <tr><td><code>title</code></td><td>string</td><td>✓</td><td><code>@IsString</code> (<code>:6-7</code>)</td><td>trimmed server-side (<code>book.schema.ts:16-17</code>)</td></tr> <tr><td><code>author</code></td><td>string</td><td>✓</td><td><code>@IsString</code> (<code>:10-11</code>)</td><td>trimmed (<code>book.schema.ts:19-20</code>)</td></tr> <tr><td><code>isbn</code></td><td>string</td><td>✓</td><td><code>@IsString</code> (<code>:14-15</code>)</td><td>trimmed; <strong>unique per tenant</strong> — 409 on duplicate (<code>library.service.ts:35-40</code>, <code>book.schema.ts:51</code>)</td></tr> <tr><td><code>publisher</code></td><td>string</td><td>–</td><td><code>@IsOptional @IsString</code> (<code>:17-20</code>)</td><td></td></tr> <tr><td><code>category</code></td><td>string</td><td>–</td><td><code>@IsOptional @IsString</code> (<code>:22-25</code>)</td><td>client filter chip source (OQ-5)</td></tr> <tr><td><code>edition</code></td><td>string</td><td>–</td><td><code>@IsOptional @IsString</code> (<code>:27-30</code>)</td><td></td></tr> <tr><td><code>totalCopies</code></td><td>number</td><td>–</td><td><code>@IsOptional @IsNumber @Min(1)</code> (<code>:32-36</code>)</td><td>default 1 (<code>library.service.ts:41</code>); create sets <code>availableCopies = totalCopies</code> (<code>:44-46</code>); edit shifts <code>availableCopies</code> by Δ, floor 0 (<code>library.service.ts:87-90</code>)</td></tr> <tr><td><code>shelfLocation</code></td><td>string</td><td>–</td><td><code>@IsOptional @IsString</code> (<code>:38-41</code>)</td><td></td></tr> <tr><td><code>description</code></td><td>string</td><td>–</td><td><code>@IsOptional @IsString</code> (<code>:43-46</code>)</td><td></td></tr> </tbody></table> </div> <p><strong>Validation order on submit:</strong> required → type → ISBN uniqueness hint (client may pre-check from cache, but the server 409 is authoritative).</p> <p><strong>Copy projection widget:</strong> create → "Available will equal total (N)"; edit → "Available: 2 → 3" using the Δ rule.</p> <hr /> <h2 id="f2-issue-form"><a class="header" href="#f2-issue-form">F2. Issue form</a></h2> <p><strong>Body = <code>IssueBookDto</code> (<code>issue-book.dto.ts:4-21</code>).</strong></p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type</th><th>Required</th><th>Constraints (server)</th><th>Notes</th></tr></thead><tbody> <tr><td><code>bookId</code></td><td>string</td><td>✓</td><td><code>@IsString</code> (<code>:6-7</code>)</td><td>from Book Detail context; 404 if unknown (<code>library.service.ts:108</code>)</td></tr> <tr><td><code>studentId</code></td><td>string</td><td>✓</td><td><code>@IsString</code> (<code>:9-10</code>)</td><td>from <code>StudentPicker</code>; ref Student (<code>borrow-record.schema.ts:25-26</code>)</td></tr> <tr><td><code>dueDate</code></td><td>string (ISO)</td><td>✓</td><td><code>@IsDateString</code> (<code>:14-15</code>)</td><td><strong>no server min-date rule</strong> — client must require future dates; <code>new Date(dueDate)</code> stored (<code>library.service.ts:135</code>)</td></tr> <tr><td><code>notes</code></td><td>string</td><td>–</td><td><code>@IsOptional @IsString</code> (<code>:17-20</code>)</td><td>stored on record (<code>library.service.ts:138</code>)</td></tr> </tbody></table> </div> <p><strong>Server-side gates (client shows the same copy pre-submit where possible):</strong></p> <div class="table-wrapper"><table><thead><tr><th>Gate</th><th>Condition</th><th>Error</th></tr></thead><tbody> <tr><td>Availability</td><td><code>book.availableCopies < 1</code></td><td>409 "No copies available for borrowing." (<code>library.service.ts:109-111</code>)</td></tr> <tr><td>Student cap</td><td>active borrows ≥ 5</td><td>409 "Student already has maximum number of borrowed books." (<code>library.service.ts:116-120</code>)</td></tr> </tbody></table> </div> <p><strong>Defaults:</strong> due date = today + 14 days (client default only; no server default — <code>dueDate</code> required).</p> <p><strong>On success:</strong> record created with <code>status: ACTIVE</code>, <code>borrowedAt: now</code> (<code>library.service.ts:132-139</code>); book <code>availableCopies − 1</code>, <code>status → borrowed</code> when 0 remain (<code>library.service.ts:122-130</code>).</p> <hr /> <h2 id="f3-return-form"><a class="header" href="#f3-return-form">F3. Return form</a></h2> <p><strong>Body = <code>ReturnBookDto</code> (<code>return-book.dto.ts:4-19</code>).</strong></p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type</th><th>Required</th><th>Constraints (server)</th><th>Notes</th></tr></thead><tbody> <tr><td><code>borrowRecordId</code></td><td>string</td><td>✓</td><td><code>@IsString</code> (<code>:6-7</code>)</td><td>404 if unknown (<code>library.service.ts:153-154</code>); 409 if not <code>ACTIVE</code> (<code>library.service.ts:155-157</code>)</td></tr> <tr><td><code>fineAmount</code></td><td>number</td><td>–</td><td><code>@IsOptional @IsNumber @Min(0)</code> (<code>:9-13</code>)</td><td><strong>staff override</strong>: server prefers <code>dto.fineAmount ?? calculateFine(dueDate)</code> (<code>library.service.ts:159</code>); 0 is legal (on-time or waived-by-zero)</td></tr> <tr><td><code>notes</code></td><td>string</td><td>–</td><td><code>@IsOptional @IsString</code> (<code>:15-18</code>)</td><td>replaces record notes on return (<code>library.service.ts:167</code>)</td></tr> </tbody></table> </div> <p><strong>Fine computation (server, authoritative):</strong></p> <pre><code>calculateFine(dueDate): 0 if now <= dueDate; else ceil((now - dueDate) / 1 day) × 5 (library.service.ts:211-217; DAILY_FINE_RATE = 5 at :25) </code></pre> <p><strong>On success:</strong> <code>status → RETURNED</code>, <code>returnedAt: now</code>, <code>fineAmount</code> set, <code>fineStatus → pending</code> iff <code>fineAmount > 0</code> (else left unset) (<code>library.service.ts:161-169</code>); book <code>availableCopies + 1</code>, <code>status → available</code> (<code>library.service.ts:172-178</code>).</p> <p><strong>Post-return follow-up:</strong> if <code>fineAmount > 0</code>, offer "Pay fine" → <code>POST /books/fines/:borrowRecordId/pay</code> (no body — <code>library.controller.ts:85-89</code>) → <code>fineStatus: PAID</code> (<code>library.service.ts:203-209</code>). Server does not guard pay state (OQ-6): client only offers it on <code>fineStatus == pending</code>.</p> <hr /> <h2 id="f4-validation--error-ux-all-forms"><a class="header" href="#f4-validation--error-ux-all-forms">F4. Validation & error UX (all forms)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Code</th><th>Meaning</th><th>UI</th></tr></thead><tbody> <tr><td>400 <code>VALIDATION_ERROR</code></td><td><code>details[].message</code> per field</td><td>inline field errors, focus first invalid</td></tr> <tr><td>409 <code>DUPLICATE_RESOURCE</code></td><td>ISBN dup / no copies / cap / not-active</td><td>banner + contextual action (edit ISBN / refresh availability)</td></tr> <tr><td>404 <code>RESOURCE_NOT_FOUND</code></td><td>book/record gone</td><td>close form, snackbar</td></tr> <tr><td>429 <code>RATE_LIMITED</code></td><td>api tier</td><td>countdown, no auto-retry</td></tr> <tr><td>5xx</td><td>server</td><td>generic + requestId, manual retry</td></tr> </tbody></table> </div> <p>Envelope + codes: <code>response-envelope.interceptor.ts:44-52</code>, <code>http-exception.filter.ts:28-34,74-78</code>.</p> <p><strong>No optimistic writes in any form</strong> — every submit awaits the server response (<code>00-shared/07 §9</code>).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-library-module"><a class="header" href="#09--user-behaviour-library-module">09 — User Behaviour (Library Module)</a></h1> <ul> <li><a href="library/09_User_Behaviour.html#1-the-desk-clerk-needs-speed-not-romance">1. The desk clerk needs speed, not romance</a></li> <li><a href="library/09_User_Behaviour.html#2-availability-is-the-first-question">2. Availability is the first question</a></li> <li><a href="library/09_User_Behaviour.html#3-due-dates-and-fines-are-anxiety-triggers">3. Due dates and fines are anxiety triggers</a></li> <li><a href="library/09_User_Behaviour.html#4-staff-override-student-observes">4. Staff override, student observes</a></li> <li><a href="library/09_User_Behaviour.html#5-search-fatigue">5. Search fatigue</a></li> <li><a href="library/09_User_Behaviour.html#6-the-overdue-sweep-is-aspirational">6. The overdue sweep is aspirational</a></li> <li><a href="library/09_User_Behaviour.html#7-mistakes-must-be-reversible-or-blocked">7. Mistakes must be reversible or blocked</a></li> <li><a href="library/09_User_Behaviour.html#8-numbers-speak-plainly">8. Numbers speak plainly</a></li> <li><a href="library/09_User_Behaviour.html#9-identity-comes-from-context">9. Identity comes from context</a></li> <li><a href="library/09_User_Behaviour.html#10-concurrency-realities-behavioural-caution">10. Concurrency realities (behavioural caution)</a></li> </ul> <blockquote> <p>Observed/expected behaviour patterns and the design decisions they drive. References the personas in <a href="library/02_User_Personas.html">02_User_Personas.md</a>.</p> </blockquote> <hr /> <h2 id="1-the-desk-clerk-needs-speed-not-romance"><a class="header" href="#1-the-desk-clerk-needs-speed-not-romance">1. The desk clerk needs speed, not romance</a></h2> <ul> <li>Librarians transact all day: issue → return → next. Every form must open in ≤ 2 taps and prefill aggressively (due date +14 d, student last-used <code>(proposed)</code>).</li> <li>Confirmation dialogs allowed only on <strong>destructive/irreversible</strong> actions (delete, fine pay); issue/return get a single confirm in-form button with spinner (<code>06 S4/S6</code>).</li> <li>Resulting rule: <strong>write paths are server-confirmed</strong> (<a href="library/../00-shared/07_API_Conventions.html">00-shared/07 §9</a>); the spinner is the confirmation.</li> </ul> <h2 id="2-availability-is-the-first-question"><a class="header" href="#2-availability-is-the-first-question">2. Availability is the first question</a></h2> <ul> <li>Students ask "is it in?" before "what is it?". Every catalog row shows <code>AvailabilityBadge</code> (<a href="library/07_Component_Library.html">07 §1</a>); Book Detail's primary CTA is Issue, disabled when <code>availableCopies == 0</code> (<code>library.service.ts:109-111</code>).</li> <li>When a copy's availability changes elsewhere (another return), the client re-fetches on focus — no stale "available" claims.</li> </ul> <h2 id="3-due-dates-and-fines-are-anxiety-triggers"><a class="header" href="#3-due-dates-and-fines-are-anxiety-triggers">3. Due dates and fines are anxiety triggers</a></h2> <ul> <li>Active loans show dueDate as the dominant field, with days remaining (<code>S5</code>); overdue gets the red <code>OverdueTag</code> + fine estimate.</li> <li>Fine estimates are explicitly labeled "estimate — server computes at return" because the server is authoritative (<code>library.service.ts:211-217</code>) and the client clock may differ.</li> <li>Never hide a fine: <code>FineChip</code> renders <code>pending|paid|waived</code> from <code>borrow-record.schema.ts:14-18</code>; absence of chip = on-time return.</li> </ul> <h2 id="4-staff-override-student-observes"><a class="header" href="#4-staff-override-student-observes">4. Staff override, student observes</a></h2> <ul> <li><code>fineAmount</code> override (<code>return-book.dto.ts:9-13</code>) is staff-only UI; students/parents see the server value only.</li> <li>Pay action (<code>fines.pay</code>, <code>permissions.constants.ts:61</code>) appears only on <code>fineStatus == pending</code> — the server doesn't guard it (OQ-6), so the client does.</li> </ul> <h2 id="5-search-fatigue"><a class="header" href="#5-search-fatigue">5. Search fatigue</a></h2> <ul> <li>Server search is title/author/ISBN regex (<code>book.repository.ts:26-30</code>) — students type partial strings, so debounce 300 ms and never require exact matches.</li> <li>Category/status/availability filters are <strong>client-side</strong> (OQ-5); chips must not imply server filtering.</li> </ul> <h2 id="6-the-overdue-sweep-is-aspirational"><a class="header" href="#6-the-overdue-sweep-is-aspirational">6. The overdue sweep is aspirational</a></h2> <ul> <li>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 <code>(planned)</code> (IMPLEMENTATION_PLAN.md:228).</li> </ul> <h2 id="7-mistakes-must-be-reversible-or-blocked"><a class="header" href="#7-mistakes-must-be-reversible-or-blocked">7. Mistakes must be reversible or blocked</a></h2> <ul> <li>Delete blocked while active loans exist (409, <code>library.service.ts:97-102</code>) — the dialog says why.</li> <li>Double-issue prevention: form buttons lock during submit; the server 409s when the cap is hit (<code>library.service.ts:109-111,116-120</code>) — see QA-2/QA-3.</li> <li>No undo for issue/return (state is transactional); corrections go through the record's lifecycle (return then re-issue).</li> </ul> <h2 id="8-numbers-speak-plainly"><a class="header" href="#8-numbers-speak-plainly">8. Numbers speak plainly</a></h2> <ul> <li>Fines are plain units (rate 5/day, <code>library.service.ts:25</code>); no currency symbol invented — render "20" with label "units" until a currency contract exists (OQ-1 in <a href="library/01_Product_Overview.html">01_Product_Overview.md</a>).</li> </ul> <h2 id="9-identity-comes-from-context"><a class="header" href="#9-identity-comes-from-context">9. Identity comes from context</a></h2> <ul> <li>Borrows are keyed by <code>studentId</code> (<code>borrow-record.schema.ts:25-26</code>); 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.</li> </ul> <h2 id="10-concurrency-realities-behavioural-caution"><a class="header" href="#10-concurrency-realities-behavioural-caution">10. Concurrency realities (behavioural caution)</a></h2> <ul> <li>Issue/return are read-then-write (<code>library.service.ts:108-139,153-178</code>) — 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 <a href="library/14_QA_Checklist.html">14_QA_Checklist.md</a>.)</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-library-module"><a class="header" href="#10--interaction-specification-library-module">10 — Interaction Specification (Library Module)</a></h1> <ul> <li><a href="library/10_Interaction_Specification.html#1-global-interaction-rules">1. Global interaction rules</a></li> <li><a href="library/10_Interaction_Specification.html#2-screen-level-interactions">2. Screen-level interactions</a> <ul> <li><a href="library/10_Interaction_Specification.html#s1-catalog-list">S1 Catalog List</a></li> <li><a href="library/10_Interaction_Specification.html#s2-book-detail">S2 Book Detail</a></li> <li><a href="library/10_Interaction_Specification.html#s3-book-form">S3 Book Form</a></li> <li><a href="library/10_Interaction_Specification.html#s4-issue-sheet">S4 Issue Sheet</a></li> <li><a href="library/10_Interaction_Specification.html#s5-borrowed-by-me">S5 Borrowed-by-Me</a></li> <li><a href="library/10_Interaction_Specification.html#s6-return-sheet">S6 Return Sheet</a></li> <li><a href="library/10_Interaction_Specification.html#s7-overdue-view">S7 Overdue View</a></li> </ul> </li> <li><a href="library/10_Interaction_Specification.html#3-motion-summary">3. Motion summary</a></li> <li><a href="library/10_Interaction_Specification.html#4-keyboard--adaptive">4. Keyboard & adaptive</a></li> <li><a href="library/10_Interaction_Specification.html#5-a11y-module">5. a11y (module)</a></li> <li><a href="library/10_Interaction_Specification.html#6-analytics-proposed">6. Analytics (proposed)</a></li> </ul> <blockquote> <p>Micro-interactions, motion, feedback and platform behavior per screen/component. Timing tokens (<code>m-fast</code>/<code>m-base</code>/<code>m-slow</code>), a11y and adaptive rules come from <a href="library/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a> and <a href="library/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>; this file lists module specifics only.</p> </blockquote> <hr /> <h2 id="1-global-interaction-rules"><a class="header" href="#1-global-interaction-rules">1. Global interaction rules</a></h2> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Detail</th></tr></thead><tbody> <tr><td>Write feedback</td><td>every write (create/update/issue/return/pay) ends in a snackbar with the server's <code>message</code>; failures show the error code + <code>requestId</code></td></tr> <tr><td>No optimistic writes</td><td>all mutations await server; no rollback UI (nothing to roll back)</td></tr> <tr><td>Double-tap guard</td><td>every submit button shows a spinner and is disabled while in flight; sheets not dismissible during submit</td></tr> <tr><td>409 handling</td><td>never auto-retry; always refresh the affected entity and re-render</td></tr> <tr><td>Pull-to-refresh</td><td>catalog + borrows screens; bypasses cache TTL</td></tr> <tr><td>Focus refresh</td><td>returning to any screen re-fetches (availability changes elsewhere)</td></tr> <tr><td>429</td><td>countdown banner; no silent retry</td></tr> <tr><td>Offline</td><td>reads from cache (TTL below); writes blocked with offline banner — <strong>no offline queue</strong> for library</td></tr> </tbody></table> </div> <h2 id="2-screen-level-interactions"><a class="header" href="#2-screen-level-interactions">2. Screen-level interactions</a></h2> <h3 id="s1-catalog-list"><a class="header" href="#s1-catalog-list">S1 Catalog List</a></h3> <ul> <li>Search debounce 300 ms; typing triggers shimmer under the search bar; results swap in with <code>m-fast</code> fade; clearing the query re-fetches page 1.</li> <li>Infinite scroll: loader row appears when <code>meta.hasNext</code> (<code>buildPaginationMeta</code>, <code>library.service.ts:75</code>); failure at page N → inline "Retry" chip.</li> <li>Chip filter changes re-filter the <strong>current</strong> list in memory (no server call — OQ-5); chips show active state.</li> <li>Row tap → hero transition to S2 (<code>m-base</code>).</li> </ul> <h3 id="s2-book-detail"><a class="header" href="#s2-book-detail">S2 Book Detail</a></h3> <ul> <li>Menu: Edit / Delete. Delete → <code>AppDialog</code> (destructive tone, "Delete book?") → <code>DELETE /books/:id</code> (<code>library.controller.ts:55-59</code>); 409 → dialog swaps copy to "This book still has N active loans" (count unavailable client-side — generic server message shown verbatim).</li> <li>Issue CTA enabled iff <code>availableCopies >= 1</code>; disabled state explains why ("All copies on loan" / "Book marked damaged").</li> <li>After issue/return elsewhere: focus refresh.</li> </ul> <h3 id="s3-book-form"><a class="header" href="#s3-book-form">S3 Book Form</a></h3> <ul> <li>Live <code>CopyStepper</code> projection (create: <code>available = total</code>; edit: <code>available + Δ</code>, floor 0 — <code>library.service.ts:41-46,87-90</code>).</li> <li>Back with dirty form → unsaved-changes dialog.</li> <li>409 duplicate ISBN → inline error + (if cached) link to existing book.</li> </ul> <h3 id="s4-issue-sheet"><a class="header" href="#s4-issue-sheet">S4 Issue Sheet</a></h3> <ul> <li><code>StudentPicker</code> search-as-you-type; selection locks the row; due date <code>AppDateField</code> defaults +14 d, min = tomorrow (client rule).</li> <li>Submit → spinner on Confirm; success → sheet closes with check animation; the detail behind it refreshes to the new availability.</li> <li>409 no-copies → banner + detail re-fetches; 409 cap → banner shows the student's current active count.</li> </ul> <h3 id="s5-borrowed-by-me"><a class="header" href="#s5-borrowed-by-me">S5 Borrowed-by-Me</a></h3> <ul> <li>Tabs (Active/History) keep separate scroll positions; badge counts on tabs.</li> <li><code>OverdueTag</code> animates in (<code>m-fast</code> pop) when a card crosses <code>now > dueDate</code> — computed on ticker, refreshed on any re-fetch (derived; OQ-2).</li> <li>Row → optional detail sheet (record fields verbatim: borrowedAt, dueDate, returnedAt?, fineAmount, fineStatus?, notes?).</li> <li>Staff rows: <code>[Return]</code> button → S6 sheet; <code>[Pay]</code> on pending fines → confirm dialog.</li> </ul> <h3 id="s6-return-sheet"><a class="header" href="#s6-return-sheet">S6 Return Sheet</a></h3> <ul> <li>Opens prefilled from the record; fine panel computes <code>days = ceil((now − dueDate)/day)</code> and <code>days × 5</code> live (mirrors <code>library.service.ts:211-217</code>); staff may edit <code>fineAmount</code> (<code>@Min(0)</code>, <code>return-book.dto.ts:9-13</code>).</li> <li>Submit → spinner → success snackbar "Returned · fine 20 pending" → chips update in the list behind.</li> <li>If fine > 0 → inline secondary "Pay fine" → confirm dialog → pay endpoint (<code>library.controller.ts:85-89</code>) → chip flips to paid.</li> <li>409 "Book was not actively borrowed." → banner + record refresh.</li> </ul> <h3 id="s7-overdue-view"><a class="header" href="#s7-overdue-view">S7 Overdue View</a></h3> <ul> <li>Persistent info banner (server scan planned — IMPLEMENTATION_PLAN.md:228); list derived from active borrows; <code>[Return]</code> reuses S6; empty state "Nothing overdue".</li> </ul> <h2 id="3-motion-summary"><a class="header" href="#3-motion-summary">3. Motion summary</a></h2> <div class="table-wrapper"><table><thead><tr><th>Transition</th><th>Token</th><th>Trigger</th></tr></thead><tbody> <tr><td>List → detail</td><td><code>m-base</code> hero</td><td>row tap</td></tr> <tr><td>Sheet present (issue/return)</td><td><code>m-base</code> slide-up</td><td>CTA tap</td></tr> <tr><td>Availability badge change</td><td><code>m-fast</code> fade</td><td>after issue/return refresh</td></tr> <tr><td>Overdue tag appear</td><td><code>m-fast</code> pop</td><td>ticker crosses due date</td></tr> <tr><td>Snackbar</td><td><code>m-fast</code></td><td>all writes</td></tr> <tr><td>Dialog</td><td><code>m-base</code></td><td>delete / pay confirm</td></tr> </tbody></table> </div> <h2 id="4-keyboard--adaptive"><a class="header" href="#4-keyboard--adaptive">4. Keyboard & adaptive</a></h2> <ul> <li>Forms: keyboard avoidance + <code>resizeToAvoidBottomInset</code>; fields advance via <code>onSubmit</code> (title → author → isbn → …).</li> <li>Date fields: native pickers, no manual typing.</li> <li>Phone: sheets for issue/return; tablet/desktop: centered dialogs ≤ 480 dp and master-detail for catalog (list left, detail right).</li> </ul> <h2 id="5-a11y-module"><a class="header" href="#5-a11y-module">5. a11y (module)</a></h2> <ul> <li><code>AvailabilityBadge</code>/<code>OverdueTag</code>/<code>FineChip</code> expose full-sentence semantics.</li> <li>Server 409/404 messages announced via live region (never just color).</li> <li>All interactive rows are buttons with visible focus.</li> <li>Spinner states announced ("Submitting issue…").</li> </ul> <h2 id="6-analytics-proposed"><a class="header" href="#6-analytics-proposed">6. Analytics (proposed)</a></h2> <p><code>library.catalog.search</code>, <code>library.book.open</code>, <code>library.issue.submit|conflict</code>, <code>library.return.submit|conflict</code>, <code>library.fine.pay</code>, <code>library.overdue.open</code> — naming per <a href="library/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a> (SDK open).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-library-module"><a class="header" href="#11--design-system-mapping-library-module">11 — Design System Mapping (Library Module)</a></h1> <ul> <li><a href="library/11_Design_System_Mapping.html#1-semantic-status--token-mapping">1. Semantic status → token mapping</a></li> <li><a href="library/11_Design_System_Mapping.html#2-typography-mapping">2. Typography mapping</a></li> <li><a href="library/11_Design_System_Mapping.html#3-spacing--shape--elevation">3. Spacing / shape / elevation</a></li> <li><a href="library/11_Design_System_Mapping.html#4-iconography">4. Iconography</a></li> <li><a href="library/11_Design_System_Mapping.html#5-component-instance-map-00-shared03">5. Component instance map (00-shared/03)</a></li> <li><a href="library/11_Design_System_Mapping.html#6-theming-notes">6. Theming notes</a></li> </ul> <blockquote> <p>How the module maps to tokens and components of <a href="library/../00-shared/02_Design_Tokens.html">00-shared/02</a> (tokens), <a href="library/../00-shared/03_Component_Library.html">00-shared/03</a> (components), <a href="library/../00-shared/04_Design_System_Mapping.html">00-shared/04</a> (system). No new tokens are invented; status colors are the only semantic additions and they bind to existing palette semantics (success/warning/error/neutral).</p> </blockquote> <hr /> <h2 id="1-semantic-status--token-mapping"><a class="header" href="#1-semantic-status--token-mapping">1. Semantic status → token mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Domain state</th><th>Semantics</th><th>Token class</th><th>Used on</th></tr></thead><tbody> <tr><td><code>BookStatus.available</code></td><td>success</td><td><code>color-success</code></td><td>AvailabilityBadge</td></tr> <tr><td><code>BookStatus.borrowed</code></td><td>info/warning</td><td><code>color-info</code></td><td>Book status badge</td></tr> <tr><td><code>BookStatus.damaged</code> / <code>lost</code></td><td>error</td><td><code>color-error</code></td><td>Book status badge (issue disabled)</td></tr> <tr><td><code>BorrowStatus.active</code></td><td>info</td><td><code>color-info</code></td><td>record chip</td></tr> <tr><td><code>BorrowStatus.returned</code></td><td>neutral</td><td><code>color-neutral</code></td><td>history chip</td></tr> <tr><td>Overdue (derived)</td><td>error</td><td><code>color-error</code> + <code>type-style-danger</code></td><td>OverdueTag</td></tr> <tr><td><code>FineStatus.pending</code></td><td>warning</td><td><code>color-warning</code></td><td>FineChip</td></tr> <tr><td><code>FineStatus.paid</code></td><td>success</td><td><code>color-success</code></td><td>FineChip</td></tr> <tr><td><code>FineStatus.waived</code></td><td>neutral</td><td><code>color-neutral</code></td><td>FineChip</td></tr> </tbody></table> </div> <p>Enum sources: <code>book.schema.ts:7-12</code>, <code>borrow-record.schema.ts:7-18</code>.</p> <h2 id="2-typography-mapping"><a class="header" href="#2-typography-mapping">2. Typography mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>UI element</th><th>Text style (tokens)</th></tr></thead><tbody> <tr><td>Book title (detail)</td><td><code>text-headline</code></td></tr> <tr><td>Book title (row/card)</td><td><code>text-title</code></td></tr> <tr><td>Author · ISBN (rows)</td><td><code>text-body</code> muted</td></tr> <tr><td>Metadata (publisher/category/edition/shelf)</td><td><code>text-body</code> muted, labeled rows</td></tr> <tr><td>Availability badge</td><td><code>text-label</code> (bold), on <code>color-*</code> surfaces</td></tr> <tr><td>Due date (active card)</td><td><code>text-body</code> emphasized + <code>type-style-danger</code> when overdue</td></tr> <tr><td>Fine amounts</td><td><code>text-label</code> monospace-ish (numbers), tabular figures <code>(proposed)</code></td></tr> <tr><td>Info banner (overdue-derived notice)</td><td><code>text-caption</code> on info surface</td></tr> </tbody></table> </div> <h2 id="3-spacing--shape--elevation"><a class="header" href="#3-spacing--shape--elevation">3. Spacing / shape / elevation</a></h2> <ul> <li>Catalog rows: standard list density (<code>space-sm</code> gutters); cards on wide layouts.</li> <li>Sheets: standard bottom-sheet padding; forms ≤ 480 dp centered on large screens.</li> <li>Availability badge: pill shape (<code>shape-pill</code>), <code>size-sm</code>.</li> <li>Fine summary panel: inset panel <code>elevation-1</code> with divider.</li> </ul> <h2 id="4-iconography-9"><a class="header" href="#4-iconography-9">4. Iconography</a></h2> <div class="table-wrapper"><table><thead><tr><th>Icon</th><th>Meaning</th><th>Bindings</th></tr></thead><tbody> <tr><td><code>menu_book</code> / <code>book</code></td><td>catalog rows, empty state</td><td>S1/S5</td></tr> <tr><td><code>search</code></td><td>search bar</td><td>S1</td></tr> <tr><td><code>add</code></td><td>FAB "Add book"</td><td>S1</td></tr> <tr><td><code>swap_horiz</code> / <code>assignment</code></td><td>issue/return actions</td><td>S4/S6</td></tr> <tr><td><code>event</code> / <code>calendar_today</code></td><td>due date display</td><td>S5</td></tr> <tr><td><code>warning</code></td><td>overdue + derived-data banner</td><td>S7</td></tr> <tr><td><code>payments</code> / <code>receipt</code></td><td>fine pay</td><td>S5/S6</td></tr> <tr><td><code>check_circle</code></td><td>paid fine chip</td><td>S5/S6</td></tr> </tbody></table> </div> <h2 id="5-component-instance-map-00-shared03"><a class="header" href="#5-component-instance-map-00-shared03">5. Component instance map (<a href="library/../00-shared/03_Component_Library.html">00-shared/03</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Shared component</th><th>Instance(s) in library</th><th>Module-specific override</th></tr></thead><tbody> <tr><td><code>AppSearchBar</code></td><td>S1 catalog search</td><td>debounce 300 ms</td></tr> <tr><td><code>AppListTile</code></td><td>S1 rows (via BookRow)</td><td>trailing badges</td></tr> <tr><td><code>AppCard</code></td><td>S5/S7 BorrowRecordCard, S2 header</td><td>due-date emphasis</td></tr> <tr><td><code>AppBadge</code></td><td>status/availability/fine chips</td><td>color mapping per §1</td></tr> <tr><td><code>AppBottomSheet</code></td><td>S4 issue, S6 return</td><td>non-dismissible while submitting</td></tr> <tr><td><code>AppDialog</code></td><td>delete confirm, fine-pay confirm</td><td>destructive tone on delete</td></tr> <tr><td><code>AppFAB</code></td><td>S1 "Add book"</td><td>gated by <code>books.create</code></td></tr> <tr><td><code>AppEmptyState</code></td><td>no books / no borrows / 404</td><td>per-state copy</td></tr> <tr><td><code>AppSkeleton</code></td><td>list + detail loading</td><td>book-shaped rows</td></tr> <tr><td><code>AppOfflineBanner</code></td><td>all reads</td><td>cache fallback</td></tr> <tr><td><code>AppDateField</code></td><td>S4 due date</td><td>min = tomorrow, default +14 d</td></tr> <tr><td><code>AppPicker</code></td><td>S4 StudentPicker</td><td>searchable</td></tr> <tr><td><code>AppMenu</code></td><td>S2 overflow (Edit/Delete)</td><td>permission-gated entries</td></tr> </tbody></table> </div> <h2 id="6-theming-notes-1"><a class="header" href="#6-theming-notes-1">6. Theming notes</a></h2> <ul> <li>No library-specific theme; all screens consume the global tokens; status colors derive from existing semantic classes (§1) so dark mode/contrast come free (<a href="library/../00-shared/02_Design_Tokens.html">00-shared/02</a>).</li> <li>Fine "units" rendering: plain numerals, no currency glyph (no server currency contract — <code>library.service.ts:25</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-library-module"><a class="header" href="#12--api-mapping-library-module">12 — API Mapping (Library Module)</a></h1> <ul> <li><a href="library/12_API_Mapping.html#0-module-wide-request-envelope--client-policy">0. Module-wide request envelope & client policy</a></li> <li><a href="library/12_API_Mapping.html#screen-catalog-list--search--get-books">Screen: Catalog list / search — <code>GET /books</code></a></li> <li><a href="library/12_API_Mapping.html#screen-book-detail--get-booksid">Screen: Book detail — <code>GET /books/:id</code></a></li> <li><a href="library/12_API_Mapping.html#screen-create-book--post-books">Screen: Create book — <code>POST /books</code></a></li> <li><a href="library/12_API_Mapping.html#screen-edit-book--patch-booksid">Screen: Edit book — <code>PATCH /books/:id</code></a></li> <li><a href="library/12_API_Mapping.html#screen-delete-book--delete-booksid">Screen: Delete book — <code>DELETE /books/:id</code></a></li> <li><a href="library/12_API_Mapping.html#screen-issue-form--post-booksissue">Screen: Issue form — <code>POST /books/issue</code></a></li> <li><a href="library/12_API_Mapping.html#screen-return-form--post-booksreturn">Screen: Return form — <code>POST /books/return</code></a></li> <li><a href="library/12_API_Mapping.html#screen-borrowed-by-me--history--active">Screen: Borrowed-by-me — history & active</a></li> <li><a href="library/12_API_Mapping.html#screen-fine-pay--post-booksfinesborrowrecordidpay">Screen: Fine pay — <code>POST /books/fines/:borrowRecordId/pay</code></a></li> <li><a href="library/12_API_Mapping.html#not-exposed-gaps-the-client-must-work-around">Not exposed (gaps the client must work around)</a></li> <li><a href="library/12_API_Mapping.html#loading--streaming--realtime">Loading / streaming / realtime</a></li> <li><a href="library/12_API_Mapping.html#client-side-error-mapping-module">Client-side error mapping (module)</a></li> <li><a href="library/12_API_Mapping.html#optimistic--undo">Optimistic / undo</a></li> <li><a href="library/12_API_Mapping.html#notifications-surface">Notifications surface</a></li> </ul> <blockquote> <p>Exact wire contract for every screen → endpoint. Base <code>/api/v1</code>; envelope per <a href="library/../00-shared/07_API_Conventions.html">00-shared/07</a>. All endpoints from <code>src/modules/library/library.controller.ts</code> (<code>@Controller('books')</code>, <code>:23</code>); business rules from <code>library.service.ts</code>. Guards: <code>@UseGuards(JwtAuthGuard)</code> at controller level (<code>library.controller.ts:22</code>) — <strong>no RBAC metadata</strong> (OQ-1). Tenant from JWT only; <strong>never in body</strong> (<code>base.repository.ts</code> scoped filters).</p> </blockquote> <hr /> <h2 id="0-module-wide-request-envelope--client-policy-9"><a class="header" href="#0-module-wide-request-envelope--client-policy-9">0. Module-wide request envelope & client policy</a></h2> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Contract</th></tr></thead><tbody> <tr><td>Base</td><td><code>https://api.<domain>/api/v1</code></td></tr> <tr><td>Headers</td><td><code>Authorization: Bearer <accessToken></code>; <code>x-request-id</code>; <code>Content-Type: application/json</code></td></tr> <tr><td>Response</td><td><code>{success, message:"OK", data, meta?, timestamp, requestId}</code> (<code>response-envelope.interceptor.ts:44-52</code>)</td></tr> <tr><td>Error</td><td><code>{success:false, message, error:{code, details?}, timestamp, requestId}</code> (<code>http-exception.filter.ts:17-24,74-78</code>); codes include <code>VALIDATION_ERROR</code> (400), <code>DUPLICATE_RESOURCE</code> (409), <code>RATE_LIMITED</code> (429), <code>RESOURCE_NOT_FOUND</code> (404) (<code>:28-34</code>)</td></tr> <tr><td>Pagination</td><td><code>GET /books</code> only: <code>page</code> (≥1, default 1), <code>limit</code> (default 20) (<code>library.controller.ts:36-38</code>); <code>meta = {page, limit, totalItems, totalPages, hasNext, hasPrevious}</code> via <code>buildPaginationMeta</code> (<code>library.service.ts:75</code>); <strong>borrow endpoints are not paginated</strong> (<code>library.service.ts:195-201</code>)</td></tr> <tr><td>Caching</td><td>reads cached client-side (catalog 5 min, borrows 1 min, detail 1 min); <strong>no cache on write responses</strong></td></tr> <tr><td>Offline</td><td>reads from cache; writes blocked (no offline queue)</td></tr> <tr><td>Retry</td><td>backoff on 5xx/network; no auto-retry on 409 or 429</td></tr> <tr><td>Idempotency</td><td>none server-side (issue/return are not idempotent; double-tap guarded client-side — QA-2)</td></tr> </tbody></table> </div> <hr /> <h2 id="screen-catalog-list--search--get-books"><a class="header" href="#screen-catalog-list--search--get-books">Screen: Catalog list / search — <code>GET /books</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>GET /api/v1/books?page&limit&q</code> (<code>library.controller.ts:33-41</code>)</td></tr> <tr><td>Query</td><td><code>page</code> (default 1), <code>limit</code> (default 20) — <code>Number()</code> cast (<code>:40</code>); <code>q</code> optional</td></tr> <tr><td>Success</td><td>200 <code>data: [BookDoc…]</code> + <code>meta</code>; with <code>q</code> → regex title/author/ISBN case-insensitive (<code>book.repository.ts:26-30</code>); without → all, <code>sort: {title: 1}</code> (<code>:36</code>); skip <code>(page−1)*limit</code> (<code>library.service.ts:59,71</code>)</td></tr> <tr><td>Count</td><td>matched via <code>$or</code> regex or <code>{}</code> (<code>library.service.ts:60-67,73</code>)</td></tr> <tr><td>Errors</td><td>400 invalid ints; 5xx</td></tr> <tr><td>Filters</td><td>category/status/availability <strong>not supported server-side</strong> — client-side only (OQ-5)</td></tr> </tbody></table> </div> <p><strong>BookDoc</strong> (<code>book.schema.ts:15-48</code>, <code>base.schema.ts</code>): <code>_id, tenantId, title, author, isbn, publisher?, category?, edition?, totalCopies, availableCopies, status(available|borrowed|damaged|lost), shelfLocation?, description?, createdAt, updatedAt, version</code>. Indexes: <code>{tenantId,isbn}</code> unique (<code>:51</code>), <code>{tenantId,title}</code> (<code>:52</code>), <code>{tenantId,author}</code> (<code>:53</code>).</p> <h2 id="screen-book-detail--get-booksid"><a class="header" href="#screen-book-detail--get-booksid">Screen: Book detail — <code>GET /books/:id</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>GET /api/v1/books/:id</code> (<code>library.controller.ts:43-47</code>)</td></tr> <tr><td>Success</td><td>200 <code>data: BookDoc</code></td></tr> <tr><td>Errors</td><td>404 <code>RESOURCE_NOT_FOUND</code> "Book not found." (<code>library.service.ts:80</code>); 400 invalid id (CastError → <code>VALIDATION_ERROR</code>)</td></tr> </tbody></table> </div> <h2 id="screen-create-book--post-books"><a class="header" href="#screen-create-book--post-books">Screen: Create book — <code>POST /books</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>POST /api/v1/books</code> (<code>library.controller.ts:27-31</code>)</td></tr> <tr><td>Body</td><td><code>CreateBookDto</code> (<code>create-book.dto.ts:4-47</code>): <code>title*, author*, isbn*, publisher?, category?, edition?, totalCopies?(@Min 1), shelfLocation?, description?</code></td></tr> <tr><td>Success</td><td>201 <code>data: BookDoc</code> — <code>totalCopies = dto.totalCopies ?? 1</code>, <code>availableCopies = totalCopies</code> (<code>library.service.ts:41-46</code>)</td></tr> <tr><td>Errors</td><td><strong>409 <code>DUPLICATE_RESOURCE</code></strong> "Book with ISBN "…" already exists." (<code>library.service.ts:37-39</code>) + unique index <code>{tenantId,isbn}</code> (<code>book.schema.ts:51</code>); 400 validation</td></tr> </tbody></table> </div> <h2 id="screen-edit-book--patch-booksid"><a class="header" href="#screen-edit-book--patch-booksid">Screen: Edit book — <code>PATCH /books/:id</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>PATCH /api/v1/books/:id</code> (<code>library.controller.ts:49-53</code>)</td></tr> <tr><td>Body</td><td>any subset of <code>CreateBookDto</code> fields (<code>update-book.dto.ts:4</code>)</td></tr> <tr><td>Success</td><td>200 <code>data: BookDoc</code>; if <code>totalCopies</code> changed → <code>availableCopies = max(0, available + Δ)</code> (<code>library.service.ts:87-90</code>)</td></tr> <tr><td>Errors</td><td>404 (<code>library.service.ts:92</code>); 400 validation; 409 duplicate ISBN if ISBN changed to an existing one</td></tr> </tbody></table> </div> <h2 id="screen-delete-book--delete-booksid"><a class="header" href="#screen-delete-book--delete-booksid">Screen: Delete book — <code>DELETE /books/:id</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>DELETE /api/v1/books/:id</code> (<code>library.controller.ts:55-59</code>)</td></tr> <tr><td>Success</td><td>200 — <strong>soft delete</strong> (<code>library.service.ts:103</code> via <code>BaseRepository.softDelete</code>)</td></tr> <tr><td>Errors</td><td><strong>409</strong> "Cannot delete book with active borrow records." (<code>library.service.ts:99-101</code>); 404 (<code>:104</code>)</td></tr> </tbody></table> </div> <h2 id="screen-issue-form--post-booksissue"><a class="header" href="#screen-issue-form--post-booksissue">Screen: Issue form — <code>POST /books/issue</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>POST /api/v1/books/issue</code> (<code>library.controller.ts:61-65</code>)</td></tr> <tr><td>Body</td><td><code>{bookId*, studentId*, dueDate* (ISO), notes?}</code> (<code>issue-book.dto.ts:4-21</code>)</td></tr> <tr><td>Success</td><td>201 <code>data: BorrowRecordDoc</code> — <code>status: ACTIVE</code>, <code>borrowedAt: now</code>, <code>dueDate</code> as sent (<code>library.service.ts:132-139</code>); book <code>availableCopies − 1</code>, <code>status → borrowed</code> if 0 remain (<code>:122-130</code>)</td></tr> <tr><td>Side effect</td><td><code>BookIssued {bookId, studentId}</code> domain event (<code>library.service.ts:141-148</code>)</td></tr> <tr><td>Errors</td><td>404 book (<code>:108</code>); <strong>409</strong> "No copies available for borrowing." (<code>:109-111</code>); <strong>409</strong> "Student already has maximum number of borrowed books." (cap 5, <code>:116-120</code>); 400 validation</td></tr> <tr><td>Note</td><td>no server min-date on <code>dueDate</code> — client enforces future; no idempotency key</td></tr> </tbody></table> </div> <h2 id="screen-return-form--post-booksreturn"><a class="header" href="#screen-return-form--post-booksreturn">Screen: Return form — <code>POST /books/return</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>POST /api/v1/books/return</code> (<code>library.controller.ts:67-71</code>)</td></tr> <tr><td>Body</td><td><code>{borrowRecordId*, fineAmount?(@Min 0), notes?}</code> (<code>return-book.dto.ts:4-19</code>)</td></tr> <tr><td>Success</td><td>200 <code>data: BorrowRecordDoc</code> — <code>status: RETURNED</code>, <code>returnedAt: now</code>, <code>fineAmount = dto.fineAmount ?? calculateFine(dueDate)</code>, <code>fineStatus = pending</code> iff <code>fineAmount > 0</code> else unset (<code>library.service.ts:159-169</code>); book <code>availableCopies + 1</code>, <code>status → available</code> (<code>:172-178</code>)</td></tr> <tr><td>Side effect</td><td><code>BookReturned {bookId, studentId, fineAmount}</code> event (<code>library.service.ts:180-191</code>)</td></tr> <tr><td>Errors</td><td>404 record (<code>:153-154</code>); <strong>409</strong> "Book was not actively borrowed." (<code>:155-157</code>); 400 validation</td></tr> <tr><td>Note</td><td><code>fineAmount</code> override is trusted server-side — staff-only UI (QA-4)</td></tr> </tbody></table> </div> <p><strong>BorrowRecordDoc</strong> (<code>borrow-record.schema.ts:20-58</code>): <code>_id, tenantId, bookId (populated by repos: </code>borrow-record.repository.ts:31,42<code>), studentId, issuedBy? (never written — OQ-3), borrowedAt, dueDate, returnedAt?, status(active|returned|overdue|lost), fineAmount, fineStatus?(pending|paid|waived), notes?, createdAt, updatedAt</code>. Indexes (<code>:61-63</code>): <code>{tenantId,bookId,studentId}</code>, <code>{tenantId,studentId,status}</code>, <code>{tenantId,dueDate,status}</code>.</p> <h2 id="screen-borrowed-by-me--history--active"><a class="header" href="#screen-borrowed-by-me--history--active">Screen: Borrowed-by-me — history & active</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>History</strong></td><td><code>GET /api/v1/books/borrows/:studentId</code> (<code>library.controller.ts:73-77</code>) → 200 <code>[BorrowRecordDoc…]</code> sorted <code>borrowedAt</code> desc (<code>library.service.ts:195-197</code>); <strong>no pagination, no populate of studentId</strong></td></tr> <tr><td><strong>Active</strong></td><td><code>GET /api/v1/books/borrows/:studentId/active</code> (<code>library.controller.ts:79-83</code>) → 200 <code>[BorrowRecordDoc…]</code> where <code>status: ACTIVE</code> (<code>library.service.ts:199-201</code>, <code>borrow-record.repository.ts:24-32</code>, bookId populated <code>:31</code>)</td></tr> <tr><td>Errors</td><td>400 invalid id; 5xx</td></tr> <tr><td>Note</td><td>no <code>me</code> variant — client supplies <code>studentId</code> from profile (OQ-9)</td></tr> </tbody></table> </div> <h2 id="screen-fine-pay--post-booksfinesborrowrecordidpay"><a class="header" href="#screen-fine-pay--post-booksfinesborrowrecordidpay">Screen: Fine pay — <code>POST /books/fines/:borrowRecordId/pay</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>POST /api/v1/books/fines/:borrowRecordId/pay</code> (<code>library.controller.ts:85-89</code>) — <strong>no body</strong></td></tr> <tr><td>Success</td><td>200 <code>data: BorrowRecordDoc</code> with <code>fineStatus: PAID</code> (<code>library.service.ts:203-209</code>)</td></tr> <tr><td>Errors</td><td>404 record (<code>:207</code>); 400 invalid id</td></tr> <tr><td>Note</td><td>server does not check <code>fineStatus</code> before paying and does not validate fine > 0 (OQ-6) — client gates the button</td></tr> </tbody></table> </div> <hr /> <h2 id="not-exposed-gaps-the-client-must-work-around"><a class="header" href="#not-exposed-gaps-the-client-must-work-around">Not exposed (gaps the client must work around)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Need</th><th>Status</th></tr></thead><tbody> <tr><td>List all active borrows (overdue sweep)</td><td><code>(planned)</code> — <code>findOverdue()</code> exists unused (<code>borrow-record.repository.ts:34-43</code>); IMPLEMENTATION_PLAN.md:228</td></tr> <tr><td>Mark book damaged/lost, record lost</td><td><code>(planned)</code> — enums exist (<code>book.schema.ts:7-12</code>)</td></tr> <tr><td>Renew / reserve</td><td><code>(planned)</code> — IMPLEMENTATION_PLAN.md:228</td></tr> <tr><td>Catalog import/export</td><td><code>(planned)</code> — IMPLEMENTATION_PLAN.md:172,228</td></tr> <tr><td>Waive fine</td><td><code>(planned)</code> — <code>FineStatus.WAIVED</code> exists (<code>borrow-record.schema.ts:17</code>) but nothing sets it</td></tr> <tr><td>Book cover / attachments</td><td><code>(proposed)</code> — files module exists (<code>files.controller.ts:29-71</code>), Book has no cover field (OQ-8)</td></tr> <tr><td>QR/barcode scanning</td><td><code>(forward-looking)</code></td></tr> </tbody></table> </div> <h2 id="loading--streaming--realtime-5"><a class="header" href="#loading--streaming--realtime-5">Loading / streaming / realtime</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Loading</th><th>Realtime</th></tr></thead><tbody> <tr><td>Catalog list</td><td><code>AppSkeleton</code></td><td>re-fetch on focus; WS <code>(planned)</code></td></tr> <tr><td>Book detail</td><td>skeleton</td><td>re-fetch on focus (issue/return elsewhere)</td></tr> <tr><td>Borrowed-by-me</td><td>skeleton</td><td>re-fetch on focus</td></tr> <tr><td>Issue/Return/Fine pay</td><td>button spinner</td><td>—</td></tr> </tbody></table> </div> <h2 id="client-side-error-mapping-module-1"><a class="header" href="#client-side-error-mapping-module-1">Client-side error mapping (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>code</th><th>UI</th></tr></thead><tbody> <tr><td>issue</td><td>409 (copies/cap)</td><td>banner + refresh availability</td></tr> <tr><td>return</td><td>409 (not active)</td><td>banner "Already returned" + refresh</td></tr> <tr><td>create/update</td><td>409 (ISBN)</td><td>inline field error</td></tr> <tr><td>any</td><td>404</td><td><code>AppEmptyState</code> / close form + snackbar</td></tr> <tr><td>any</td><td>400</td><td>field errors</td></tr> <tr><td>any</td><td>401 → refresh → fail</td><td><code>sessionExpired</code></td></tr> <tr><td>any</td><td>429</td><td>countdown, no retry</td></tr> <tr><td>any</td><td>5xx</td><td>generic + requestId, retry</td></tr> </tbody></table> </div> <h2 id="optimistic--undo-5"><a class="header" href="#optimistic--undo-5">Optimistic / undo</a></h2> <ul> <li><strong>No optimistic mutations</strong> — issue/return/fine pay/create/update/delete are all server-confirmed (<a href="library/../00-shared/07_API_Conventions.html">00-shared/07 §9</a>).</li> <li>Undo: in-form edits only; delete = confirm dialog (soft delete server-side); no undo for issue/return (record lifecycle instead).</li> </ul> <h2 id="notifications-surface-1"><a class="header" href="#notifications-surface-1">Notifications surface</a></h2> <p><code>BookIssued</code> / <code>BookReturned</code> events (<code>library.service.ts:141-148,180-191</code>) are on the domain event bus; notification/email consumers <code>(proposed)</code>.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-library-module"><a class="header" href="#13--state-management-library-module">13 — State Management (Library Module)</a></h1> <ul> <li><a href="library/13_State_Management.html#1-catalogcubit-s1--list--search--filters">1. CatalogCubit (S1 — list + search + filters)</a></li> <li><a href="library/13_State_Management.html#2-bookdetailcubit-s2">2. BookDetailCubit (S2)</a></li> <li><a href="library/13_State_Management.html#3-bookformcubit-s3--create--edit">3. BookFormCubit (S3 — create + edit)</a></li> <li><a href="library/13_State_Management.html#4-issuecubit-s4">4. IssueCubit (S4)</a></li> <li><a href="library/13_State_Management.html#5-borrowscubit-s5--active--history">5. BorrowsCubit (S5 — active + history)</a></li> <li><a href="library/13_State_Management.html#6-returncubit-s6">6. ReturnCubit (S6)</a></li> <li><a href="library/13_State_Management.html#7-overduecubit-s7">7. OverdueCubit (S7)</a></li> <li><a href="library/13_State_Management.html#8-finepaycubit-inline">8. FinePayCubit (inline)</a></li> <li><a href="library/13_State_Management.html#9-repository-layer">9. Repository layer</a></li> <li><a href="library/13_State_Management.html#10-cross-cubit-refresh-rules">10. Cross-cubit refresh rules</a></li> </ul> <blockquote> <p>Per-screen Cubits (Flutter/bloc; proposal, <a href="library/../00-shared/06_State_Management.html">00-shared/06</a>) backed by <code>LibraryRepository</code> (dio) calling the endpoints in <a href="library/12_API_Mapping.html">12_API_Mapping.md</a>. Module-wide rules: <strong>no optimistic mutations</strong>, server-confirmed writes only; availability and fine amounts are server-authoritative; overdue is client-derived (OQ-2).</p> </blockquote> <hr /> <h2 id="1-catalogcubit-s1--list--search--filters"><a class="header" href="#1-catalogcubit-s1--list--search--filters">1. CatalogCubit (S1 — list + search + filters)</a></h2> <pre class="mermaid">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 </pre> <ul> <li><strong>State:</strong> <code>{status, books[], meta, query, filters{category?, status?, availableOnly?}}</code>.</li> <li>Load: <code>GET /books?page&limit&q</code> (<code>library.controller.ts:33-41</code>); <code>meta.hasNext</code> gates <code>LoadMore</code> (<code>library.service.ts:75</code>).</li> <li>Search: debounced <code>q</code> → server regex (title/author/ISBN, <code>book.repository.ts:26-30</code>).</li> <li><strong>Filters are client-side only</strong> (OQ-5): applied to <code>books[]</code> before render, never sent as params.</li> <li><strong>Caching:</strong> key <code>lib:catalog:{tenant}:{q}:{page}</code>, TTL 5 min, stale-while-revalidate (<code>00-shared/06 §3.3</code>); pull-to-refresh bypasses.</li> <li>Events: <code>Load</code>, <code>Refresh</code>, <code>Search(q)</code>, <code>LoadMore</code>, <code>ChangeFilters</code>, <code>Retry</code>.</li> </ul> <h2 id="2-bookdetailcubit-s2"><a class="header" href="#2-bookdetailcubit-s2">2. BookDetailCubit (S2)</a></h2> <ul> <li><strong>State:</strong> <code>{status, book?}</code>; <code>book</code> = <code>BookDoc</code> model (<code>id, title, author, isbn, publisher?, category?, edition?, totalCopies, availableCopies, status, shelfLocation?, description?</code>).</li> <li>Load: <code>GET /books/:id</code> (<code>library.controller.ts:43-47</code>); 404 → <code>notFound</code> terminal-ish state (empty + back).</li> <li><strong>Derived:</strong> <code>isAvailable = availableCopies > 0</code>, <code>canIssue = isAvailable && status == available</code>.</li> <li>Events: <code>Load(id)</code>, <code>Refresh</code> (focus), <code>IssueDone</code>/<code>EditDone</code>/<code>DeleteDone</code> (re-fetch or pop).</li> <li>No cache (volatile) — re-fetch on focus (availability changes elsewhere).</li> </ul> <h2 id="3-bookformcubit-s3--create--edit"><a class="header" href="#3-bookformcubit-s3--create--edit">3. BookFormCubit (S3 — create + edit)</a></h2> <ul> <li><strong>State:</strong> <code>{mode, form{title, author, isbn, publisher?, category?, edition?, totalCopies, shelfLocation?, description?}, copiesProjection, status: idle|submitting|error(field?)}</code>.</li> <li>Create submit → <code>POST /books</code> (<code>library.controller.ts:27-31</code>); edit → <code>PATCH /books/:id</code> (<code>:49-53</code>). Success → emit done with server doc; navigate.</li> <li><strong>Copies projection:</strong> create <code>available = total</code> (<code>library.service.ts:41-46</code>); edit <code>available = max(0, available + Δ)</code> (<code>:87-90</code>).</li> <li>Errors: 409 duplicate ISBN → <code>fieldErrors.isbn</code> (server message verbatim); 400 → map <code>details[].message</code> → field; 5xx → error (form preserved).</li> <li><strong>No optimistic writes.</strong> Draft persist <code>(proposed)</code>.</li> </ul> <h2 id="4-issuecubit-s4"><a class="header" href="#4-issuecubit-s4">4. IssueCubit (S4)</a></h2> <pre class="mermaid">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 </pre> <ul> <li><strong>State:</strong> <code>{status, book, studentId?, dueDate?, notes?, errorKind?}</code>.</li> <li>Submit: <code>POST /books/issue {bookId, studentId, dueDate, notes?}</code> (<code>issue-book.dto.ts:4-21</code>, <code>library.controller.ts:61-65</code>).</li> <li><code>noCopies</code>/<code>capReached</code> are <strong>banner states</strong> — re-read the book/borrows and return to <code>ready</code>; never auto-resubmit.</li> <li>Client guard: due date must be future (server has no min — <code>issue-book.dto.ts:14-15</code>).</li> <li>Double-tap guard: <code>submitting</code> blocks re-entry (server has no idempotency — QA-2).</li> </ul> <h2 id="5-borrowscubit-s5--active--history"><a class="header" href="#5-borrowscubit-s5--active--history">5. BorrowsCubit (S5 — active + history)</a></h2> <ul> <li><strong>State:</strong> <code>{status, active[], history[], studentId}</code>.</li> <li>Load: <code>GET /books/borrows/:studentId/active</code> (<code>library.controller.ts:79-83</code>) and <code>GET /books/borrows/:studentId</code> (<code>:73-77</code>) — <strong>not paginated</strong> (<code>library.service.ts:195-201</code>); client shows first N + "load more" <code>(proposed)</code>.</li> <li><code>bookId</code> is populated (<code>borrow-record.repository.ts:31,42</code>); <code>studentId</code> identity comes from Students profile state (OQ-9).</li> <li><strong>Derived per record:</strong> <code>isOverdue = status=='active' && now > dueDate</code>, <code>daysOverdue = ceil((now − dueDate)/day)</code>, <code>finePreview = daysOverdue × 5</code> (mirrors <code>library.service.ts:211-217</code>; labeled "estimate").</li> <li>Events: <code>Load(studentId)</code>, <code>Refresh</code>, <code>ReturnedDone(recordId)</code> (replace doc), <code>FinePaidDone(recordId)</code> (replace doc).</li> <li><strong>Caching:</strong> active 1 min TTL; history 5 min; focus refresh bypasses.</li> </ul> <h2 id="6-returncubit-s6"><a class="header" href="#6-returncubit-s6">6. ReturnCubit (S6)</a></h2> <ul> <li><strong>State:</strong> <code>{status, record?, fineAmount?, fineStatus?, notes?, derived{daysOverdue, computedFine}}</code>.</li> <li>Prefill: <code>fineAmount = computedFine</code> (client mirror of server rule); staff may edit (<code>@Min(0)</code> — <code>return-book.dto.ts:9-13</code>).</li> <li>Submit → <code>POST /books/return {borrowRecordId, fineAmount?, notes?}</code> (<code>library.controller.ts:67-71</code>) → server doc (server recomputes fine when no override — <code>library.service.ts:159</code>).</li> <li><code>notActive</code> state on 409 (<code>library.service.ts:155-157</code>) → banner + refresh record.</li> <li>If returned <code>fineAmount > 0</code> → offer pay: <code>POST /books/fines/:id/pay</code> (<code>library.controller.ts:85-89</code>) → <code>fineStatus: PAID</code> (<code>library.service.ts:203-209</code>).</li> </ul> <h2 id="7-overduecubit-s7"><a class="header" href="#7-overduecubit-s7">7. OverdueCubit (S7)</a></h2> <ul> <li><strong>State:</strong> <code>{status, rows[]}</code> where row = <code>{student, book, dueDate, daysLate, finePreview}</code>.</li> <li><strong>Derived</strong>: aggregates active borrows from <code>BorrowsCubit</code>-loaded data (per-student); no server endpoint (OQ-2). Persistent UI notice: "derived — server scan planned".</li> <li>Refresh re-pulls the underlying active lists.</li> <li>Server-side sweep <code>(planned)</code> (IMPLEMENTATION_PLAN.md:228) will replace this.</li> </ul> <h2 id="8-finepaycubit-inline"><a class="header" href="#8-finepaycubit-inline">8. FinePayCubit (inline)</a></h2> <ul> <li><code>{status, recordId?, fineAmount?}</code>; <code>Pay</code> → confirm dialog → endpoint → PAID doc.</li> <li>Server doesn't guard state (OQ-6): cubit only exposes pay when <code>fineStatus == 'pending'</code> and <code>fineAmount > 0</code>.</li> </ul> <h2 id="9-repository-layer"><a class="header" href="#9-repository-layer">9. Repository layer</a></h2> <p><code>LibraryRepository</code> (dio): methods map 1:1 to the endpoints in <code>12_API_Mapping.md</code>; wraps the envelope, unwraps <code>data</code>/<code>meta</code>, throws typed <code>ApiException{code,message,details,requestId}</code>; injects auth token + <code>x-request-id</code>. Models (<code>Book</code>, <code>BorrowRecord</code>) parse the raw docs; enums kept as string constants mirroring <code>book.schema.ts:7-12</code>, <code>borrow-record.schema.ts:7-18</code>.</p> <h2 id="10-cross-cubit-refresh-rules"><a class="header" href="#10-cross-cubit-refresh-rules">10. Cross-cubit refresh rules</a></h2> <div class="table-wrapper"><table><thead><tr><th>Write done</th><th>Refresh</th></tr></thead><tbody> <tr><td>Book created/updated/deleted</td><td>CatalogCubit + BookDetailCubit</td></tr> <tr><td>Issue succeeded</td><td>BookDetailCubit (availability) + BorrowsCubit (student)</td></tr> <tr><td>Return succeeded</td><td>BookDetailCubit + BorrowsCubit + OverdueCubit</td></tr> <tr><td>Fine paid</td><td>BorrowsCubit (record chip)</td></tr> </tbody></table> </div> <p>All refreshes are server-confirmed re-fetches; no local doc mutation except replacing with the server response.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-library-module"><a class="header" href="#14--qa-checklist-library-module">14 — QA Checklist (Library Module)</a></h1> <ul> <li><a href="library/14_QA_Checklist.html#qa-1-availability-race-concurrent-issue-of-the-last-copy--critical">QA-1. Availability race (concurrent issue of the last copy) — CRITICAL</a></li> <li><a href="library/14_QA_Checklist.html#qa-2-double-issue-same-book-same-student-twice">QA-2. Double-issue (same book, same student, twice)</a></li> <li><a href="library/14_QA_Checklist.html#qa-3-copy-count-integrity">QA-3. Copy-count integrity</a></li> <li><a href="library/14_QA_Checklist.html#qa-4-fines--computation-and-override">QA-4. Fines — computation and override</a></li> <li><a href="library/14_QA_Checklist.html#qa-5-overdue-computation-client-derived">QA-5. Overdue computation (client-derived)</a></li> <li><a href="library/14_QA_Checklist.html#qa-6-business-rule-409s-message-accuracy">QA-6. Business-rule 409s (message accuracy)</a></li> <li><a href="library/14_QA_Checklist.html#qa-7-envelope-auth-tenant-isolation">QA-7. Envelope, auth, tenant isolation</a></li> <li><a href="library/14_QA_Checklist.html#qa-8-search--pagination">QA-8. Search & pagination</a></li> <li><a href="library/14_QA_Checklist.html#qa-9-client-states-offline-rate-permissions">QA-9. Client states (offline, rate, permissions)</a></li> <li><a href="library/14_QA_Checklist.html#qa-10-events--side-effects">QA-10. Events & side effects</a></li> <li><a href="library/14_QA_Checklist.html#severity-summary">Severity summary</a></li> </ul> <blockquote> <p>Test matrix for the client + API surface. Server behaviors cited from <code>library.service.ts</code> / <code>book.schema.ts</code> / <code>borrow-record.schema.ts</code>; E2E requires MongoDB + Redis (see AGENTS.md). Focus areas per the module brief: <strong>availability race, double-issue, overdue computation, fines</strong>.</p> </blockquote> <hr /> <h2 id="qa-1-availability-race-concurrent-issue-of-the-last-copy--critical"><a class="header" href="#qa-1-availability-race-concurrent-issue-of-the-last-copy--critical">QA-1. Availability race (concurrent issue of the last copy) — CRITICAL</a></h2> <p><strong>Server behavior today:</strong> <code>issueBook</code> reads <code>book.availableCopies</code> then writes <code>availableCopies - 1</code> (<code>library.service.ts:108-130</code>) — a <strong>read-then-write without atomic conditional update</strong>. Two concurrent issues of the last copy can both pass the <code>< 1</code> check (OQ-7).</p> <div class="table-wrapper"><table><thead><tr><th>Check</th><th>Expected</th></tr></thead><tbody> <tr><td>Two parallel <code>POST /books/issue</code> for the same book with <code>availableCopies = 1</code></td><td>at least one must fail (or both succeed but availability stays ≥ 0 — <strong>today this is a race</strong>; assert no negative <code>availableCopies</code>; document as known gap)</td></tr> <tr><td>Issue when <code>availableCopies = 0</code> (sequential)</td><td>409 "No copies available for borrowing." (<code>library.service.ts:109-111</code>)</td></tr> <tr><td>Client on 409</td><td>refreshes availability, <strong>never auto-retries</strong> (<a href="library/13_State_Management.html">13 §4</a>)</td></tr> <tr><td>Client button during submit</td><td>disabled/spinner — prevents client-side double-tap (server has no idempotency, QA-2)</td></tr> <tr><td><code>availableCopies</code> never negative after any sequence</td><td>property test over issue/return mixes</td></tr> </tbody></table> </div> <h2 id="qa-2-double-issue-same-book-same-student-twice"><a class="header" href="#qa-2-double-issue-same-book-same-student-twice">QA-2. Double-issue (same book, same student, twice)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Check</th><th>Expected</th></tr></thead><tbody> <tr><td>Issue same <code>(bookId, studentId)</code> while a record is ACTIVE</td><td><strong>server allows it</strong> today (no active-record check per book+student; only the 5-cap <code>library.service.ts:116-120</code>) — assert & document as gap <code>(planned)</code></td></tr> <tr><td>Two quick submits of the same issue form</td><td>client blocks (submit lock); if both reach server, cap may still allow → duplicates possible; log for review</td></tr> <tr><td>Book status after double-issue</td><td>status flips to <code>borrowed</code> when available hits 0 (<code>library.service.ts:125-129</code>)</td></tr> </tbody></table> </div> <h2 id="qa-3-copy-count-integrity"><a class="header" href="#qa-3-copy-count-integrity">QA-3. Copy-count integrity</a></h2> <div class="table-wrapper"><table><thead><tr><th>Check</th><th>Expected</th></tr></thead><tbody> <tr><td><code>totalCopies</code> edit by +N</td><td><code>availableCopies</code> +N, floor 0 (<code>library.service.ts:87-90</code>)</td></tr> <tr><td>Edit <code>totalCopies</code> below currently available</td><td>available clamps at 0, never negative</td></tr> <tr><td>Create with <code>totalCopies = 5</code></td><td>available = 5 (<code>library.service.ts:41-46</code>); default 1</td></tr> <tr><td>Delete book with ACTIVE records</td><td>409 (<code>library.service.ts:97-102</code>); delete succeeds only when no active records; soft-delete (book gone from lists, not physically deleted)</td></tr> </tbody></table> </div> <h2 id="qa-4-fines--computation-and-override"><a class="header" href="#qa-4-fines--computation-and-override">QA-4. Fines — computation and override</a></h2> <div class="table-wrapper"><table><thead><tr><th>Check</th><th>Expected</th></tr></thead><tbody> <tr><td>Return on due date</td><td><code>fineAmount = 0</code>, <code>fineStatus</code> unset (<code>library.service.ts:159-169</code>)</td></tr> <tr><td>Return 1 ms after due date</td><td><code>ceil(diff/24h) × 5</code> = 5 for < 24 h late — <strong>ceil makes any late return ≥ 1 day</strong> (<code>library.service.ts:211-217</code>)</td></tr> <tr><td>Return 3.5 days late</td><td><code>ceil(3.5) × 5</code> = 20</td></tr> <tr><td>Return with <code>fineAmount: 0</code></td><td>override wins (0, <code>library.service.ts:159</code>) — "waive via zero" path</td></tr> <tr><td>Return with <code>fineAmount: 999</code></td><td>override wins — <strong>server trusts client</strong>; QA must confirm UI hides override for non-staff (OQ-6)</td></tr> <tr><td>Fine on ACTIVE record: <code>POST /books/fines/:id/pay</code></td><td>succeeds today (no guard) — assert UI gates by <code>fineStatus == pending</code></td></tr> <tr><td>Pay twice</td><td>idempotent overwrite to PAID (no error) — no double-charge UI implication (units only)</td></tr> <tr><td><code>fineStatus: WAIVED</code></td><td>never produced by any endpoint — don't test as reachable</td></tr> </tbody></table> </div> <h2 id="qa-5-overdue-computation-client-derived"><a class="header" href="#qa-5-overdue-computation-client-derived">QA-5. Overdue computation (client-derived)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Check</th><th>Expected</th></tr></thead><tbody> <tr><td>OverdueTag shows iff <code>status=='active' && now > dueDate</code></td><td>derived rule (<a href="library/13_State_Management.html">13 §5</a>); server never writes <code>OVERDUE</code> (OQ-2)</td></tr> <tr><td>Days late / fine preview</td><td><code>ceil((now−dueDate)/day) × 5</code> matches server rule when the same clock is used; label "estimate"</td></tr> <tr><td>Clock skew (device vs server)</td><td>preview differs from server fine at return → UI must show server value after return (authoritative)</td></tr> <tr><td>Server <code>findOverdue()</code></td><td>exists unused (<code>borrow-record.repository.ts:34-43</code>) — plan tests when the endpoint ships <code>(planned)</code></td></tr> </tbody></table> </div> <h2 id="qa-6-business-rule-409s-message-accuracy"><a class="header" href="#qa-6-business-rule-409s-message-accuracy">QA-6. Business-rule 409s (message accuracy)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Check</th><th>Expected</th></tr></thead><tbody> <tr><td>Duplicate ISBN</td><td>409, exact message <code>Book with ISBN "…" already exists.</code> (<code>library.service.ts:37-39</code>)</td></tr> <tr><td>No copies</td><td>409 exact copy (<code>:109-111</code>)</td></tr> <tr><td>5-active cap</td><td>409 exact copy (<code>:116-120</code>) — boundary: exactly 5 active → block; 4 → allow</td></tr> <tr><td>Return non-ACTIVE record</td><td>409 "Book was not actively borrowed." (<code>:155-157</code>)</td></tr> <tr><td>Unknown book/record</td><td>404 (<code>:80,153-154,207</code>)</td></tr> <tr><td>Invalid Mongo id</td><td>400 <code>VALIDATION_ERROR</code></td></tr> </tbody></table> </div> <h2 id="qa-7-envelope-auth-tenant-isolation"><a class="header" href="#qa-7-envelope-auth-tenant-isolation">QA-7. Envelope, auth, tenant isolation</a></h2> <div class="table-wrapper"><table><thead><tr><th>Check</th><th>Expected</th></tr></thead><tbody> <tr><td>Every response wraps <code>{success, message, data, meta?, timestamp, requestId}</code></td><td><code>response-envelope.interceptor.ts:44-52</code></td></tr> <tr><td><code>x-request-id</code> echoed</td><td><code>requestId</code> in body (<code>http-exception.filter.ts:45</code>)</td></tr> <tr><td>No token → 401</td><td>JwtAuthGuard (<code>library.controller.ts:22</code>)</td></tr> <tr><td>Tenant A book invisible to tenant B</td><td>all repos tenant-scoped; unique ISBN is per-tenant (<code>book.schema.ts:51</code>)</td></tr> <tr><td>ISBN duplicate across tenants</td><td>allowed (both succeed)</td></tr> </tbody></table> </div> <h2 id="qa-8-search--pagination"><a class="header" href="#qa-8-search--pagination">QA-8. Search & pagination</a></h2> <div class="table-wrapper"><table><thead><tr><th>Check</th><th>Expected</th></tr></thead><tbody> <tr><td><code>q</code> matches title/author/ISBN, case-insensitive substring</td><td>regex <code>$options: 'i'</code> (<code>book.repository.ts:26-30</code>)</td></tr> <tr><td>No <code>q</code> → all books sorted title asc</td><td><code>library.service.ts:68-74</code></td></tr> <tr><td><code>page</code>/<code>limit</code> math</td><td><code>meta</code> matches <code>buildPaginationMeta</code> (<code>library.service.ts:75</code>); last page <code>hasNext = false</code></td></tr> <tr><td>Non-numeric <code>page</code>/<code>limit</code></td><td><code>Number()</code> cast → NaN edge (400 not guaranteed — flag)</td></tr> </tbody></table> </div> <h2 id="qa-9-client-states-offline-rate-permissions"><a class="header" href="#qa-9-client-states-offline-rate-permissions">QA-9. Client states (offline, rate, permissions)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Check</th><th>Expected</th></tr></thead><tbody> <tr><td>Offline with cache</td><td>cached catalog/borrows render + <code>AppOfflineBanner</code>; writes blocked</td></tr> <tr><td>429</td><td>countdown banner, no auto-retry</td></tr> <tr><td>401 mid-session</td><td>token refresh then retry once; fail → <code>sessionExpired</code></td></tr> <tr><td>RBAC <code>(planned)</code></td><td>with perms enforced: no <code>books.create</code> → FAB hidden; no <code>fines.pay</code> → pay hidden (matrix <a href="library/02_User_Personas.html">02 §6</a>)</td></tr> </tbody></table> </div> <h2 id="qa-10-events--side-effects"><a class="header" href="#qa-10-events--side-effects">QA-10. Events & side effects</a></h2> <div class="table-wrapper"><table><thead><tr><th>Check</th><th>Expected</th></tr></thead><tbody> <tr><td>Issue emits <code>BookIssued {bookId, studentId}</code></td><td>event bus (<code>library.service.ts:141-148</code>); correlationId/tenantId/actorId populated</td></tr> <tr><td>Return emits <code>BookReturned</code> incl. <code>fineAmount</code></td><td>(<code>:180-191</code>)</td></tr> <tr><td><code>issuedBy</code> on the record</td><td><strong>never written</strong> (<code>:132-139</code>) — assert field absent in responses; test when fixed (OQ-3)</td></tr> </tbody></table> </div> <hr /> <h2 id="severity-summary"><a class="header" href="#severity-summary">Severity summary</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Area</th><th>Severity</th><th>Status</th></tr></thead><tbody> <tr><td>QA-1</td><td>availability race</td><td>High</td><td>server gap (OQ-7); client: refresh-on-409</td></tr> <tr><td>QA-2</td><td>double-issue same book+student</td><td>Medium</td><td>server gap <code>(planned)</code>; client: submit lock</td></tr> <tr><td>QA-4</td><td>fine override trusted</td><td>Medium</td><td>client: staff-only UI</td></tr> <tr><td>QA-5</td><td>overdue derived</td><td>Low</td><td>server scan <code>(planned)</code></td></tr> <tr><td>QA-6</td><td>409 messages</td><td>High</td><td>test verbatim</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-library-module"><a class="header" href="#15--flutter-implementation-guide-library-module">15 — Flutter Implementation Guide (Library Module)</a></h1> <ul> <li><a href="library/15_Flutter_Implementation_Guide.html#1-module-layout-feature-first">1. Module layout (feature-first)</a></li> <li><a href="library/15_Flutter_Implementation_Guide.html#2-models-parse-raw-docs-never-derive-server-truth">2. Models (parse raw docs; never derive server truth)</a></li> <li><a href="library/15_Flutter_Implementation_Guide.html#3-repository-one-class-typed-errors">3. Repository (one class, typed errors)</a></li> <li><a href="library/15_Flutter_Implementation_Guide.html#4-cubits">4. Cubits</a></li> <li><a href="library/15_Flutter_Implementation_Guide.html#5-caching">5. Caching</a></li> <li><a href="library/15_Flutter_Implementation_Guide.html#6-error-ux-mapping">6. Error UX mapping</a></li> <li><a href="library/15_Flutter_Implementation_Guide.html#7-permissions-aware-ui-rbac-planned--oq-1">7. Permissions-aware UI (RBAC planned — OQ-1)</a></li> <li><a href="library/15_Flutter_Implementation_Guide.html#8-date--fine-handling">8. Date & fine handling</a></li> <li><a href="library/15_Flutter_Implementation_Guide.html#9-testing">9. Testing</a></li> <li><a href="library/15_Flutter_Implementation_Guide.html#10-known-ceilings-ponytail-notes">10. Known ceilings (ponytail notes)</a></li> </ul> <blockquote> <p>Concrete build guide for the library client on top of the app architecture in <a href="library/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a>. Follows the shared repo/conventions; module specifics only.</p> </blockquote> <hr /> <h2 id="1-module-layout-feature-first"><a class="header" href="#1-module-layout-feature-first">1. Module layout (feature-first)</a></h2> <pre><code>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) </code></pre> <h2 id="2-models-parse-raw-docs-never-derive-server-truth"><a class="header" href="#2-models-parse-raw-docs-never-derive-server-truth">2. Models (parse raw docs; never derive server truth)</a></h2> <pre><code class="language-dart">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); </code></pre> <ul> <li>Keep enums as strings matching server values — no renaming.</li> <li><code>fineAmount</code> is <code>double</code>/<code>num</code> (plain units; no currency — <code>library.service.ts:25</code>).</li> </ul> <h2 id="3-repository-one-class-typed-errors"><a class="header" href="#3-repository-one-class-typed-errors">3. Repository (one class, typed errors)</a></h2> <pre><code class="language-dart">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 } </code></pre> <ul> <li>Unwrap <code>data</code>/<code>meta</code> from the envelope (<code>response-envelope.interceptor.ts:44-52</code>); throw <code>ApiException(code, message, details, requestId)</code> on <code>success:false</code>.</li> <li>ISO dates: parse <code>dueDate</code>/<code>borrowedAt</code>/<code>returnedAt</code> with UTC-safe parsing; send <code>dueDate.toIso8601String()</code> (<code>@IsDateString</code>, <code>issue-book.dto.ts:14-15</code>).</li> <li>No request body ever includes <code>tenantId</code> (JWT-scoped).</li> </ul> <h2 id="4-cubits-1"><a class="header" href="#4-cubits-1">4. Cubits</a></h2> <p>Implement the seven Cubits from <code>13_State_Management.md</code>: <code>CatalogCubit</code>, <code>BookDetailCubit</code>, <code>BookFormCubit</code>, <code>IssueCubit</code>, <code>BorrowsCubit</code>, <code>ReturnCubit</code>, <code>OverdueCubit</code> (+ inline <code>FinePayCubit</code>). Rules:</p> <ul> <li><strong>No optimistic writes</strong> — every mutation emits server-confirmed docs.</li> <li><strong>409 = refresh, never retry</strong>; <code>noCopies</code>/<code>capReached</code> are named states.</li> <li><strong>Double-tap guard</strong> — submit states are final until the response returns.</li> <li>Overdue/fine-preview derived via <code>FineCalculator</code> (unit-testable pure function).</li> <li>Cross-cubit refresh table (<code>13 §10</code>): issue → refresh detail + borrows; return → detail + borrows + overdue; pay → borrows.</li> </ul> <h2 id="5-caching"><a class="header" href="#5-caching">5. Caching</a></h2> <div class="table-wrapper"><table><thead><tr><th>Data</th><th>Key</th><th>TTL</th><th>Notes</th></tr></thead><tbody> <tr><td>Catalog page</td><td><code>lib:catalog:{tenant}:{q}:{page}</code></td><td>5 min</td><td>stale-while-revalidate; pull-to-refresh bypasses</td></tr> <tr><td>Book detail</td><td><code>lib:book:{id}</code></td><td>1 min</td><td>focus re-fetch</td></tr> <tr><td>Active borrows</td><td><code>lib:active:{studentId}</code></td><td>1 min</td><td>focus re-fetch</td></tr> <tr><td>History</td><td><code>lib:history:{studentId}</code></td><td>5 min</td><td>focus re-fetch</td></tr> <tr><td>Writes</td><td>—</td><td>never cached</td><td></td></tr> </tbody></table> </div> <h2 id="6-error-ux-mapping"><a class="header" href="#6-error-ux-mapping">6. Error UX mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Code</th><th>Handling</th></tr></thead><tbody> <tr><td>400 <code>VALIDATION_ERROR</code></td><td>map <code>details[].message</code> → field errors (forms)</td></tr> <tr><td>409 <code>DUPLICATE_RESOURCE</code></td><td>context banners: ISBN dup (inline), no copies, 5-cap, not-active</td></tr> <tr><td>404</td><td>empty states / close form + snackbar</td></tr> <tr><td>401</td><td>refresh token once → retry → <code>sessionExpired</code></td></tr> <tr><td>429</td><td>countdown banner, no retry</td></tr> <tr><td>5xx / network</td><td>generic + <code>requestId</code> + retry action</td></tr> </tbody></table> </div> <h2 id="7-permissions-aware-ui-rbac-planned--oq-1"><a class="header" href="#7-permissions-aware-ui-rbac-planned--oq-1">7. Permissions-aware UI (RBAC planned — OQ-1)</a></h2> <p>Gate from the user's role permissions (matrix in <code>02 §6</code>): <code>books.read</code> (module visible), <code>books.create</code> (FAB), <code>books.update</code> (edit), <code>books.delete</code> (menu), <code>books.issue</code> (issue CTA), <code>books.return</code> (return button), <code>fines.pay</code> (pay action). Until the server enforces, the client hides what the role lacks; when guards land, keep the same gates.</p> <h2 id="8-date--fine-handling"><a class="header" href="#8-date--fine-handling">8. Date & fine handling</a></h2> <ul> <li>Due dates: native date picker; client min = tomorrow; default +14 days.</li> <li><code>FineCalculator.daysOverdue(dueDate) = ceil((now − dueDate).inDays fractional)</code> — must mirror <code>Math.ceil(diffMs / dayMs)</code> (<code>library.service.ts:215</code>).</li> <li>Fine preview is always labeled <strong>estimate</strong>; after return, render the server's <code>fineAmount</code> verbatim.</li> <li>Timezone: compare dates in the tenant's local day boundary <code>(proposed)</code>; today use UTC instants to stay consistent with the server's <code>new Date()</code> comparisons.</li> </ul> <h2 id="9-testing"><a class="header" href="#9-testing">9. Testing</a></h2> <ul> <li>Unit: <code>FineCalculator</code> (due-today → 0; +1 ms → 5; +3.5 d → 20), model parsing, enum mapping, <code>BookRow</code> badge states.</li> <li>Cubit: issue 409 paths (<code>noCopies</code>, <code>capReached</code>), return <code>notActive</code>, fine pay gating, catalog filter logic.</li> <li>Widget: sheet non-dismissible during submit; FAB hidden without permission.</li> <li>Integration: repository against the API with mocked envelope + error bodies.</li> <li>E2E (needs Mongo + Redis): the scenarios in <code>14_QA_Checklist.md</code> QA-1/2/4/6.</li> </ul> <h2 id="10-known-ceilings-ponytail-notes"><a class="header" href="#10-known-ceilings-ponytail-notes">10. Known ceilings (ponytail notes)</a></h2> <ul> <li>Overdue screen is derived data; replace with the server endpoint when the scan ships <code>(planned)</code> (IMPLEMENTATION_PLAN.md:228).</li> <li>No QR/barcode scan yet — <code>(forward-looking)</code>; keep the issue flow keyed on <code>bookId</code> so a scanner can later inject the same id.</li> <li>Analytics <code>(proposed)</code> — hook names from <code>05 §Analytics</code> when the SDK lands.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-notifications-module"><a class="header" href="#01--product-overview-notifications-module">01 — Product Overview (Notifications Module)</a></h1> <ul> <li><a href="notifications/01_Product_Overview.html#1-product-statement">1. Product Statement</a></li> <li><a href="notifications/01_Product_Overview.html#2-scope--in-v1-implemented">2. Scope — In (v1, implemented)</a></li> <li><a href="notifications/01_Product_Overview.html#3-scope--out-planned--forward-looking">3. Scope — Out (planned / forward-looking)</a></li> <li><a href="notifications/01_Product_Overview.html#4-prd-exclusion-native-mobile-app">4. PRD Exclusion: Native Mobile App</a></li> <li><a href="notifications/01_Product_Overview.html#5-how-notifications-are-created">5. How Notifications Are Created</a></li> <li><a href="notifications/01_Product_Overview.html#6-non-goals-v1">6. Non-Goals (v1)</a></li> </ul> <blockquote> <p>Single notification engine across all channels. Scope for this design pass: the <strong>in-app notification inbox</strong> as implemented in <code>src/modules/notifications/</code>. Multi-channel dispatch (email, push, WhatsApp, WebSocket) is <strong>planned</strong> — see <code>studylyon-blueprint/04-Modules/Notifications.md:11-17</code>.</p> </blockquote> <hr /> <h2 id="1-product-statement"><a class="header" href="#1-product-statement">1. Product Statement</a></h2> <p>StudyLyon users (students, teachers, parents, staff, org admins) receive a persistent, per-tenant <strong>in-app notification inbox</strong> generated from system events (account lifecycle today; homework/attendance/exams in the event pipeline). Each notification is a small record: <code>type</code>, <code>title</code>, <code>body</code>, optional <code>data</code> payload, and a <code>readAt</code> flag (<code>notification.schema.ts:15-32</code>). The inbox supports list + pagination, single & bulk mark-read, and an unread-count badge.</p> <h2 id="2-scope--in-v1-implemented"><a class="header" href="#2-scope--in-v1-implemented">2. Scope — In (v1, implemented)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Area</th><th>Status</th></tr></thead><tbody> <tr><td>Persisted in-app notifications, tenant-scoped</td><td>✅ <code>src/modules/notifications/</code></td></tr> <tr><td>Event-driven creation (<code>EventBus</code> fan-in)</td><td>✅ <code>notifications.handler.ts</code></td></tr> <tr><td>List w/ pagination + <code>unreadOnly</code> filter</td><td>✅ <code>GET /api/v1/notifications</code></td></tr> <tr><td>Unread count</td><td>✅ <code>GET /api/v1/notifications/unread-count</code></td></tr> <tr><td>Mark one read / mark all read</td><td>✅ <code>PATCH /api/v1/notifications/:id/read</code>, <code>PATCH /api/v1/notifications/read-all</code></td></tr> </tbody></table> </div> <h2 id="3-scope--out-planned--forward-looking"><a class="header" href="#3-scope--out-planned--forward-looking">3. Scope — Out (planned / forward-looking)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Area</th><th>Where</th></tr></thead><tbody> <tr><td>Email / Push / WhatsApp / WebSocket dispatch</td><td><code>Notifications.md:13</code>; <code>QUEUE.EMAILS / PUSH / WHATSAPP</code> exist (<code>queue.constants.ts:2-4</code>) but no workers consume them for notifications</td></tr> <tr><td><code>POST /api/v1/notifications/send</code> (internal send)</td><td><code>Notifications.md:24</code> — <em>(planned)</em></td></tr> <tr><td>Notification templates (i18n + interpolation)</td><td><code>Notifications.md:7</code> — <em>(planned)</em></td></tr> <tr><td>Per-user channel preferences</td><td><code>Notifications.md:7,27-28</code> — <em>(planned)</em></td></tr> <tr><td>Push/FCM device tokens</td><td><code>POST /api/v1/notifications/device-token</code> <code>(future)</code> — <code>docs/user-flows/END_TO_END_USER_FLOWS.md:788</code> — <em>(forward-looking)</em></td></tr> <tr><td>Push for coaching test reminders / DPP alerts / batch start</td><td><code>docs/IMPLEMENTATION_PLAN.md:769,859</code> — <em>(forward-looking)</em></td></tr> <tr><td>Analytics events <code>{module}.{screen}.{action}</code></td><td><em>(proposed)</em> — SDK open, see <a href="notifications/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a></td></tr> </tbody></table> </div> <h2 id="4-prd-exclusion-native-mobile-app"><a class="header" href="#4-prd-exclusion-native-mobile-app">4. PRD Exclusion: Native Mobile App</a></h2> <p>Per <code>docs/IMPLEMENTATION_PLAN.md:859</code> the mobile app is an <strong>open clarifying question, not a v1 deliverable</strong>. The PRD scopes the web app + API first; this design therefore covers the <strong>web/app-agnostic in-app inbox</strong> and keeps mobile push behind the <code>(forward-looking)</code> markers above. Any native-app-specific screen is out of scope.</p> <h2 id="5-how-notifications-are-created"><a class="header" href="#5-how-notifications-are-created">5. How Notifications Are Created</a></h2> <ol> <li>Any module emits a <code>DomainEvent</code> on the shared <code>EventBus</code>.</li> <li><code>NotificationsHandler</code> subscribes via <code>eventBus.onAny</code> (<code>notifications.handler.ts:61</code>) and maps event → notification config (<code>handler.ts:8-31</code>, <code>33-50</code>).</li> <li>Recipient resolves from <code>payload.userId ?? payload.recipientId ?? actorId</code> (<code>handler.ts:76-79</code>).</li> <li>Persist happens inside a synthetic tenant context (<code>handler.ts:81-99</code>).</li> <li>Client reads via the four authenticated endpoints.</li> </ol> <p>Today only <strong>four</strong> event types produce notifications (see <a href="notifications/03_User_Journey.html">03_User_Journey</a> and <a href="notifications/12_API_Mapping.html">12_API_Mapping</a> for the list).</p> <h2 id="6-non-goals-v1"><a class="header" href="#6-non-goals-v1">6. Non-Goals (v1)</a></h2> <ul> <li>No read-receipt analytics or per-recipient delivery stats.</li> <li>No notification detail endpoint (<code>GET /:id</code>) — detail is derived client-side from the list item + <code>data</code> payload.</li> <li>No server-side push to external providers.</li> <li>No admin-facing send UI (see <a href="notifications/08_Form_Specifications.html">08_Form_Specifications</a>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-notifications-module"><a class="header" href="#02--user-personas-notifications-module">02 — User Personas (Notifications Module)</a></h1> <ul> <li><a href="notifications/02_User_Personas.html#p1--student-school--coaching">P1 — Student (School & Coaching)</a></li> <li><a href="notifications/02_User_Personas.html#p2--teacher">P2 — Teacher</a></li> <li><a href="notifications/02_User_Personas.html#p3--parent">P3 — Parent</a></li> <li><a href="notifications/02_User_Personas.html#p4--org-admin--school-admin">P4 — Org Admin / School Admin</a></li> <li><a href="notifications/02_User_Personas.html#p5--platform-admin-super-tenant">P5 — Platform Admin (Super Tenant)</a></li> <li><a href="notifications/02_User_Personas.html#permission-matrix">Permission Matrix</a></li> </ul> <blockquote> <p>Derived from source only. Personas are the five identity classes present in StudyLyon (<code>permissions.constants.ts</code> + RBAC member model). Notification content today is account-lifecycle only; domain payloads (homework, results, fees) arrive via the event pipeline — see gaps in <a href="notifications/14_QA_Checklist.html">14_QA_Checklist</a> and <a href="notifications/01_Product_Overview.html">01_Product_Overview §3</a>.</p> </blockquote> <hr /> <h2 id="p1--student-school--coaching"><a class="header" href="#p1--student-school--coaching">P1 — Student (School & Coaching)</a></h2> <ul> <li><strong>Profile:</strong> enrolled pupil; coaching variant tracked per <code>docs/IMPLEMENTATION_PLAN.md:769</code> (test reminders, DPP due alerts, batch start, rank updates — <em>(forward-looking)</em>).</li> <li><strong>Today receives:</strong> welcome (<code>UserRegistered</code>), email-verified, verification resent, password reset — <code>notifications.handler.ts:8-50</code>.</li> <li><strong>Needs:</strong> glanceable unread badge, newest-first list, one-tap mark read.</li> <li><strong>Pain points:</strong> buried system emails; notification fatigue if every school event pings the inbox (see <a href="notifications/09_User_Behaviour.html">09_User_Behaviour</a>).</li> </ul> <h2 id="p2--teacher"><a class="header" href="#p2--teacher">P2 — Teacher</a></h2> <ul> <li><strong>Profile:</strong> staff member with teaching duties; homework-graded / attendance events route to the <code>in-app</code> queue per <code>event-queue-map.ts:14-25</code> <em>(planned)</em>.</li> <li><strong>Needs:</strong> distinguish actionable items (grade homework, results published) from informational ones; bulk "mark all read" after a teaching block.</li> <li><strong>Today:</strong> same four account events as P1.</li> </ul> <h2 id="p3--parent"><a class="header" href="#p3--parent">P3 — Parent</a></h2> <ul> <li><strong>Profile:</strong> account linked to a student; reads announcements and fee invoices. Invoice events exist (<code>event-queue-map.ts:41-42</code> → <code>emails</code> queue).</li> <li><strong>Needs:</strong> unread count on the dashboard shell; parent is a <strong>viewer</strong> — no send capabilities (<code>notification.update</code> is not in parent's default set).</li> </ul> <h2 id="p4--org-admin--school-admin"><a class="header" href="#p4--org-admin--school-admin">P4 — Org Admin / School Admin</a></h2> <ul> <li><strong>Profile:</strong> tenant administrator; manages users, fees, announcements.</li> <li><strong>Needs:</strong> assurance that system events surfaced to the right recipients; <em>(planned)</em> manual send to a target audience via <code>POST /api/v1/notifications/send</code> (<code>Notifications.md:24</code>) and channel-preferences management (<code>Notifications.md:27-28</code>).</li> <li><strong>Permissions:</strong> <code>notification.read</code>, <code>notification.update</code> (<code>permissions.constants.ts:32-33</code>).</li> </ul> <h2 id="p5--platform-admin-super-tenant"><a class="header" href="#p5--platform-admin-super-tenant">P5 — Platform Admin (Super Tenant)</a></h2> <ul> <li><strong>Profile:</strong> StudyLyon operator; cross-tenant oversight.</li> <li><strong>Needs:</strong> DLQ/retry visibility for the notification pipeline (<code>Notifications.md:41</code> — <code>NotificationFailed</code> event, <em>(planned)</em>).</li> </ul> <hr /> <h2 id="permission-matrix"><a class="header" href="#permission-matrix">Permission Matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th>Action</th><th>Student</th><th>Teacher</th><th>Parent</th><th>Org Admin</th></tr></thead><tbody> <tr><td>List my notifications</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td></tr> <tr><td>Mark read (own only)</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td></tr> <tr><td>Mark all read (own only)</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td></tr> <tr><td>Unread count</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td></tr> <tr><td>Send (internal)</td><td>—</td><td>—</td><td>—</td><td><em>(planned)</em></td></tr> </tbody></table> </div> <p>All read actions are owner-scoped by the service: <code>markAsRead</code> returns <code>null</code> when <code>recipientId !== userId</code> (<code>notifications.service.ts:54-59</code>).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journeys-notifications-module"><a class="header" href="#03--user-journeys-notifications-module">03 — User Journeys (Notifications Module)</a></h1> <ul> <li><a href="notifications/03_User_Journey.html#j1--new-user-onboarding-welcome--verification">J1 — New User Onboarding (Welcome + Verification)</a></li> <li><a href="notifications/03_User_Journey.html#j2--resend-verification-email">J2 — Resend Verification Email</a></li> <li><a href="notifications/03_User_Journey.html#j3--password-reset-completed">J3 — Password Reset Completed</a></li> <li><a href="notifications/03_User_Journey.html#j4--unread-inbox-visit-daily-loop">J4 — Unread Inbox Visit (Daily Loop)</a></li> <li><a href="notifications/03_User_Journey.html#j5--empty-state">J5 — Empty State</a></li> <li><a href="notifications/03_User_Journey.html#planned-journeys-not-implemented">Planned Journeys (not implemented)</a></li> </ul> <blockquote> <p>All journeys are grounded in the implemented flow: event → <code>EventBus</code> → <code>NotificationsHandler</code> → persistence → authenticated API (<code>notifications.handler.ts:61-99</code>).</p> </blockquote> <hr /> <h2 id="j1--new-user-onboarding-welcome--verification"><a class="header" href="#j1--new-user-onboarding-welcome--verification">J1 — New User Onboarding (Welcome + Verification)</a></h2> <ol> <li><code>UserRegistered</code> fires on signup.</li> <li>Handler maps to <code>WELCOME</code> (<code>notifications.handler.ts:41-49</code>), body interpolates <code>firstName</code> from <code>payload</code> (<code>handler.ts:44-47</code>), recipient = <code>payload.userId</code>.</li> <li>User opens the app → bell badge shows 1 unread → inbox lists "Welcome to StudyLyon".</li> <li>User verifies email → <code>EmailVerified</code> → badge 2 ("Email Verified", <code>handler.ts:16-20</code>).</li> </ol> <p><strong>Exit states:</strong> badge increments, list stays newest-first, tapping marks read.</p> <h2 id="j2--resend-verification-email"><a class="header" href="#j2--resend-verification-email">J2 — Resend Verification Email</a></h2> <ol> <li>User requests resend → <code>EmailVerificationResent</code> (<code>handler.ts:21-25</code>).</li> <li>Notification "Verification Email Sent" is created; user knows an email is coming without leaving the app.</li> </ol> <h2 id="j3--password-reset-completed"><a class="header" href="#j3--password-reset-completed">J3 — Password Reset Completed</a></h2> <ol> <li>Reset flow finishes → <code>PasswordResetCompleted</code> → "Password Reset" (<code>handler.ts:26-30</code>).</li> <li>Notification acts as confirmation + implicit security signal (someone reset it).</li> </ol> <h2 id="j4--unread-inbox-visit-daily-loop"><a class="header" href="#j4--unread-inbox-visit-daily-loop">J4 — Unread Inbox Visit (Daily Loop)</a></h2> <ol> <li>Badge shows <code>n</code> unread (<code>GET /api/v1/notifications/unread-count</code>).</li> <li>User opens <code>/notifications</code> → <code>GET /api/v1/notifications?page=1&limit=20</code> → <code>{ data, meta }</code> (<code>notifications.service.ts:39-52</code>).</li> <li>Tap item → <code>PATCH /api/v1/notifications/:id/read</code> → badge decrements.</li> <li>Scroll → infinite pagination (<code>meta.hasNext</code> → next page, <code>pagination-query.dto.ts:32-39</code>).</li> <li>Optional "Mark all read" → <code>PATCH /api/v1/notifications/read-all</code> → badge = 0.</li> </ol> <h2 id="j5--empty-state"><a class="header" href="#j5--empty-state">J5 — Empty State</a></h2> <p>First-run user (or after mark-all): list renders empty state. No notifications are ever auto-deleted; an empty list is a <em>state</em>, not a flow — see <a href="notifications/05_Screen_Inventory.html">05_Screen_Inventory §3</a>.</p> <hr /> <h2 id="planned-journeys-not-implemented"><a class="header" href="#planned-journeys-not-implemented">Planned Journeys (not implemented)</a></h2> <ul> <li><strong>J6 — Domain fan-out:</strong> homework created/submitted/graded, results published, student/teacher/staff/parent created — routed to the <code>in-app</code> queue in <code>event-queue-map.ts:10,13,22-40</code> but <strong>no worker/handler consumes them</strong> — <em>(planned)</em>, see <a href="notifications/14_QA_Checklist.html">14_QA_Checklist §G</a>.</li> <li><strong>J7 — Push on mobile:</strong> test reminder push (coaching) — <code>docs/IMPLEMENTATION_PLAN.md:859</code> — <em>(forward-looking)</em>; device-token registration <code>(future)</code> — <code>docs/user-flows/END_TO_END_USER_FLOWS.md:788</code>.</li> <li><strong>J8 — Announcements in inbox:</strong> <code>AnnouncementPublished</code> is emitted (<code>announcement.service.ts:80-91</code>) but <strong>not mapped</strong> in <code>EVENT_TYPE_MAP</code> — announcements keep their own read receipts (<code>announcement.service.ts:95-102</code>). No overlap today; decide in a later phase whether announcements fan into the inbox (see <a href="notifications/14_QA_Checklist.html">14_QA_Checklist §E</a>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-notifications-module"><a class="header" href="#04--information-architecture-notifications-module">04 — Information Architecture (Notifications Module)</a></h1> <ul> <li><a href="notifications/04_Information_Architecture.html#1-entity-model-source-of-truth-notificationschemats">1. Entity Model (source of truth: <code>notification.schema.ts</code>)</a></li> <li><a href="notifications/04_Information_Architecture.html#2-planned-entities-blueprint-not-implemented">2. Planned Entities (blueprint, not implemented)</a></li> <li><a href="notifications/04_Information_Architecture.html#3-type-taxonomy-current">3. Type Taxonomy (current)</a></li> <li><a href="notifications/04_Information_Architecture.html#4-app-navigation-placement">4. App Navigation Placement</a></li> <li><a href="notifications/04_Information_Architecture.html#5-data-flow">5. Data Flow</a></li> </ul> <blockquote> <p>Global IA baseline in <a href="notifications/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a>; this file adds the module slice.</p> </blockquote> <hr /> <h2 id="1-entity-model-source-of-truth-notificationschemats"><a class="header" href="#1-entity-model-source-of-truth-notificationschemats">1. Entity Model (source of truth: <code>notification.schema.ts</code>)</a></h2> <pre><code>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) </code></pre> <p><strong>Indexes</strong> (<code>notification.schema.ts:37-38</code>):</p> <ul> <li><code>{ tenantId, recipientId, readAt }</code> — unread-count queries.</li> <li><code>{ tenantId, recipientId, createdAt: -1 }</code> — inbox ordering.</li> </ul> <h2 id="2-planned-entities-blueprint-not-implemented"><a class="header" href="#2-planned-entities-blueprint-not-implemented">2. Planned Entities (blueprint, not implemented)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Entity</th><th>Collection</th><th>Source</th></tr></thead><tbody> <tr><td>NotificationTemplate</td><td><code>notification_templates</code></td><td><code>Notifications.md:7</code></td></tr> <tr><td>NotificationPreference</td><td><code>notification_preferences</code></td><td><code>Notifications.md:7</code></td></tr> </tbody></table> </div> <h2 id="3-type-taxonomy-current"><a class="header" href="#3-type-taxonomy-current">3. Type Taxonomy (current)</a></h2> <div class="table-wrapper"><table><thead><tr><th><code>NotificationType</code></th><th>Source Event</th><th>Title (title-cased constant)</th></tr></thead><tbody> <tr><td><code>email_verified</code></td><td><code>EmailVerified</code></td><td>Email Verified — <code>handler.ts:16-20</code></td></tr> <tr><td><code>verification_resent</code></td><td><code>EmailVerificationResent</code></td><td>Verification Email Sent — <code>handler.ts:21-25</code></td></tr> <tr><td><code>password_reset</code></td><td><code>PasswordResetCompleted</code></td><td>Password Reset — <code>handler.ts:26-30</code></td></tr> <tr><td><code>welcome</code></td><td><code>UserRegistered</code></td><td>Welcome to StudyLyon — <code>handler.ts:41-49</code></td></tr> </tbody></table> </div> <p>Types are a <strong>closed enum on the server</strong>; the client renders by <code>type</code> and must treat unknown enum values as generic "system notification".</p> <h2 id="4-app-navigation-placement"><a class="header" href="#4-app-navigation-placement">4. App Navigation Placement</a></h2> <ul> <li><strong>Bell/badge icon</strong> in the app shell (see <code>05_Screen_Inventory §2</code>).</li> <li><strong>Inbox route</strong> <code>/notifications</code> — top-level destination, reachable from the shell icon and from deep links.</li> <li><strong>Detail</strong> — no server route; derived from list item + <code>data</code> (see <code>06</code>).</li> <li><strong>Preferences (planned)</strong> — <code>GET/PATCH /api/v1/notifications/preferences</code> (<code>Notifications.md:27-28</code>) will live behind the inbox, <em>(planned)</em>.</li> </ul> <h2 id="5-data-flow"><a class="header" href="#5-data-flow">5. Data Flow</a></h2> <pre><code>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) </code></pre> <p>There is <strong>no HTTP write path</strong> — creation is event-only; <code>NotificationsService</code> is exported from the module (<code>notifications.module.ts:28</code>) for in-process reuse.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-notifications-module"><a class="header" href="#05--screen-inventory-notifications-module">05 — Screen Inventory (Notifications Module)</a></h1> <ul> <li><a href="notifications/05_Screen_Inventory.html#legend">Legend</a></li> <li><a href="notifications/05_Screen_Inventory.html#1-notification-inbox-screen-notifications">1. Notification Inbox Screen (<code>/notifications</code>)</a></li> <li><a href="notifications/05_Screen_Inventory.html#2-unread-badge-shell-widget--not-a-screen">2. Unread Badge (Shell Widget — not a screen)</a></li> <li><a href="notifications/05_Screen_Inventory.html#3-empty-state-state-of-1-not-a-separate-route">3. Empty State (state of §1, not a separate route)</a></li> <li><a href="notifications/05_Screen_Inventory.html#4-notification-detail-notificationsid-planned">4. Notification Detail (<code>/notifications/:id</code>) <code>(planned)</code></a></li> <li><a href="notifications/05_Screen_Inventory.html#5-notification-preferences-planned">5. Notification Preferences <code>(planned)</code></a></li> <li><a href="notifications/05_Screen_Inventory.html#6-admin-manual-send-planned">6. Admin Manual Send <code>(planned)</code></a></li> <li><a href="notifications/05_Screen_Inventory.html#permission-map">Permission Map</a></li> </ul> <blockquote> <p>Every screen of the Notifications module, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in <a href="notifications/../00-shared/03_Component_Library.html">00-shared/03</a>; this file enumerates which ones each screen uses with module specifics.</p> </blockquote> <hr /> <h2 id="legend-14"><a class="header" href="#legend-14">Legend</a></h2> <p>States = idle / loading / success / empty / error(offline, rate, invalid) / disabled / permission. Analytics events follow <code>{module}.{screen}.{action}</code> <em>(proposed)</em> — <a href="notifications/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a>.</p> <hr /> <h2 id="1-notification-inbox-screen-notifications"><a class="header" href="#1-notification-inbox-screen-notifications">1. Notification Inbox Screen (<code>/notifications</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Newest-first list of my persisted notifications w/ pagination</td></tr> <tr><td>Entry</td><td>shell bell icon, deep link <code>/notifications</code>, badge tap</td></tr> <tr><td>Exit</td><td>tap item → mark-read + optional route via <code>data</code> payload; back to shell</td></tr> <tr><td>Data</td><td><code>GET /api/v1/notifications?page=1&limit=20[&unreadOnly=true]</code> → <code>{ data, meta }</code> (<code>notifications.controller.ts:21-29</code>)</td></tr> <tr><td>Composition</td><td>AppBar ("Notifications" + Mark-all action), lazy list of NotificationListTile, pagination footer, empty state</td></tr> <tr><td>Primary CTA</td><td>Mark all read (<code>PATCH /api/v1/notifications/read-all</code>) — only when unread > 0</td></tr> <tr><td>States</td><td>idle, loading (first page skeleton), pagination loading (footer), empty (<code>J5</code>), error offline w/ retry, error 4xx/5xx</td></tr> <tr><td>Analytics</td><td><code>notifications.list.open</code>, <code>notifications.list.loaded</code>, <code>notifications.item.tap</code>, <code>notifications.mark_all</code></td></tr> <tr><td>Adaptive</td><td>phone single pane; tablet/desktop max-width ~640 dp centered list</td></tr> <tr><td>a11y</td><td>unread items announced as "unread", live-region on badge change</td></tr> <tr><td>Motion</td><td>list <code>m-base</code> fade-in; read swipe <code>m-fast</code> (see <a href="notifications/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>)</td></tr> </tbody></table> </div> <h2 id="2-unread-badge-shell-widget--not-a-screen"><a class="header" href="#2-unread-badge-shell-widget--not-a-screen">2. Unread Badge (Shell Widget — not a screen)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Persistent unread count on the app shell</td></tr> <tr><td>Data</td><td><code>GET /api/v1/notifications/unread-count</code> → <code>{ count }</code> (<code>controller.ts:31-35</code>)</td></tr> <tr><td>States</td><td>0 (hidden), n ≤ 99 (numeric), > 99 ("99+"), error (hidden, silent)</td></tr> <tr><td>Refresh</td><td>on app foreground, on inbox mutations, after mark-read responses</td></tr> <tr><td>a11y</td><td>semantics label "N unread notifications"</td></tr> </tbody></table> </div> <h2 id="3-empty-state-state-of-1-not-a-separate-route"><a class="header" href="#3-empty-state-state-of-1-not-a-separate-route">3. Empty State (state of §1, not a separate route)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Trigger</td><td><code>meta.totalItems === 0</code>, or <code>unreadOnly=true</code> with zero unread</td></tr> <tr><td>Copy</td><td>"No notifications" + contextual subline (unread filter → "You're all caught up")</td></tr> <tr><td>CTA</td><td>none (v1); <em>(proposed)</em> "Clear filter" when <code>unreadOnly</code> active</td></tr> <tr><td>a11y</td><td>no live-region spam; static illustration is decorative (<a href="notifications/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>)</td></tr> </tbody></table> </div> <h2 id="4-notification-detail-notificationsid-planned"><a class="header" href="#4-notification-detail-notificationsid-planned">4. Notification Detail (<code>/notifications/:id</code>) <code>(planned)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Read full body + act on <code>data</code> payload (deep-link target)</td></tr> <tr><td>Entry</td><td>tap item whose <code>data</code> carries a route hint; today no link field exists in the model (<code>notification.schema.ts:28-29</code> — free-form <code>data</code> only)</td></tr> <tr><td>Data</td><td>no <code>GET /:id</code> endpoint — must pass the full item client-side (see <a href="notifications/01_Product_Overview.html">01 §6</a>)</td></tr> <tr><td>Deep link</td><td><code>data</code> payload carries the source event fields (e.g. <code>userId</code>); a <code>targetUrl</code>/<code>entityId</code> convention is <em>(proposed)</em> for v2</td></tr> <tr><td>Note</td><td>The API gap makes this screen client-derived; mark route dormant until a <code>targetUrl</code> convention lands</td></tr> </tbody></table> </div> <h2 id="5-notification-preferences-planned"><a class="header" href="#5-notification-preferences-planned">5. Notification Preferences <code>(planned)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Per-user channel prefs (email/push/in-app)</td></tr> <tr><td>Data</td><td><code>GET /api/v1/notifications/preferences</code>, <code>PATCH /api/v1/notifications/preferences</code> (<code>Notifications.md:27-28</code>)</td></tr> <tr><td>Status</td><td>Not implemented — keep route dormant</td></tr> </tbody></table> </div> <h2 id="6-admin-manual-send-planned"><a class="header" href="#6-admin-manual-send-planned">6. Admin Manual Send <code>(planned)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Org admin pushes a notification to a target audience</td></tr> <tr><td>Data</td><td><code>POST /api/v1/notifications/send</code> (internal/service) (<code>Notifications.md:24</code>)</td></tr> <tr><td>Permissions</td><td>requires <code>notification.send</code> — absent from <code>ALL_PERMISSIONS</code> (<code>permissions.constants.ts</code>) — <em>(planned)</em></td></tr> <tr><td>Status</td><td>Not implemented — see <a href="notifications/08_Form_Specifications.html">08_Form_Specifications</a></td></tr> </tbody></table> </div> <hr /> <h2 id="permission-map"><a class="header" href="#permission-map">Permission Map</a></h2> <p>All five implemented surfaces are user-scoped reads/writes of <strong>own</strong> data; <code>notification.read</code> + <code>notification.update</code> (<code>permissions.constants.ts:32-33</code>) are declared, and every controller endpoint is <code>@UseGuards(JwtAuthGuard)</code> (<code>notifications.controller.ts:16</code>).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-notifications-module"><a class="header" href="#06--screen-specifications-notifications-module">06 — Screen Specifications (Notifications Module)</a></h1> <ul> <li><a href="notifications/06_Screen_Specifications.html#1-notification-inbox-notifications">1. Notification Inbox (<code>/notifications</code>)</a> <ul> <li><a href="notifications/06_Screen_Specifications.html#11-layout-phone">1.1 Layout (phone)</a></li> <li><a href="notifications/06_Screen_Specifications.html#12-list-item-notificationlisttile">1.2 List Item (NotificationListTile)</a></li> <li><a href="notifications/06_Screen_Specifications.html#13-appbar-actions">1.3 AppBar Actions</a></li> <li><a href="notifications/06_Screen_Specifications.html#14-data--pagination">1.4 Data & Pagination</a></li> <li><a href="notifications/06_Screen_Specifications.html#15-unread-filter-filter-chip--optional-v11">1.5 Unread Filter (Filter Chip — optional v1.1)</a></li> <li><a href="notifications/06_Screen_Specifications.html#16-states">1.6 States</a></li> <li><a href="notifications/06_Screen_Specifications.html#17-a11y">1.7 a11y</a></li> <li><a href="notifications/06_Screen_Specifications.html#18-motion">1.8 Motion</a></li> <li><a href="notifications/06_Screen_Specifications.html#19-analytics-proposed">1.9 Analytics (proposed)</a></li> </ul> </li> <li><a href="notifications/06_Screen_Specifications.html#2-unread-badge-shell">2. Unread Badge (shell)</a></li> <li><a href="notifications/06_Screen_Specifications.html#3-system-feedback-transient">3. System Feedback (transient)</a></li> </ul> <blockquote> <p>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 <a href="notifications/../00-shared/02_Design_Tokens.html">00-shared/02,03,08,09</a>; referenced by name.</p> </blockquote> <hr /> <h2 id="1-notification-inbox-notifications"><a class="header" href="#1-notification-inbox-notifications">1. Notification Inbox (<code>/notifications</code>)</a></h2> <h3 id="11-layout-phone-1"><a class="header" href="#11-layout-phone-1">1.1 Layout (phone)</a></h3> <pre><code>┌──────────────────────────────┐ │ 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] │ └──────────────────────────────┘ </code></pre> <ul> <li>Max content width 640 dp centered (tablet/desktop).</li> <li>No separate detail page in v1 — tapping an item marks it read; if <code>data</code> contains a known route hint (<em>(proposed)</em> <code>data.targetUrl</code>), navigate. <code>data</code> is the raw event payload (<code>notification.schema.ts:28-29</code>), so v1 never guesses — unknown payloads only mark read.</li> </ul> <h3 id="12-list-item-notificationlisttile"><a class="header" href="#12-list-item-notificationlisttile">1.2 List Item (NotificationListTile)</a></h3> <div class="table-wrapper"><table><thead><tr><th>Region</th><th>Content</th><th>Rules</th></tr></thead><tbody> <tr><td>Leading</td><td>type icon (see <code>11_Design_System_Mapping §3</code>)</td><td>40 dp, tinted surface</td></tr> <tr><td>Title</td><td><code>title</code> (server, required)</td><td>unread → bold; 1 line</td></tr> <tr><td>Body</td><td><code>body</code> (server, required)</td><td>2-line clamp, <code>textSecondary</code></td></tr> <tr><td>Trailing</td><td>timestamp + unread dot</td><td>relative time ("2m", "3h", "5d" — <code>00-shared/02</code>); dot = <code>readAt == null</code></td></tr> <tr><td>Tap</td><td>mark read + optional navigate</td><td>full-width hit target ≥ 48 dp</td></tr> </tbody></table> </div> <h3 id="13-appbar-actions"><a class="header" href="#13-appbar-actions">1.3 AppBar Actions</a></h3> <ul> <li><strong>Mark all read</strong> — visible only when unread > 0; disabled while a request is in flight. Calls <code>PATCH /api/v1/notifications/read-all</code> (<code>controller.ts:43-47</code>).</li> <li>Confirm dialog? <strong>No</strong> — action is reversible-cost-free; show a transient confirmation ("All notifications marked as read") instead.</li> </ul> <h3 id="14-data--pagination"><a class="header" href="#14-data--pagination">1.4 Data & Pagination</a></h3> <ul> <li>First load: <code>GET /api/v1/notifications?page=1&limit=20</code>.</li> <li>Response envelope: <code>{ data: Notification[], meta: PaginationMeta }</code> where <code>meta = { page, limit, totalItems, totalPages, hasNext, hasPrevious }</code> (<code>pagination-query.dto.ts:32-39</code>).</li> <li><strong>Infinite scroll:</strong> fetch <code>page+1</code> when last item enters viewport and <code>meta.hasNext</code>; append. Footer shows loading spinner; on failure, footer becomes a retry row (do not clear existing items).</li> <li>Pull-to-refresh: refetch page 1 and <strong>replace</strong> the list (keep scroll position if totalItems unchanged).</li> </ul> <h3 id="15-unread-filter-filter-chip--optional-v11"><a class="header" href="#15-unread-filter-filter-chip--optional-v11">1.5 Unread Filter (Filter Chip — optional v1.1)</a></h3> <ul> <li><code>unreadOnly=true</code> (<code>list-notifications.dto.ts:21-25</code>, boolean-coerced from <code>'true'</code>). Toggling refetches from page 1.</li> <li>Empty-with-filter state: "You're all caught up" + "Clear filter" <em>(proposed)</em>.</li> </ul> <h3 id="16-states"><a class="header" href="#16-states">1.6 States</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>UI</th><th>Notes</th></tr></thead><tbody> <tr><td>idle (loaded)</td><td>list</td><td>items sorted <code>createdAt</code> desc — server contract (<code>repository.ts:66</code>)</td></tr> <tr><td>first-load loading</td><td>skeleton rows (4×)</td><td>never spinner alone</td></tr> <tr><td>pagination loading</td><td>footer spinner</td><td>existing rows untouched</td></tr> <tr><td>empty</td><td>illustration + "No notifications"</td><td>see <code>05 §3</code></td></tr> <tr><td>offline (first load)</td><td>error panel + Retry</td><td>native connectivity check; cached copy if available</td></tr> <tr><td>offline (pagination)</td><td>footer "Couldn't load more — Retry"</td><td>—</td></tr> <tr><td>4xx/5xx</td><td>error panel + Retry</td><td>401/403 → shell handles auth redirect</td></tr> <tr><td>disabled</td><td>n/a — no permission-gated controls on own inbox</td><td>—</td></tr> </tbody></table> </div> <h3 id="17-a11y"><a class="header" href="#17-a11y">1.7 a11y</a></h3> <ul> <li>Unread items: <code>semantics</code> announce "unread" suffix; dot is not the only indicator (title weight differs too — <code>00-shared/09</code>).</li> <li>Badge: <code>label = "N unread notifications"</code>; updates via live region, not focus.</li> <li>Mark-all: full label "Mark all notifications as read".</li> </ul> <h3 id="18-motion-1"><a class="header" href="#18-motion-1">1.8 Motion</a></h3> <ul> <li>First-load list items: staggered fade <code>m-base</code>.</li> <li>Read transition: dot removal + weight change <code>m-fast</code>; optional swipe-to-mark-read (<code>Dismissible</code>, <code>m-fast</code>) — <em>(proposed)</em>.</li> <li>Badge count change: <code>m-fast</code> scale bounce; see <code>00-shared/08</code>.</li> </ul> <h3 id="19-analytics-proposed"><a class="header" href="#19-analytics-proposed">1.9 Analytics <em>(proposed)</em></a></h3> <p><code>notifications.list.open | loaded(n,unread) | item.tap(id,type) | item.mark_read | mark_all | filter.set(unreadOnly) | paginate(page) | error(code)</code></p> <hr /> <h2 id="2-unread-badge-shell"><a class="header" href="#2-unread-badge-shell">2. Unread Badge (shell)</a></h2> <ul> <li>Poll vs push: <strong>no WebSocket consumer exists</strong> in this module (<code>01 §3</code>) — v1 refreshes on foreground + after inbox mutations; <em>(forward-looking)</em> realtime via WebSocket <code>/ws</code> (<code>END_TO_END_USER_FLOWS.md:773-775</code>).</li> <li>Count ceiling: display <code>99+</code> above 99.</li> <li>Hide when 0; no badge flash on first open of the session.</li> </ul> <h2 id="3-system-feedback-transient"><a class="header" href="#3-system-feedback-transient">3. System Feedback (transient)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Trigger</th><th>Feedback</th></tr></thead><tbody> <tr><td>Mark-all success</td><td>SnackBar "All notifications marked as read"</td></tr> <tr><td>Mark-read failure</td><td>silent retry ×1, then snackbar; item stays unread</td></tr> <tr><td>Unread-count fetch failure</td><td>badge hidden silently (never show "-1")</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-notifications-module"><a class="header" href="#07--component-library-notifications-module">07 — Component Library (Notifications Module)</a></h1> <ul> <li><a href="notifications/07_Component_Library.html#1-component-inventory">1. Component Inventory</a></li> <li><a href="notifications/07_Component_Library.html#2-notificationlisttile-contract">2. NotificationListTile Contract</a></li> <li><a href="notifications/07_Component_Library.html#3-notificationbadge-contract">3. NotificationBadge Contract</a></li> <li><a href="notifications/07_Component_Library.html#4-reuse-notes">4. Reuse Notes</a></li> </ul> <blockquote> <p>Authoritative shared components in <a href="notifications/../00-shared/03_Component_Library.html">00-shared/03</a>; 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 <a href="notifications/12_API_Mapping.html">12_API_Mapping</a>.</p> </blockquote> <hr /> <h2 id="1-component-inventory"><a class="header" href="#1-component-inventory">1. Component Inventory</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Component</th><th>Purpose</th><th>Composed of (from <a href="notifications/../00-shared/03_Component_Library.html">00-shared/03</a>)</th><th>State</th></tr></thead><tbody> <tr><td>C1</td><td>NotificationListTile</td><td>One inbox row</td><td>Card/ListTile, Avatar(icon), Text (title/body), relative-time Text, UnreadDot</td><td>✅</td></tr> <tr><td>C2</td><td>UnreadDot</td><td>8 dp accent dot, unread only</td><td>custom Paint (no shared dep)</td><td>✅</td></tr> <tr><td>C3</td><td>NotificationBadge</td><td>Shell bell badge w/ count (0/<code>n</code>/<code>99+</code>)</td><td>Badge widget, IconButton(bell)</td><td>✅</td></tr> <tr><td>C4</td><td>InboxEmptyState</td><td>Empty/zero-unread illustration + copy</td><td>EmptyState (shared)</td><td>✅</td></tr> <tr><td>C5</td><td>MarkAllReadAction</td><td>AppBar text button, conditional visibility</td><td>TextButton</td><td>✅</td></tr> <tr><td>C6</td><td>PaginationFooter</td><td>Spinner / retry row at list end</td><td>CircularProgress, TextButton</td><td>✅</td></tr> <tr><td>C7</td><td>InboxErrorPanel</td><td>First-load error + Retry</td><td>ErrorPanel (shared)</td><td>✅</td></tr> <tr><td>C8</td><td>TypeIcon</td><td>Per-<code>type</code> leading icon mapping</td><td>Icon (mapped in <a href="notifications/11_Design_System_Mapping.html">11 §3</a>)</td><td>✅</td></tr> <tr><td>C9</td><td>FilterChip (unread)</td><td><code>unreadOnly</code> toggle</td><td>FilterChip (shared) <em>(proposed)</em></td><td>⏳</td></tr> <tr><td>C10</td><td>SwipeToMarkRead</td><td>Dismissible wrapper on C1</td><td>Dismissible <em>(proposed)</em></td><td>⏳</td></tr> </tbody></table> </div> <h2 id="2-notificationlisttile-contract"><a class="header" href="#2-notificationlisttile-contract">2. NotificationListTile Contract</a></h2> <pre><code class="language-dart">NotificationListTile({ required Notification n, // model from `15_Flutter_Implementation_Guide` required VoidCallback onTap, // marks read (if unread) + optional nav }) </code></pre> <ul> <li>Displays: <code>n.title</code>, <code>n.body</code> (2-line), relative <code>n.createdAt</code>, <code>C8</code> icon, <code>C2</code> dot iff <code>n.readAt == null</code>.</li> <li>Unread style: <code>titleMedium</code> bold + tinted surface; read: <code>titleMedium</code> regular.</li> <li>Semantics: unread suffix (see <code>06 §1.7</code>).</li> </ul> <h2 id="3-notificationbadge-contract"><a class="header" href="#3-notificationbadge-contract">3. NotificationBadge Contract</a></h2> <ul> <li>Input: <code>int count</code> (0 hides).</li> <li>Behavior: <code>99+</code> ceiling; <code>m-fast</code> scale on change; semantics label.</li> <li>Data source: <code>UnreadCountCubit</code> (<code>13_State_Management</code>), refreshed on foreground + inbox mutations.</li> </ul> <h2 id="4-reuse-notes"><a class="header" href="#4-reuse-notes">4. Reuse Notes</a></h2> <ul> <li><strong>Do not</strong> invent new buttons/inputs — reuse shared <code>AppButton</code>, <code>AppTextField</code> etc. from <code>00-shared/03</code>.</li> <li>No new stateful primitive widgets: C1/C3 hold state via Cubits, not local widget state (<code>00-shared/06</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-notifications-module"><a class="header" href="#08--form-specifications-notifications-module">08 — Form Specifications (Notifications Module)</a></h1> <ul> <li><a href="notifications/08_Form_Specifications.html#1-implicit-inputs-no-form-ui">1. Implicit Inputs (no form UI)</a> <ul> <li><a href="notifications/08_Form_Specifications.html#11-list-query--get-apiv1notifications">1.1 List query — <code>GET /api/v1/notifications</code></a></li> <li><a href="notifications/08_Form_Specifications.html#12-read-actions-no-payload">1.2 Read actions (no payload)</a></li> </ul> </li> <li><a href="notifications/08_Form_Specifications.html#2-admin-manual-send-form-planned">2. Admin Manual-Send Form <code>(planned)</code></a></li> <li><a href="notifications/08_Form_Specifications.html#3-notification-preferences-form-planned">3. Notification Preferences Form <code>(planned)</code></a></li> <li><a href="notifications/08_Form_Specifications.html#4-form-rules-summary">4. Form Rules Summary</a></li> </ul> <blockquote> <p>Notifications are <strong>system-generated</strong> — 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 <em>(planned)</em> admin form, since a send endpoint is named in the blueprint.</p> </blockquote> <hr /> <h2 id="1-implicit-inputs-no-form-ui"><a class="header" href="#1-implicit-inputs-no-form-ui">1. Implicit Inputs (no form UI)</a></h2> <h3 id="11-list-query--get-apiv1notifications"><a class="header" href="#11-list-query--get-apiv1notifications">1.1 List query — <code>GET /api/v1/notifications</code></a></h3> <div class="table-wrapper"><table><thead><tr><th>Param</th><th>Type</th><th>Constraints</th><th>Default</th><th>Source</th></tr></thead><tbody> <tr><td><code>page</code></td><td>int</td><td><code>≥ 1</code></td><td><code>1</code></td><td><code>list-notifications.dto.ts:6-11</code></td></tr> <tr><td><code>limit</code></td><td>int</td><td><code>1..100</code></td><td><code>20</code></td><td><code>list-notifications.dto.ts:13-19</code></td></tr> <tr><td><code>unreadOnly</code></td><td>bool</td><td><code>true</code>/<code>'true'</code> coerced</td><td>absent</td><td><code>list-notifications.dto.ts:21-25</code></td></tr> </tbody></table> </div> <p>Invalid params → 400 via global validation pipe (<code>main.ts</code>). Client never sends <code>unreadOnly=false</code> (omit instead).</p> <h3 id="12-read-actions-no-payload"><a class="header" href="#12-read-actions-no-payload">1.2 Read actions (no payload)</a></h3> <ul> <li><code>PATCH /api/v1/notifications/:id/read</code> — <code>:id</code> is a Mongo ObjectId.</li> <li><code>PATCH /api/v1/notifications/read-all</code> — empty body.</li> <li>Ownership: cross-user <code>:id</code> → <code>200 null</code> (<code>notifications.service.ts:54-59</code>), treated as no-op client-side.</li> </ul> <h2 id="2-admin-manual-send-form-planned"><a class="header" href="#2-admin-manual-send-form-planned">2. Admin Manual-Send Form <code>(planned)</code></a></h2> <p>Endpoint exists only in the blueprint — <code>POST /api/v1/notifications/send</code> (internal/service) (<code>Notifications.md:24</code>). Design when <code>notification.send</code> enters <code>ALL_PERMISSIONS</code> (currently absent — <code>permissions.constants.ts</code>).</p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type</th><th>Rules (proposed, from blueprint semantics)</th></tr></thead><tbody> <tr><td><code>type</code></td><td>select</td><td>from server enum; unknown → 400</td></tr> <tr><td><code>title</code></td><td>text</td><td>required, ≤ 120 chars</td></tr> <tr><td><code>body</code></td><td>textarea</td><td>required, ≤ 500 chars</td></tr> <tr><td><code>recipientIds</code></td><td>multi-select</td><td>≥ 1 user; tenant-scoped</td></tr> <tr><td><code>data</code></td><td>JSON</td><td>optional, opaque (<code>schema:28-29</code>)</td></tr> <tr><td>submit</td><td>—</td><td>async via BullMQ, never sync (<code>Notifications.md:57</code>)</td></tr> </tbody></table> </div> <h2 id="3-notification-preferences-form-planned"><a class="header" href="#3-notification-preferences-form-planned">3. Notification Preferences Form <code>(planned)</code></a></h2> <p><code>GET/PATCH /api/v1/notifications/preferences</code> (<code>Notifications.md:27-28</code>); the <code>notification_preferences</code> collection does not exist yet (<code>Notifications.md:7</code>).</p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type</th><th>Rules (proposed)</th></tr></thead><tbody> <tr><td>channel toggles (email/push/in-app)</td><td>switches</td><td>per-user, per-type optionality</td></tr> <tr><td>Save</td><td>—</td><td>PATCH with partial update; optimistic UI</td></tr> </tbody></table> </div> <h2 id="4-form-rules-summary"><a class="header" href="#4-form-rules-summary">4. Form Rules Summary</a></h2> <ul> <li><strong>v1 shipped forms: none.</strong> All mutations are idempotent-ish actions; do not build form scaffolding for planned screens (<a href="notifications/01_Product_Overview.html">01 §6</a>, ponytail).</li> <li>Validation mirrors server DTOs — client pre-validates only <code>page/limit</code> bounds to avoid 400 round-trips.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-notifications-module"><a class="header" href="#09--user-behaviour-notifications-module">09 — User Behaviour (Notifications Module)</a></h1> <ul> <li><a href="notifications/09_User_Behaviour.html#1-badge-first-behaviour">1. Badge-First Behaviour</a></li> <li><a href="notifications/09_User_Behaviour.html#2-read-on-tap">2. Read-on-Tap</a></li> <li><a href="notifications/09_User_Behaviour.html#3-mark-all-read-session">3. Mark-All-Read Session</a></li> <li><a href="notifications/09_User_Behaviour.html#4-ordering-expectation">4. Ordering Expectation</a></li> <li><a href="notifications/09_User_Behaviour.html#5-notification-fatigue-planned-feature-guard">5. Notification Fatigue (planned-feature guard)</a></li> <li><a href="notifications/09_User_Behaviour.html#6-time-perception">6. Time Perception</a></li> <li><a href="notifications/09_User_Behaviour.html#7-deep-link-navigation-proposed">7. Deep-Link Navigation (proposed)</a></li> <li><a href="notifications/09_User_Behaviour.html#8-error-resilience">8. Error Resilience</a></li> </ul> <blockquote> <p>Behavioural patterns observed/assumed for the inbox, grounded in the API contract (<code>notifications.controller.ts</code>) and the event fan-in (<code>notifications.handler.ts</code>).</p> </blockquote> <hr /> <h2 id="1-badge-first-behaviour"><a class="header" href="#1-badge-first-behaviour">1. Badge-First Behaviour</a></h2> <ul> <li>Users judge the inbox by the <strong>badge number</strong> before opening it; they expect the number to equal what the list shows. Contract requirement: <code>unread-count</code> (<code>controller.ts:31-35</code>) must agree with the count of <code>readAt == null</code> rows in the list — same source collection, same filter (<code>repository.ts:31-35</code> vs <code>repository.ts:45-50</code>), so it is consistent by construction. Client must still reconcile after mark-read mutations (see <a href="notifications/13_State_Management.html">13_State_Management</a>).</li> </ul> <h2 id="2-read-on-tap"><a class="header" href="#2-read-on-tap">2. Read-on-Tap</a></h2> <ul> <li>Typical pattern: tap item → mark read → return. The read flag must update <strong>optimistically</strong> on the tile and the badge; server <code>200 null</code> (foreign id) must not crash the tile (<code>service.ts:54-59</code>).</li> </ul> <h2 id="3-mark-all-read-session"><a class="header" href="#3-mark-all-read-session">3. Mark-All-Read Session</a></h2> <ul> <li>Users batch-clear the inbox when unread exceeds ~10. After <code>read-all</code>, they expect the badge to drop to 0 <strong>immediately</strong>; partial failures are invisible server-side (bulk update has no per-item error — <code>repository.ts:45-50</code>). Client treats the response as success if the request succeeds.</li> </ul> <h2 id="4-ordering-expectation"><a class="header" href="#4-ordering-expectation">4. Ordering Expectation</a></h2> <ul> <li>Users read newest-first and assume page 1 = today's items. Server sorts <code>createdAt: -1</code> (<code>repository.ts:66</code>); never client-sort.</li> </ul> <h2 id="5-notification-fatigue-planned-feature-guard"><a class="header" href="#5-notification-fatigue-planned-feature-guard">5. Notification Fatigue (planned-feature guard)</a></h2> <ul> <li>Per <code>docs/IMPLEMENTATION_PLAN.md:769</code> coaching tenants will generate high-volume events (test reminders, DPP due alerts, batch starts). Without per-type preferences (<code>Notifications.md:27-28</code> — <em>(planned)</em>) the inbox will flood. V1 mitigation: only four account-lifecycle events are wired today (<code>handler.ts:8-50</code>) — the event map is the volume governor. Add prefs <strong>before</strong> fanning out homework/exam events (<a href="notifications/14_QA_Checklist.html">14_QA_Checklist §G</a>).</li> </ul> <h2 id="6-time-perception"><a class="header" href="#6-time-perception">6. Time Perception</a></h2> <ul> <li>Relative timestamps ("5m ago") dominate; absolute timestamps only in accessibility text. Derived from <code>createdAt</code> (timestamps: true, <code>schema:14</code>).</li> </ul> <h2 id="7-deep-link-navigation-proposed"><a class="header" href="#7-deep-link-navigation-proposed">7. Deep-Link Navigation (proposed)</a></h2> <ul> <li>Users expect a notification tap to open the <strong>thing</strong> it announces. Today <code>data</code> is the raw event payload (<code>handler.ts:96</code> — e.g. <code>{ userId }</code>), with no <code>targetUrl</code> convention. Until v2 defines <code>data.targetUrl</code>, most taps only mark read — acceptable for v1 account-lifecycle content (welcome/verify/reset).</li> </ul> <h2 id="8-error-resilience"><a class="header" href="#8-error-resilience">8. Error Resilience</a></h2> <ul> <li>Users retry after transient failures; pagination failure must not nuke the loaded list (footer retry, <a href="notifications/06_Screen_Specifications.html">06 §1.4</a>). Offline first-load shows cached copy when available (<a href="notifications/../00-shared/06_State_Management.html">00-shared/06</a> hydration).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-notifications-module"><a class="header" href="#10--interaction-specification-notifications-module">10 — Interaction Specification (Notifications Module)</a></h1> <ul> <li><a href="notifications/10_Interaction_Specification.html#1-item-tap-mark-read--optional-navigate">1. Item Tap (mark read + optional navigate)</a></li> <li><a href="notifications/10_Interaction_Specification.html#2-unread-badge-mutations">2. Unread Badge Mutations</a></li> <li><a href="notifications/10_Interaction_Specification.html#3-mark-all-read">3. Mark All Read</a></li> <li><a href="notifications/10_Interaction_Specification.html#4-pull-to-refresh">4. Pull-to-Refresh</a></li> <li><a href="notifications/10_Interaction_Specification.html#5-infinite-scroll">5. Infinite Scroll</a></li> <li><a href="notifications/10_Interaction_Specification.html#6-unread-filter-proposed-v11">6. Unread Filter (proposed v1.1)</a></li> <li><a href="notifications/10_Interaction_Specification.html#7-swipe-to-mark-read-proposed">7. Swipe-to-Mark-Read (proposed)</a></li> <li><a href="notifications/10_Interaction_Specification.html#8-keyboard--shortcut-desktop">8. Keyboard / Shortcut (desktop)</a></li> <li><a href="notifications/10_Interaction_Specification.html#9-motion-summary">9. Motion Summary</a></li> </ul> <blockquote> <p>Precise interaction rules for the inbox + badge. Motion tokens from <a href="notifications/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>; accessibility baseline <a href="notifications/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>.</p> </blockquote> <hr /> <h2 id="1-item-tap-mark-read--optional-navigate"><a class="header" href="#1-item-tap-mark-read--optional-navigate">1. Item Tap (mark read + optional navigate)</a></h2> <ol> <li>Tap tile → if <code>readAt == null</code>: optimistic flip to read (dot off, weight down, <code>m-fast</code>) → fire <code>PATCH /api/v1/notifications/:id/read</code> (<code>controller.ts:37-41</code>).</li> <li>Response <code>200</code> with document → reconcile (no visual change needed).</li> <li>Response <code>200 null</code> (not owner) → revert? <strong>No</strong> — treat as already-read; remove from unread scope.</li> <li>Error → retry once silently; then snackbar "Couldn't update" and revert tile.</li> <li>If <code>data</code> carries a recognized route hint <em>(proposed <code>data.targetUrl</code>)</em> → navigate after successful mark; v1 has no hint → no navigation.</li> </ol> <h2 id="2-unread-badge-mutations"><a class="header" href="#2-unread-badge-mutations">2. Unread Badge Mutations</a></h2> <div class="table-wrapper"><table><thead><tr><th>Trigger</th><th>Effect</th></tr></thead><tbody> <tr><td>tap item (unread)</td><td>decrement on optimistic flip; server fail → re-increment</td></tr> <tr><td>mark-all success</td><td>→ 0 immediately</td></tr> <tr><td>inbox opened</td><td>refetch count; reconcile with list-derived count if drift</td></tr> <tr><td>app foreground / login</td><td>refetch count</td></tr> <tr><td>new notification arrives</td><td>count+1 (v1: on next fetch — no WS push, <a href="notifications/01_Product_Overview.html">01 §3</a>)</td></tr> </tbody></table> </div> <h2 id="3-mark-all-read"><a class="header" href="#3-mark-all-read">3. Mark All Read</a></h2> <ul> <li>Enabled iff <code>unread > 0</code> and no in-flight request.</li> <li>Fire <code>PATCH /api/v1/notifications/read-all</code> (<code>controller.ts:43-47</code>) → on success: badge 0, all tiles read (<code>m-fast</code> batch, no per-tile animations), snackbar confirmation.</li> <li>Failure: revert, snackbar with Retry.</li> </ul> <h2 id="4-pull-to-refresh"><a class="header" href="#4-pull-to-refresh">4. Pull-to-Refresh</a></h2> <ul> <li>Refetch page 1 (and unread-count in parallel). Replace list on success; keep scroll offset when <code>meta.totalItems</code> unchanged.</li> </ul> <h2 id="5-infinite-scroll"><a class="header" href="#5-infinite-scroll">5. Infinite Scroll</a></h2> <ul> <li>Trigger: last visible index ≥ <code>data.length - threshold(4)</code> and <code>meta.hasNext</code>.</li> <li>Fetch <code>page+1</code>; append; dedupe by <code>_id</code> (defensive — server pagination is offset-based, stable under insert-on-top drift can duplicate a row across pages; dedupe client-side, see <a href="notifications/14_QA_Checklist.html">14 §B</a>).</li> </ul> <h2 id="6-unread-filter-proposed-v11"><a class="header" href="#6-unread-filter-proposed-v11">6. Unread Filter (proposed v1.1)</a></h2> <ul> <li>Chip toggle → reset to page 1 with <code>unreadOnly</code> (<code>dto:21-25</code>); chip shows active state; empty-with-filter shows "all caught up" (<a href="notifications/05_Screen_Inventory.html">05 §3</a>).</li> </ul> <h2 id="7-swipe-to-mark-read-proposed"><a class="header" href="#7-swipe-to-mark-read-proposed">7. Swipe-to-Mark-Read (proposed)</a></h2> <ul> <li><code>Dismissible</code> end-dismiss (<code>m-fast</code>) → same as §1 (optimistic + API), no navigate. Do not allow swipe on already-read tiles.</li> </ul> <h2 id="8-keyboard--shortcut-desktop"><a class="header" href="#8-keyboard--shortcut-desktop">8. Keyboard / Shortcut (desktop)</a></h2> <ul> <li><code>m</code> = mark all read when inbox focused; <code>j/k</code> move focus; <code>Enter</code> = tap. <em>(proposed)</em> — adaptivity in <a href="notifications/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a>.</li> </ul> <h2 id="9-motion-summary"><a class="header" href="#9-motion-summary">9. Motion Summary</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Token</th><th>Note</th></tr></thead><tbody> <tr><td>item read flip</td><td><code>m-fast</code></td><td>dot fade + weight change, no slide</td></tr> <tr><td>first-load stagger</td><td><code>m-base</code></td><td>rows fade, 40 ms stagger</td></tr> <tr><td>badge count change</td><td><code>m-fast</code></td><td>scale 1→1.15→1</td></tr> <tr><td>page swap (filter/refresh replace)</td><td><code>m-base</code></td><td>crossfade list</td></tr> <tr><td>snackbar confirmations</td><td><code>m-base</code></td><td>standard toast</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-notifications-module"><a class="header" href="#11--design-system-mapping-notifications-module">11 — Design System Mapping (Notifications Module)</a></h1> <ul> <li><a href="notifications/11_Design_System_Mapping.html#1-color">1. Color</a></li> <li><a href="notifications/11_Design_System_Mapping.html#2-typography">2. Typography</a></li> <li><a href="notifications/11_Design_System_Mapping.html#3-iconography">3. Iconography</a></li> <li><a href="notifications/11_Design_System_Mapping.html#4-spacing--shape">4. Spacing / Shape</a></li> <li><a href="notifications/11_Design_System_Mapping.html#5-motion">5. Motion</a></li> <li><a href="notifications/11_Design_System_Mapping.html#6-component-mapping">6. Component Mapping</a></li> <li><a href="notifications/11_Design_System_Mapping.html#7-branding-note">7. Branding Note</a></li> </ul> <blockquote> <p>Maps the module onto <a href="notifications/../00-shared/02_Design_Tokens.html">00-shared/02</a> (Design Tokens) and <a href="notifications/../00-shared/04_Design_System_Mapping.html">00-shared/04</a> (Design System Mapping). No new tokens.</p> </blockquote> <hr /> <h2 id="1-color"><a class="header" href="#1-color">1. Color</a></h2> <div class="table-wrapper"><table><thead><tr><th>Usage</th><th>Token (from <a href="notifications/../00-shared/02_Design_Tokens.html">00-shared/02</a>)</th><th>Note</th></tr></thead><tbody> <tr><td>Unread dot / badge</td><td><code>color.primary</code> (or <code>accent</code> per brand)</td><td>only non-text indicator; must pair with weight change (<a href="notifications/06_Screen_Specifications.html">06 §1.7</a>)</td></tr> <tr><td>Unread tile surface</td><td><code>surfaceVariant</code> tint / <code>color.primary</code> at low alpha</td><td>subtle, not a full card fill</td></tr> <tr><td>Title (unread)</td><td><code>textPrimary</code> bold; (read) <code>textPrimary</code> regular</td><td>—</td></tr> <tr><td>Body</td><td><code>textSecondary</code></td><td>both states</td></tr> <tr><td>Badge count</td><td><code>onPrimary</code> on <code>primary</code></td><td>≥ 3:1 contrast (<a href="notifications/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>)</td></tr> <tr><td>Empty state / error panel</td><td><code>textSecondary</code> + surface</td><td>standard shared patterns</td></tr> </tbody></table> </div> <h2 id="2-typography-2"><a class="header" href="#2-typography-2">2. Typography</a></h2> <div class="table-wrapper"><table><thead><tr><th>Element</th><th>Token</th></tr></thead><tbody> <tr><td>AppBar title</td><td><code>titleLarge</code></td></tr> <tr><td>Tile title</td><td><code>titleMedium</code> (weight varies by read state)</td></tr> <tr><td>Tile body</td><td><code>bodyMedium</code> (2-line clamp)</td></tr> <tr><td>Timestamp</td><td><code>labelSmall</code> (<code>textSecondary</code>)</td></tr> <tr><td>Badge count</td><td><code>labelSmall</code> bold</td></tr> </tbody></table> </div> <h2 id="3-iconography"><a class="header" href="#3-iconography">3. Iconography</a></h2> <div class="table-wrapper"><table><thead><tr><th><code>type</code> (server enum)</th><th>Icon</th><th>Fallback</th></tr></thead><tbody> <tr><td><code>welcome</code></td><td>celebration/star</td><td>—</td></tr> <tr><td><code>email_verified</code></td><td>verified badge/check</td><td>—</td></tr> <tr><td><code>verification_resent</code></td><td>mail-send</td><td>—</td></tr> <tr><td><code>password_reset</code></td><td>lock/rotate</td><td>—</td></tr> <tr><td>unknown</td><td>info/bell</td><td>generic system icon — client must handle future enum values (<a href="notifications/04_Information_Architecture.html">04 §3</a>)</td></tr> </tbody></table> </div> <p>Mapping lives in one client file (see <a href="notifications/15_Flutter_Implementation_Guide.html">15_Flutter_Implementation_Guide §7</a>).</p> <h2 id="4-spacing--shape"><a class="header" href="#4-spacing--shape">4. Spacing / Shape</a></h2> <ul> <li>Tile padding: <code>space-16</code>; icon 40 dp rounded <code>radius-m</code>; gap 12.</li> <li>List max width 640 dp centered; bottom inset respects system nav bar.</li> <li>Badge: 18 dp min height pill, <code>radius-full</code>.</li> </ul> <h2 id="5-motion-1"><a class="header" href="#5-motion-1">5. Motion</a></h2> <ul> <li>All module motion via <a href="notifications/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a> tokens: <code>m-fast</code> (read flip, badge), <code>m-base</code> (list fade, page swap). No custom curves.</li> </ul> <h2 id="6-component-mapping"><a class="header" href="#6-component-mapping">6. Component Mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Module component (<a href="notifications/07_Component_Library.html">07</a>)</th><th>Shared primitive</th></tr></thead><tbody> <tr><td>C1 NotificationListTile</td><td>ListTile + Text + Icon from <a href="notifications/../00-shared/03_Component_Library.html">00-shared/03</a></td></tr> <tr><td>C3 NotificationBadge</td><td>Badge + IconButton (shared)</td></tr> <tr><td>C4 InboxEmptyState</td><td>EmptyState (shared)</td></tr> <tr><td>C7 InboxErrorPanel</td><td>ErrorPanel (shared)</td></tr> </tbody></table> </div> <h2 id="7-branding-note"><a class="header" href="#7-branding-note">7. Branding Note</a></h2> <p>This module introduces no brand assets; notification icons reuse the shared icon set. Future push-channel icons/illustrations go through <a href="notifications/../00-shared/02_Design_Tokens.html">00-shared/02</a>.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-notifications-module"><a class="header" href="#12--api-mapping-notifications-module">12 — API Mapping (Notifications Module)</a></h1> <ul> <li><a href="notifications/12_API_Mapping.html#1-implemented-endpoints">1. Implemented Endpoints</a> <ul> <li><a href="notifications/12_API_Mapping.html#11-get-apiv1notifications--list-my-notifications">1.1 <code>GET /api/v1/notifications</code> — List my notifications</a></li> <li><a href="notifications/12_API_Mapping.html#12-get-apiv1notificationsunread-count--unread-count">1.2 <code>GET /api/v1/notifications/unread-count</code> — Unread count</a></li> <li><a href="notifications/12_API_Mapping.html#13-patch-apiv1notificationsidread--mark-one-read">1.3 <code>PATCH /api/v1/notifications/:id/read</code> — Mark one read</a></li> <li><a href="notifications/12_API_Mapping.html#14-patch-apiv1notificationsread-all--mark-all-read">1.4 <code>PATCH /api/v1/notifications/read-all</code> — Mark all read</a></li> </ul> </li> <li><a href="notifications/12_API_Mapping.html#2-permissions">2. Permissions</a></li> <li><a href="notifications/12_API_Mapping.html#3-planned--forward-looking-endpoints">3. Planned / Forward-Looking Endpoints</a></li> <li><a href="notifications/12_API_Mapping.html#4-event--notification-creation-contract-no-http">4. Event → Notification Creation Contract (no HTTP)</a></li> <li><a href="notifications/12_API_Mapping.html#5-queue-event-routing-infrastructure">5. Queue-Event Routing (infrastructure)</a></li> </ul> <blockquote> <p>Exact contract for every endpoint. Base path <code>/api/v1</code> (URI versioning, <code>src/main.ts</code>). All routes require a JWT (<code>@UseGuards(JwtAuthGuard)</code>, <code>notifications.controller.ts:16</code>); success envelope via shared interceptor (<code>common/interceptors/response-envelope.interceptor.ts</code>), errors via <code>common/filters/http-exception.filter.ts</code> (see <a href="notifications/../00-shared/07_API_Conventions.html">00-shared/07</a>).</p> </blockquote> <hr /> <h2 id="1-implemented-endpoints"><a class="header" href="#1-implemented-endpoints">1. Implemented Endpoints</a></h2> <h3 id="11-get-apiv1notifications--list-my-notifications"><a class="header" href="#11-get-apiv1notifications--list-my-notifications">1.1 <code>GET /api/v1/notifications</code> — List my notifications</a></h3> <ul> <li><strong>Source:</strong> <code>notifications.controller.ts:21-29</code> → <code>service.findMyNotifications</code> (<code>notifications.service.ts:39-52</code>)</li> <li><strong>Query:</strong> <code>page</code> (int ≥1, default 1), <code>limit</code> (int 1..100, default 20), <code>unreadOnly</code> (bool) — <code>dto/list-notifications.dto.ts:6-25</code></li> <li><strong>Response 200:</strong> <pre><code class="language-json">{ "data": [ { "_id", "recipientId", "type", "title", "body", "data": {}, "readAt": null, "createdAt", "updatedAt", "version" } ], "meta": { "page", "limit", "totalItems", "totalPages", "hasNext", "hasPrevious" } } </code></pre> <code>meta</code> shape from <code>buildPaginationMeta</code> (<code>common/dto/pagination-query.dto.ts:41-55</code>).</li> <li><strong>Ordering:</strong> <code>createdAt</code> desc (<code>repository.ts:66</code>). <strong>Filter:</strong> tenant + recipient + optional <code>readAt: null</code> (<code>repository.ts:52-73</code>).</li> <li><strong>Errors:</strong> 400 (bad query), 401 (no token).</li> </ul> <h3 id="12-get-apiv1notificationsunread-count--unread-count"><a class="header" href="#12-get-apiv1notificationsunread-count--unread-count">1.2 <code>GET /api/v1/notifications/unread-count</code> — Unread count</a></h3> <ul> <li><strong>Source:</strong> <code>notifications.controller.ts:31-35</code> → <code>service.getUnreadCount</code> (<code>service.ts:66-70</code>) → <code>repo.countUnreadByRecipient</code> (<code>repository.ts:31-35</code>)</li> <li><strong>Response 200:</strong> <code>{ "count": 3 }</code></li> </ul> <h3 id="13-patch-apiv1notificationsidread--mark-one-read"><a class="header" href="#13-patch-apiv1notificationsidread--mark-one-read">1.3 <code>PATCH /api/v1/notifications/:id/read</code> — Mark one read</a></h3> <ul> <li><strong>Source:</strong> <code>notifications.controller.ts:37-41</code> → <code>service.markAsRead</code> (<code>service.ts:54-59</code>)</li> <li><strong>Ownership:</strong> <code>recipientId !== userId</code> → <strong><code>200 null</code></strong> (no-op, not 404).</li> <li><strong>Response 200:</strong> updated document (with <code>readAt</code>, <code>version+1</code> — <code>repository.ts:37-43</code>); <code>null</code> for foreign/missing id.</li> <li><strong>Route note:</strong> two-segment <code>:id/read</code> cannot collide with <code>read-all</code> (single segment) — keep this ordering on any future param route.</li> </ul> <h3 id="14-patch-apiv1notificationsread-all--mark-all-read"><a class="header" href="#14-patch-apiv1notificationsread-all--mark-all-read">1.4 <code>PATCH /api/v1/notifications/read-all</code> — Mark all read</a></h3> <ul> <li><strong>Source:</strong> <code>notifications.controller.ts:43-47</code> → <code>service.markAllAsRead</code> (<code>service.ts:61-64</code>) → bulk <code>updateMany</code> where <code>readAt: null</code> (<code>repository.ts:45-50</code>)</li> <li><strong>Response 200:</strong> <code>{}</code> (envelope with no payload).</li> </ul> <h2 id="2-permissions"><a class="header" href="#2-permissions">2. Permissions</a></h2> <div class="table-wrapper"><table><thead><tr><th>Permission</th><th>Constants</th><th>Used by</th></tr></thead><tbody> <tr><td><code>notification.read</code></td><td><code>permissions.constants.ts:32</code></td><td>list / unread-count (planned RBAC binding)</td></tr> <tr><td><code>notification.update</code></td><td><code>permissions.constants.ts:33</code></td><td>mark-read endpoints (planned RBAC binding)</td></tr> <tr><td><code>notification.send</code> (internal)</td><td><strong>not in <code>ALL_PERMISSIONS</code></strong></td><td><code>POST /notifications/send</code> — <em>(planned)</em>, blueprint <code>Notifications.md:24,68-70</code></td></tr> <tr><td><code>notification.preference.manage</code></td><td><strong>not in <code>ALL_PERMISSIONS</code></strong></td><td>preferences — <em>(planned)</em>, blueprint <code>Notifications.md:70</code></td></tr> </tbody></table> </div> <blockquote> <p>Note: no RBAC guard is attached today — only <code>JwtAuthGuard</code>; perms are declared-but-unenforced (gap, see <code>14 §D</code>).</p> </blockquote> <h2 id="3-planned--forward-looking-endpoints"><a class="header" href="#3-planned--forward-looking-endpoints">3. Planned / Forward-Looking Endpoints</a></h2> <div class="table-wrapper"><table><thead><tr><th>Endpoint</th><th>Status</th><th>Source</th></tr></thead><tbody> <tr><td><code>POST /api/v1/notifications/send</code> (internal)</td><td><em>(planned)</em></td><td><code>Notifications.md:24</code></td></tr> <tr><td><code>GET /api/v1/notifications/preferences</code></td><td><em>(planned)</em></td><td><code>Notifications.md:27</code></td></tr> <tr><td><code>PATCH /api/v1/notifications/preferences</code></td><td><em>(planned)</em></td><td><code>Notifications.md:28</code></td></tr> <tr><td><code>GET /api/v1/notification-templates</code></td><td><em>(planned)</em></td><td><code>Notifications.md:29</code></td></tr> <tr><td><code>POST /api/v1/notifications/device-token</code></td><td><code>(future)</code> — <em>(forward-looking)</em></td><td><code>END_TO_END_USER_FLOWS.md:788</code></td></tr> </tbody></table> </div> <h2 id="4-event--notification-creation-contract-no-http"><a class="header" href="#4-event--notification-creation-contract-no-http">4. Event → Notification Creation Contract (no HTTP)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Domain Event</th><th>NotificationType</th><th>Title</th><th>Source</th></tr></thead><tbody> <tr><td><code>EmailVerified</code></td><td><code>email_verified</code></td><td>Email Verified</td><td><code>handler.ts:16-20</code></td></tr> <tr><td><code>EmailVerificationResent</code></td><td><code>verification_resent</code></td><td>Verification Email Sent</td><td><code>handler.ts:21-25</code></td></tr> <tr><td><code>PasswordResetCompleted</code></td><td><code>password_reset</code></td><td>Password Reset</td><td><code>handler.ts:26-30</code></td></tr> <tr><td><code>UserRegistered</code></td><td><code>welcome</code></td><td>Welcome to StudyLyon (interpolates <code>firstName</code>, <code>handler.ts:44-47</code>)</td><td><code>handler.ts:41-49</code></td></tr> </tbody></table> </div> <p>Recipient resolution: <code>payload.userId ?? payload.recipientId ?? actorId</code> (<code>handler.ts:76-79</code>). <code>data</code> = full event payload (<code>handler.ts:96</code>). <code>NotificationCreatedEvent</code> interface (<code>events/notification-events.ts:1-10</code>) is declared but <strong>never emitted</strong> (gap <code>14 §F</code>).</p> <h2 id="5-queue-event-routing-infrastructure"><a class="header" href="#5-queue-event-routing-infrastructure">5. Queue-Event Routing (infrastructure)</a></h2> <ul> <li><code>QUEUE.EMAILS/PUSH/WHATSAPP/IN_APP</code> constants exist (<code>queue.constants.ts:1-17</code>).</li> <li><code>event-queue-map.ts:6-43</code> routes events: <code>emails</code> (welcome/reset/invoice/receipt), <code>in-app</code> (UserCreated, OrganizationCreated, Homework*, ExamResultsPublished, Student/Teacher/Staff/ParentCreated, FeeStructureCreated), <code>audit-write</code>, etc.</li> <li><strong>Gap:</strong> no BullMQ worker consumes the <code>in-app</code> queue for notifications; <code>NotificationsHandler</code> listens on the in-process <code>EventBus</code> only (<code>handler.ts:61</code>). The map is the <em>(planned)</em> fan-out contract — see <code>14 §G</code>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-notifications-module"><a class="header" href="#13--state-management-notifications-module">13 — State Management (Notifications Module)</a></h1> <ul> <li><a href="notifications/13_State_Management.html#1-cubits">1. Cubits</a> <ul> <li><a href="notifications/13_State_Management.html#11-notificationscubit--inbox-list">1.1 <code>NotificationsCubit</code> — inbox list</a></li> <li><a href="notifications/13_State_Management.html#12-unreadcountcubit--shell-badge">1.2 <code>UnreadCountCubit</code> — shell badge</a></li> </ul> </li> <li><a href="notifications/13_State_Management.html#2-state-flow">2. State Flow</a></li> <li><a href="notifications/13_State_Management.html#3-consistency-rules">3. Consistency Rules</a></li> <li><a href="notifications/13_State_Management.html#4-what-not-to-store-in-state">4. What NOT to store in state</a></li> </ul> <blockquote> <p>Flutter client. App-wide conventions in <a href="notifications/../00-shared/06_State_Management.html">00-shared/06</a> (State Management) and <a href="notifications/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a> (Flutter App Architecture); this file is the module slice.</p> </blockquote> <hr /> <h2 id="1-cubits-1"><a class="header" href="#1-cubits-1">1. Cubits</a></h2> <h3 id="11-notificationscubit--inbox-list"><a class="header" href="#11-notificationscubit--inbox-list">1.1 <code>NotificationsCubit</code> — inbox list</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Fields</th></tr></thead><tbody> <tr><td><code>NotificationsInitial</code></td><td>—</td></tr> <tr><td><code>NotificationsLoading</code></td><td>— (first page)</td></tr> <tr><td><code>NotificationsLoaded</code></td><td><code>items</code>, <code>page</code>, <code>hasNext</code>, <code>totalItems</code>, <code>loadingMore</code>, <code>unreadOnly</code></td></tr> <tr><td><code>NotificationsEmpty</code></td><td><code>unreadOnly</code> (drives copy)</td></tr> <tr><td><code>NotificationsError</code></td><td><code>message</code>, <code>retryable</code></td></tr> </tbody></table> </div> <p><strong>Events</strong></p> <ul> <li><code>LoadFirstPage({ bool unreadOnly })</code> — reset + fetch <code>?page=1&limit=20[&unreadOnly]</code>.</li> <li><code>LoadMore()</code> — guarded by <code>hasNext</code> && !<code>loadingMore</code>; appends, dedupes by <code>_id</code> (offset pagination can duplicate on insert-on-top, <a href="notifications/10_Interaction_Specification.html">10 §5</a>).</li> <li><code>MarkOneRead(id)</code> — optimistic flip, then <code>PATCH /:id/read</code>; revert on error.</li> <li><code>MarkAllRead()</code> — optimistic clear, then <code>PATCH /read-all</code>; revert on error.</li> <li><code>Refresh()</code> — reload page 1, keep scroll position.</li> </ul> <p><strong>Dependencies:</strong> <code>NotificationsApi</code> (see <a href="notifications/15_Flutter_Implementation_Guide.html">15 §4</a>), <code>UnreadCountCubit</code> (count reconciliation after mark mutations).</p> <h3 id="12-unreadcountcubit--shell-badge"><a class="header" href="#12-unreadcountcubit--shell-badge">1.2 <code>UnreadCountCubit</code> — shell badge</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Fields</th></tr></thead><tbody> <tr><td><code>UnreadCountState</code></td><td><code>count</code> (int ≥ 0), <code>loaded</code> (bool — false → hide badge silently, <a href="notifications/06_Screen_Specifications.html">06 §3</a>)</td></tr> </tbody></table> </div> <p><strong>Events:</strong> <code>Refresh()</code> (<code>GET /unread-count</code>), <code>Decrement()</code> (optimistic, from NotificationsCubit), <code>Reset()</code> (after mark-all), <code>Increment()</code> <em>(forward-looking: realtime via WebSocket <code>/ws</code> — <code>END_TO_END_USER_FLOWS.md:773-775</code>)</em>.</p> <p><strong>Refresh triggers:</strong> app foreground, inbox open, after any mark mutation (server value wins over optimistic).</p> <h2 id="2-state-flow"><a class="header" href="#2-state-flow">2. State Flow</a></h2> <pre class="mermaid">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] </pre> <h2 id="3-consistency-rules"><a class="header" href="#3-consistency-rules">3. Consistency Rules</a></h2> <ol> <li><strong>Server is truth</strong> after every mutation: optimistic UI → request → reconcile with response (doc <code>readAt</code>, or fresh <code>unread-count</code>).</li> <li><strong>Badge = server count</strong>; optimistic decrements are cosmetic and re-synced on foreground/inbox-open.</li> <li><code>unreadOnly</code> filter change <strong>resets</strong> the list (new <code>LoadFirstPage</code>).</li> <li>Pagination state lives in the Cubit, never in widget locals (<code>00-shared/06</code> — single source per feature).</li> <li>Offline hydration: cache last <code>Loaded</code> via app-wide cache layer (<code>00-shared/06 §hydration</code>); refresh on reconnect.</li> </ol> <h2 id="4-what-not-to-store-in-state"><a class="header" href="#4-what-not-to-store-in-state">4. What NOT to store in state</a></h2> <ul> <li>Relative-time strings (recomputed on rebuild).</li> <li>Icon mappings (pure functions in one file — <code>11 §3</code>).</li> <li>Auth token / tenant (shell concerns, <code>00-shared/06</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-notifications-module"><a class="header" href="#14--qa-checklist-notifications-module">14 — QA Checklist (Notifications Module)</a></h1> <ul> <li><a href="notifications/14_QA_Checklist.html#a-ordering--pagination">A. Ordering & Pagination</a></li> <li><a href="notifications/14_QA_Checklist.html#b-mark-read-semantics">B. Mark-Read Semantics</a></li> <li><a href="notifications/14_QA_Checklist.html#c-tenant--soft-delete-isolation">C. Tenant & Soft-Delete Isolation</a></li> <li><a href="notifications/14_QA_Checklist.html#d-auth--rbac">D. Auth & RBAC</a></li> <li><a href="notifications/14_QA_Checklist.html#e-announcement-overlap">E. Announcement Overlap</a></li> <li><a href="notifications/14_QA_Checklist.html#f-event-fan-out--idempotency">F. Event Fan-Out & Idempotency</a></li> <li><a href="notifications/14_QA_Checklist.html#g-volume--fatigue-planned-feature-gate">G. Volume & Fatigue (planned-feature gate)</a></li> <li><a href="notifications/14_QA_Checklist.html#h-client-ux-checks">H. Client UX Checks</a></li> </ul> <blockquote> <p>Verify against <code>notifications.controller.ts</code>, <code>notifications.service.ts</code>, <code>repositories/notifications.repository.ts</code>, <code>notifications.handler.ts</code>. Shared QA baseline in <a href="notifications/../00-shared/10_QA_Baseline.html">00-shared/10</a>.</p> </blockquote> <hr /> <h2 id="a-ordering--pagination"><a class="header" href="#a-ordering--pagination">A. Ordering & Pagination</a></h2> <ul> <li><input disabled="" type="checkbox"/> List returns <code>createdAt</code> desc — first page newest first (<code>repository.ts:66</code>).</li> <li><input disabled="" type="checkbox"/> <code>meta</code> math: <code>totalPages = max(1, ceil(total/limit))</code>, <code>hasNext = page < totalPages</code>, <code>hasPrevious = page > 1</code> (<code>pagination-query.dto.ts:46-54</code>).</li> <li><input disabled="" type="checkbox"/> <code>limit</code> clamp: <code>1..100</code> (<code>list-notifications.dto.ts:13-19</code>); <code>page ≥ 1</code>.</li> <li><input disabled="" type="checkbox"/> Empty total → <code>{ data: [], meta: { totalItems: 0, totalPages: 1, hasNext: false } }</code> — not an error.</li> <li><input disabled="" type="checkbox"/> Insert-on-top between page fetches: offset pagination can duplicate rows across pages — client dedupes by <code>_id</code> (<a href="notifications/10_Interaction_Specification.html">10 §5</a>); confirm no server crash on <code>skip</code> > collection.</li> <li><input disabled="" type="checkbox"/> <code>unreadOnly=true</code> returns only <code>readAt: null</code> (<code>service.ts:45</code>); <code>false</code>/absent returns all.</li> </ul> <h2 id="b-mark-read-semantics"><a class="header" href="#b-mark-read-semantics">B. Mark-Read Semantics</a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>PATCH /:id/read</code> sets <code>readAt</code> + increments <code>version</code> (<code>repository.ts:40</code>).</li> <li><input disabled="" type="checkbox"/> Cross-user id → <code>200 null</code>, never 404/403 and never touches the row (<code>service.ts:57</code>).</li> <li><input disabled="" type="checkbox"/> Already-read id → idempotent re-mark is harmless (<code>findOneAndUpdate</code>).</li> <li><input disabled="" type="checkbox"/> <code>read-all</code> only updates <code>readAt: null</code> docs (<code>repository.ts:46-49</code>).</li> <li><input disabled="" type="checkbox"/> <strong>Race:</strong> user taps item while <code>read-all</code> is in flight → both succeed; final state read. Client must not double-decrement badge (reconcile via server count, <a href="notifications/13_State_Management.html">13 §3</a>).</li> </ul> <h2 id="c-tenant--soft-delete-isolation"><a class="header" href="#c-tenant--soft-delete-isolation">C. Tenant & Soft-Delete Isolation</a></h2> <ul> <li><input disabled="" type="checkbox"/> All queries pass through <code>scopedFilter</code> (tenantId + deletedAt) via <code>BaseRepository</code> — verify a second tenant's notifications are invisible (<code>repository.ts:24-26,38-39,47-48,61</code>).</li> <li><input disabled="" type="checkbox"/> Handler writes under synthetic tenant context from the event (<code>handler.ts:81-99</code>) — tenantId must come from the event, never ambient state.</li> <li><input disabled="" type="checkbox"/> Soft-deleted recipient docs: notifications still queryable by id (hard delete not cascaded) — document intended behaviour.</li> </ul> <h2 id="d-auth--rbac"><a class="header" href="#d-auth--rbac">D. Auth & RBAC</a></h2> <ul> <li><input disabled="" type="checkbox"/> All 4 routes reject unauthenticated requests (<code>controller.ts:16</code>).</li> <li><input disabled="" type="checkbox"/> <strong>Gap:</strong> <code>notification.read</code> / <code>notification.update</code> declared (<code>permissions.constants.ts:32-33</code>) but no RBAC guard attached — confirm intended (JWT-only for v1) and log decision.</li> <li><input disabled="" type="checkbox"/> <em>(planned)</em> <code>notification.send</code> must be added to <code>ALL_PERMISSIONS</code> before any send endpoint ships.</li> </ul> <h2 id="e-announcement-overlap"><a class="header" href="#e-announcement-overlap">E. Announcement Overlap</a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>AnnouncementPublished</code> (<code>announcement.service.ts:80-91</code>) does <strong>not</strong> create inbox notifications today — confirm product intent (separate read receipts <code>announcement.service.ts:95-102</code> vs inbox). If inbox fan-out is wanted, map the event in <code>EVENT_TYPE_MAP</code> and define recipient expansion (audience resolution is in <code>announcement.service.ts:109-148</code>).</li> </ul> <h2 id="f-event-fan-out--idempotency"><a class="header" href="#f-event-fan-out--idempotency">F. Event Fan-Out & Idempotency</a></h2> <ul> <li><input disabled="" type="checkbox"/> Handler is <code>onAny</code> — only mapped types create notifications; unknown events are no-ops (<code>handler.ts:71-74</code>). Adding a new event requires an explicit map entry (feature toggle, <a href="notifications/09_User_Behaviour.html">09 §5</a>).</li> <li><input disabled="" type="checkbox"/> <strong>Gap:</strong> <code>NotificationCreatedEvent</code> interface exists (<code>events/notification-events.ts:1-10</code>) but is never emitted — either emit it (audit/websocket use) or delete it.</li> <li><input disabled="" type="checkbox"/> <strong>Gap:</strong> <code>event-queue-map.ts:10-40</code> routes ~18 events to the <code>in-app</code> BullMQ queue with no worker — creating notifications for them is <em>(planned)</em>; do not half-wire (queue without worker = silent loss, violates <code>Notifications.md:57</code>).</li> <li><input disabled="" type="checkbox"/> Retry/DLQ for notification creation: handler failures are logged only (<code>handler.ts:62-67</code>) — no retry today. Acceptable for in-process events; revisit with queue-based fan-out.</li> <li><input disabled="" type="checkbox"/> Duplicate events (e.g. double <code>EmailVerified</code> emit) create duplicate notifications — document idempotency key if dedupe is required.</li> </ul> <h2 id="g-volume--fatigue-planned-feature-gate"><a class="header" href="#g-volume--fatigue-planned-feature-gate">G. Volume & Fatigue (planned-feature gate)</a></h2> <ul> <li><input disabled="" type="checkbox"/> Before fanning out homework/exam/fee events, ship per-type preferences (<code>Notifications.md:27-28</code>) — see <a href="notifications/09_User_Behaviour.html">09 §5</a>.</li> <li><input disabled="" type="checkbox"/> Coaching push (test reminders, DPP, batch start) is gated on mobile-app decision (<code>IMPLEMENTATION_PLAN.md:859</code>).</li> </ul> <h2 id="h-client-ux-checks"><a class="header" href="#h-client-ux-checks">H. Client UX Checks</a></h2> <ul> <li><input disabled="" type="checkbox"/> Badge ≤ 99+ display; hidden on <code>loaded=false</code> (<a href="notifications/06_Screen_Specifications.html">06 §2</a>).</li> <li><input disabled="" type="checkbox"/> Badge agrees with list unread count on inbox open.</li> <li><input disabled="" type="checkbox"/> Offline first-load → error panel + retry; pagination failure → footer retry, list intact (<a href="notifications/06_Screen_Specifications.html">06 §1.6</a>).</li> <li><input disabled="" type="checkbox"/> a11y: unread suffix semantics, badge label, contrast (<a href="notifications/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>).</li> <li><input disabled="" type="checkbox"/> Analytics events <code>{module}.{screen}.{action}</code> wired <em>(proposed)</em> (<a href="notifications/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-notifications-module"><a class="header" href="#15--flutter-implementation-guide-notifications-module">15 — Flutter Implementation Guide (Notifications Module)</a></h1> <ul> <li><a href="notifications/15_Flutter_Implementation_Guide.html#1-data-model">1. Data Model</a></li> <li><a href="notifications/15_Flutter_Implementation_Guide.html#2-pagination-envelope">2. Pagination Envelope</a></li> <li><a href="notifications/15_Flutter_Implementation_Guide.html#3-api-client">3. API Client</a></li> <li><a href="notifications/15_Flutter_Implementation_Guide.html#4-repository">4. Repository</a></li> <li><a href="notifications/15_Flutter_Implementation_Guide.html#5-cubits">5. Cubits</a></li> <li><a href="notifications/15_Flutter_Implementation_Guide.html#6-widgets">6. Widgets</a></li> <li><a href="notifications/15_Flutter_Implementation_Guide.html#7-typeicon-map">7. Type→Icon Map</a></li> <li><a href="notifications/15_Flutter_Implementation_Guide.html#8-badge-integration">8. Badge Integration</a></li> <li><a href="notifications/15_Flutter_Implementation_Guide.html#9-deep-links-proposed">9. Deep Links (proposed)</a></li> <li><a href="notifications/15_Flutter_Implementation_Guide.html#10-push--fcm-forward-looking--do-not-implement-in-v1">10. Push / FCM (forward-looking — do not implement in v1)</a></li> </ul> <blockquote> <p>Client implementation guide. App architecture baseline in <a href="notifications/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a>, state conventions in <a href="notifications/../00-shared/06_State_Management.html">00-shared/06</a>. All endpoints from <a href="notifications/12_API_Mapping.html">12_API_Mapping</a>.</p> </blockquote> <hr /> <h2 id="1-data-model-1"><a class="header" href="#1-data-model-1">1. Data Model</a></h2> <pre><code class="language-dart">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; } </code></pre> <p><code>fromJson</code> must tolerate <code>data</code> = null/absent (<code>notification.schema.ts:28-29</code>).</p> <h2 id="2-pagination-envelope"><a class="header" href="#2-pagination-envelope">2. Pagination Envelope</a></h2> <pre><code class="language-dart">class PageMeta { final int page, limit, totalItems, totalPages; final bool hasNext, hasPrevious; } </code></pre> <p>Parsed from <code>meta</code> (<code>pagination-query.dto.ts:32-39</code>); deserialize <code>data</code> + <code>meta</code> inside the shared envelope (<code>00-shared/07</code>).</p> <h2 id="3-api-client-1"><a class="header" href="#3-api-client-1">3. API Client</a></h2> <div class="table-wrapper"><table><thead><tr><th>Method</th><th>Call</th></tr></thead><tbody> <tr><td><code>fetchInbox({page, limit, unreadOnly})</code></td><td><code>GET /api/v1/notifications</code></td></tr> <tr><td><code>fetchUnreadCount()</code></td><td><code>GET /api/v1/notifications/unread-count</code> → <code>{count}</code></td></tr> <tr><td><code>markRead(id)</code></td><td><code>PATCH /api/v1/notifications/:id/read</code> — treat <code>200 null</code> as success no-op</td></tr> <tr><td><code>markAllRead()</code></td><td><code>PATCH /api/v1/notifications/read-all</code></td></tr> </tbody></table> </div> <p>Base path <code>/api/v1</code> from app config; auth header via shared client (<code>00-shared/11 §auth</code>).</p> <h2 id="4-repository"><a class="header" href="#4-repository">4. Repository</a></h2> <ul> <li><code>NotificationsRepository</code> (domain layer): thin wrapper over the API client; holds the Cubits' data sources; no widget imports.</li> <li>Client-side dedupe by <code>id</code> on append (offset pagination drift, <code>10 §5</code>).</li> </ul> <h2 id="5-cubits-1"><a class="header" href="#5-cubits-1">5. Cubits</a></h2> <p><code>NotificationsCubit</code> + <code>UnreadCountCubit</code> per <code>13_State_Management</code> — states, events, and the consistency rules (optimistic → reconcile with server truth) are specified there verbatim.</p> <h2 id="6-widgets"><a class="header" href="#6-widgets">6. Widgets</a></h2> <div class="table-wrapper"><table><thead><tr><th>Widget</th><th>File suggestion</th><th>Builds</th></tr></thead><tbody> <tr><td><code>NotificationInboxScreen</code></td><td><code>features/notifications/inbox_screen.dart</code></td><td>AppBar (title + MarkAllReadAction), list, PaginationFooter, EmptyState, ErrorPanel</td></tr> <tr><td><code>NotificationListTile</code></td><td><code>features/notifications/widgets/notification_list_tile.dart</code></td><td>C1 (<code>07</code>)</td></tr> <tr><td><code>NotificationBadge</code></td><td><code>features/notifications/widgets/notification_badge.dart</code></td><td>C3 — used by AppShell</td></tr> <tr><td><code>notification_icons.dart</code></td><td><code>features/notifications/widgets/notification_icons.dart</code></td><td>type→icon map (<code>11 §3</code>)</td></tr> </tbody></table> </div> <ul> <li>List: <code>ListView.builder</code> + <code>ScrollController</code> near-end detection (<code>0.7 * maxScrollExtent</code>) → <code>LoadMore</code>.</li> <li>Pull-to-refresh: <code>RefreshIndicator</code> → <code>Refresh()</code> event.</li> <li>Tap: optimistic <code>MarkOneRead</code> then optional nav via <code>data.targetUrl</code> <em>(proposed)</em> — v1 no navigation.</li> </ul> <h2 id="7-typeicon-map"><a class="header" href="#7-typeicon-map">7. Type→Icon Map</a></h2> <pre><code class="language-dart">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, }; </code></pre> <h2 id="8-badge-integration"><a class="header" href="#8-badge-integration">8. Badge Integration</a></h2> <ul> <li>AppShell owns <code>UnreadCountCubit</code>; refreshes on foreground via <code>WidgetsBindingObserver</code>.</li> <li>After inbox mutations, <code>NotificationsCubit</code> emits → <code>UnreadCountCubit.Refresh()</code>.</li> <li>Hide badge when <code>loaded == false</code>.</li> </ul> <h2 id="9-deep-links-proposed"><a class="header" href="#9-deep-links-proposed">9. Deep Links <em>(proposed)</em></a></h2> <ul> <li>Route <code>/notifications</code> registered in the shell router.</li> <li><code>data.targetUrl</code> convention (v2): tap → mark read → <code>go_router.push(targetUrl)</code>. Validate <code>targetUrl</code> against an allowlist before navigation.</li> </ul> <h2 id="10-push--fcm-forward-looking--do-not-implement-in-v1"><a class="header" href="#10-push--fcm-forward-looking--do-not-implement-in-v1">10. Push / FCM <em>(forward-looking — do not implement in v1)</em></a></h2> <p>Blocked on: device-token endpoint (<code>END_TO_END_USER_FLOWS.md:788</code>), mobile-app decision (<code>IMPLEMENTATION_PLAN.md:859</code>), channel prefs (<code>Notifications.md:27-28</code>). When unblocked: register token after login, map <code>data</code> payload → notification tap → same deep-link path as §9.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-organizations-module"><a class="header" href="#01--product-overview-organizations-module">01 — Product Overview (Organizations Module)</a></h1> <ul> <li><a href="organizations/01_Product_Overview.html#1-purpose">1. Purpose</a></li> <li><a href="organizations/01_Product_Overview.html#2-business-goals">2. Business goals</a></li> <li><a href="organizations/01_Product_Overview.html#3-user-goals">3. User goals</a></li> <li><a href="organizations/01_Product_Overview.html#4-stakeholders">4. Stakeholders</a></li> <li><a href="organizations/01_Product_Overview.html#5-why-this-exists">5. Why this exists</a></li> <li><a href="organizations/01_Product_Overview.html#6-dependencies-module--platform">6. Dependencies (module + platform)</a></li> <li><a href="organizations/01_Product_Overview.html#7-success-metrics">7. Success metrics</a></li> <li><a href="organizations/01_Product_Overview.html#8-edge-cases">8. Edge cases</a></li> <li><a href="organizations/01_Product_Overview.html#9-assumptions">9. Assumptions</a></li> <li><a href="organizations/01_Product_Overview.html#10-assumptions--open-questions">10. Assumptions & Open Questions</a></li> <li><a href="organizations/01_Product_Overview.html#11-glossary">11. Glossary</a></li> </ul> <blockquote> <p>StudyLyon — multi-tenant ERP / School Management API. This package designs the <strong>Organizations module client</strong> (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, events, queue routes, and wire contracts are derived directly from <code>src/modules/organizations/**</code>, <code>src/modules/settings/**</code>, <code>src/modules/feature-flags/**</code>, <code>src/modules/rbac/**</code>, <code>src/modules/auth/**</code>, <code>src/infrastructure/workers/tenant-purge.worker.ts</code>, and <code>studylyon-blueprint/04-Modules/Organizations.md</code>. No feature is invented; gaps are flagged in §10 and the module Assumptions & Open Questions.</p> </blockquote> <hr /> <h2 id="1-purpose-15"><a class="header" href="#1-purpose-15">1. Purpose</a></h2> <p>Organizations is the <strong>tenant root</strong> of the platform: it owns the lifecycle of every institution (creation, profile, branding, settings, feature flags, subscription, offboarding) and, together with Auth's <code>register</code>, provisions the tenant's first administrator. It is the foundation every other module's data hangs off — every business document carries the tenant's <code>tenantId</code> (slug) and inherits its configuration (timezone, currency, academic year, attendance rules, theme, feature toggles).</p> <div class="table-wrapper"><table><thead><tr><th>Responsibility</th><th>Source</th></tr></thead><tbody> <tr><td>Tenant (institution) creation + provisioning (default roles seeded)</td><td><code>organizations.service.ts:37</code> <code>create()</code></td></tr> <tr><td>Slug + domain uniqueness (platform-wide, not tenant-scoped)</td><td><code>organizations.service.ts:40-50</code>; <code>organization.schema.ts:32-51</code></td></tr> <tr><td>Org profile (contact, address, timezone, currency, academic year, logo)</td><td><code>organizations.schema.ts:28-155</code></td></tr> <tr><td>Org embedded settings (attendance rules, academic, theme)</td><td><code>update-organization-settings.dto.ts:4-30</code>; <code>organizations.controller.ts:56-72</code></td></tr> <tr><td>Org feature-flag map (<code>metadata.featureFlags</code>)</td><td><code>organizations.controller.ts:74-89</code>; <code>organizations.service.ts:139-154</code></td></tr> <tr><td>Standalone key/value settings per tenant (upsert, groups)</td><td><code>settings.controller.ts:24-53</code>; <code>setting.schema.ts:7-38</code></td></tr> <tr><td>Standalone feature flags per tenant (enabled/label/module)</td><td><code>feature-flags.controller.ts:23-58</code>; <code>feature-flag.schema.ts:7-27</code></td></tr> <tr><td>Subscription plan/status + lifecycle status</td><td><code>organization.schema.ts:6-25, 82-127</code></td></tr> <tr><td>Tenant offboarding: soft delete + async purge (30-day retention)</td><td><code>organizations.service.ts:156-159</code>; <code>tenant-purge.worker.ts:15-47</code></td></tr> <tr><td>First admin: <code>POST /auth/register</code> binds user to tenant + <code>org_admin</code></td><td><code>auth.service.ts:54-121</code>; <code>register.dto.ts:27-29</code></td></tr> <tr><td>Domain event <code>OrganizationCreated</code> → in-app notification job</td><td><code>organizations.service.ts:59-66</code>; <code>event-queue-map.ts:13</code></td></tr> </tbody></table> </div> <h2 id="2-business-goals-14"><a class="header" href="#2-business-goals-14">2. Business goals</a></h2> <div class="table-wrapper"><table><thead><tr><th>Goal</th><th>Measure</th><th>Source</th></tr></thead><tbody> <tr><td>Tenant provisioning in one call, ready for first login</td><td><code>POST /organizations</code> returns org; <code>POST /auth/register</code> returns usable JWT pair</td><td><code>organizations.service.ts:37-68</code>; <code>auth.service.ts:54-121</code></td></tr> <tr><td>Platform-wide uniqueness of identity handles</td><td>slug + domain unique across all tenants → 409 <code>DUPLICATE_RESOURCE</code></td><td><code>organizations.service.ts:40-50,107-122</code></td></tr> <tr><td>Safe offboarding</td><td>soft delete immediate, hard purge after 30 days, idempotent worker</td><td><code>tenant-purge.worker.ts:15,26-47</code></td></tr> <tr><td>Config without hot-document writes</td><td>key/value <code>settings</code> + <code>feature_flags</code> collections, upsert semantics</td><td><code>setting.schema.ts:38</code>; <code>feature-flag.schema.ts:26</code></td></tr> <tr><td>Tenant-configurable UX</td><td>org <code>branding</code> + settings <code>theme</code> drive client <code>primary</code> override (<code>(proposed)</code> wire-up)</td><td><code>organization.schema.ts:114-120</code>; <code>update-organization-settings.dto.ts:26-29</code></td></tr> <tr><td>No cross-tenant data leakage</td><td>JWT <code>tenantId</code> claim; repositories inject scope; 403/404 never leak</td><td><code>base.repository.ts:20-30</code>; <code>MULTI_TENANCY.md §4</code></td></tr> </tbody></table> </div> <h2 id="3-user-goals-12"><a class="header" href="#3-user-goals-12">3. User goals</a></h2> <ul> <li><strong>Super Admin (platform):</strong> create tenants, review all tenants, suspend/delete offboarding tenants; never operate inside a tenant's data.</li> <li><strong>Org Admin:</strong> 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.</li> <li><strong>Invited admin:</strong> accept invitation, log in, and inherit the same org-admin surface.</li> <li><strong>Platform Ops:</strong> understand purge timing (30 days) and confirm offboarding consequences.</li> </ul> <h2 id="4-stakeholders-12"><a class="header" href="#4-stakeholders-12">4. Stakeholders</a></h2> <p>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.</p> <h2 id="5-why-this-exists-13"><a class="header" href="#5-why-this-exists-13">5. Why this exists</a></h2> <p>Every school/coaching on the platform is a tenant. Without Organizations, there is no tenant to scope anything to: auth registers users <em>into</em> a tenant, RBAC seeds roles <em>per</em> tenant, settings/feature-flags are keyed by <code>tenantId</code>. 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.</p> <h2 id="6-dependencies-module--platform"><a class="header" href="#6-dependencies-module--platform">6. Dependencies (module + platform)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Dependency</th><th>Role</th><th>Status</th></tr></thead><tbody> <tr><td>Auth (register/login)</td><td>binds first admin to tenant, <code>org_admin</code> role</td><td>implemented</td></tr> <tr><td>RBAC (<code>seedDefaults</code>, members)</td><td>seeds DEFAULT_ROLES per tenant at org creation</td><td>implemented (<code>organizations.service.ts:58</code>)</td></tr> <tr><td>Settings module</td><td>standalone key/value config surface</td><td>implemented</td></tr> <tr><td>Feature-flags module</td><td>standalone toggle surface</td><td>implemented</td></tr> <tr><td>BullMQ <code>TENANT_PURGE</code> queue</td><td>purge worker (30-day retention)</td><td>queue + worker implemented; enqueue <code>(planned)</code> — see OQ-5</td></tr> <tr><td>Storage (<code>STORAGE_PROVIDER</code>)</td><td>logo upload → <code>logoFileId</code> / <code>branding.logo</code></td><td>interface exists; providers <code>(planned)</code></td></tr> <tr><td><code>EventBus</code> → <code>eventQueueMap</code></td><td><code>OrganizationCreated</code> → <code>in-app</code>/<code>org-created-notification</code></td><td>implemented (<code>event-queue-map.ts:13</code>)</td></tr> <tr><td>Swagger</td><td>endpoint documentation</td><td>implemented (<code>main.ts:61-63</code>)</td></tr> </tbody></table> </div> <h2 id="7-success-metrics-10"><a class="header" href="#7-success-metrics-10">7. Success metrics</a></h2> <ul> <li>Tenant provisioning (org + first admin login) < 15 min (<a href="organizations/../00-shared/01_Product_Overview.html">00-shared/01 §2</a>).</li> <li><code>POST /organizations</code> p95 < 300 ms (org insert + role seeding).</li> <li>Zero slug/domain duplicates reach the DB (pre-check + unique index).</li> <li>Offboarding: soft-delete effective immediately; purge job idempotent; nothing purged before 30 days.</li> <li>No 5xx on settings/flag bulk saves; <code>settings</code> + <code>feature-flags</code> upsert always idempotent.</li> <li>Cross-tenant reads structurally impossible (repository scope); tenant-list surface gated to platform — see OQ-4.</li> </ul> <h2 id="8-edge-cases-5"><a class="header" href="#8-edge-cases-5">8. Edge cases</a></h2> <div class="table-wrapper"><table><thead><tr><th>Edge case</th><th>Behaviour</th><th>Source</th></tr></thead><tbody> <tr><td>Slug collision (auto or explicit)</td><td>409 <code>DUPLICATE_RESOURCE</code> with the conflicting slug</td><td><code>organizations.service.ts:40-44</code></td></tr> <tr><td>Domain collision with another org</td><td>409; update path checks <code>existing.id !== id</code></td><td><code>organizations.service.ts:46-50,115-122</code></td></tr> <tr><td>No slug sent</td><td>derived from name via <code>slugify</code> (lowercase, strip, hyphenate)</td><td><code>organizations.service.ts:38,161-169</code></td></tr> <tr><td>Create never takes a status</td><td>forced <code>ONBOARDING</code>; activation via later PATCH</td><td><code>organizations.service.ts:55</code></td></tr> <tr><td><code>PATCH :id/settings</code> sends partial object</td><td>full-replace <code>$set {settings}</code> — missing groups are <strong>wiped</strong>; client must send all groups</td><td><code>organizations.service.ts:134</code></td></tr> <tr><td><code>PATCH :id/feature-flags</code> sends partial map</td><td>full-replace <code>$set metadata.featureFlags</code> — missing keys deleted</td><td><code>organizations.service.ts:150</code></td></tr> <tr><td>Invalid ObjectId in <code>:id</code></td><td>CastError → 400 <code>VALIDATION_ERROR</code> "Invalid resource identifier."</td><td><code>http-exception.filter.ts:47-55,92</code></td></tr> <tr><td>Delete of unknown/soft-deleted id</td><td>404 <code>RESOURCE_NOT_FOUND</code></td><td><code>organizations.service.ts:158</code></td></tr> <tr><td>Register with email already used</td><td>409 "User with this email already exists."</td><td><code>auth.service.ts:56-58</code></td></tr> <tr><td>Register with unknown tenantId</td><td>accepted — no org-existence check in <code>register</code> (see OQ-2)</td><td><code>auth.service.ts:60-69</code></td></tr> <tr><td>Soft-deleted org</td><td>excluded from all queries (<code>isDeleted:false</code> filter)</td><td><code>organizations.repository.ts:15-19</code></td></tr> <tr><td>Purge runs while new soft-deletes exist</td><td>only deletes docs <code>deletedAt</code> older than 30-day cutoff; idempotent</td><td><code>tenant-purge.worker.ts:32-42</code></td></tr> <tr><td>Cross-tenant id access</td><td>403/404, never leak existence</td><td><a href="organizations/07_Component_Library.html">07_API_Conventions.md §6</a></td></tr> </tbody></table> </div> <h2 id="9-assumptions"><a class="header" href="#9-assumptions">9. Assumptions</a></h2> <ol> <li><strong>Client scope flag (matches <a href="organizations/../00-shared/01_Product_Overview.html">00-shared/01 §9</a>):</strong> native mobile apps are out of Phase 1 (<code>PRODUCT_REQUIREMENTS_DOCUMENT.md:144</code>); these docs are forward-looking full client specs against the implemented API. Module-specific "forward-looking" marks apply to push/QR/deep links.</li> <li>Organization surface is <strong>admin-only</strong> (<code>/organization</code> destination, <a href="organizations/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §2</a>).</li> <li>The tenant identifier in JWT and every tenant-scoped collection is the <strong>org slug string</strong>; the org document's <code>_id</code> is a separate ObjectId (see OQ-1 for the self-view gap).</li> <li>Client-side validation (lengths, hex color regex, working-day ranges) is <code>(proposed)</code> — the backend DTOs impose no min/max beyond <code>@IsEmail</code>, enums, and object types.</li> <li><code>(planned)</code> items come from <code>studylyon-blueprint/04-Modules/Organizations.md</code> and <code>docs/IMPLEMENTATION_PLAN.md</code> (R2 provider, rate-limit tiers, institution type).</li> <li>Analytics events are <code>(proposed)</code> contract only (<a href="organizations/10_Interaction_Specification.html">10_QA_Baseline.md §8</a>).</li> </ol> <h2 id="10-assumptions--open-questions"><a class="header" href="#10-assumptions--open-questions">10. Assumptions & Open Questions</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Item</th><th>Status / Impact</th></tr></thead><tbody> <tr><td>OQ-1</td><td><strong>No self-org endpoint.</strong> <code>GET /organizations/:id</code> needs the org <code>_id</code>, but JWT carries only the slug (<code>auth.service.ts:461</code>). Client cannot fetch its own org profile without a cross-tenant listing. <strong>Resolution <code>(planned)</code>:</strong> <code>GET /organizations/me</code> (or slug-resolved lookup); until then client may use <code>GET /organizations?q=<slug></code> as stopgap (see OQ-4 risk).</td><td>Org Overview screen</td></tr> <tr><td>OQ-2</td><td><code>register</code> does not verify the org exists or is active (<code>auth.service.ts:60-69</code>). Any slug registers an admin. Client should surface "tenant not found" guidance; server validation <code>(planned)</code>.</td><td>Registration journey</td></tr> <tr><td>OQ-3</td><td><code>OrganizationUpdated</code> event interface exists (<code>organization-created.event.ts:10-16</code>) but is <strong>never emitted</strong> by <code>update()</code>. No audit signal for profile changes today.</td><td>Audit trail</td></tr> <tr><td>OQ-4</td><td><code>OrganizationsRepository</code> is <strong>not tenant-scoped</strong> (<code>organizations.repository.ts:15-19</code> — only <code>isDeleted</code> filter) and the controller has <strong>no JWT/RBAC guard</strong> (<code>organizations.controller.ts:1-90</code>). A tenant admin calling <code>GET /organizations</code> today sees every tenant (slug + domain). <strong>Client must gate the tenants list + tenant detail to platform admin only; server guard/scope <code>(planned)</code>.</strong></td><td>Isolation, tenants list</td></tr> <tr><td>OQ-5</td><td>Blueprint: "Offboarding triggers <code>TenantPurgeJob</code>" (<code>Organizations.md:61</code>) — but <code>remove()</code> only soft-deletes; <strong>nothing enqueues</strong> <code>QUEUE.TENANT_PURGE</code>. Worker purges <em>all</em> soft-deleted docs ≥30 days across all models. Enqueue + per-tenant purge payload <code>(planned)</code>.</td><td>Offboarding</td></tr> <tr><td>OQ-6</td><td>Blueprint lists a separate <code>organization_settings</code> collection (<code>Organizations.md:7</code>); code keeps settings <strong>embedded</strong> in <code>organizations.settings</code> <strong>and</strong> a generic <code>settings</code> collection. Two surfaces exist; sync between them is unspecified.</td><td>Settings UI</td></tr> <tr><td>OQ-7</td><td>No <code>organization.create</code> permission in <code>permissions.constants.ts:1-97</code> — create is platform-only by role design; client hardcodes it as such.</td><td>Tenants list</td></tr> </tbody></table> </div> <h2 id="11-glossary"><a class="header" href="#11-glossary">11. Glossary</a></h2> <div class="table-wrapper"><table><thead><tr><th>Term</th><th>Meaning</th><th>Source</th></tr></thead><tbody> <tr><td>Tenant</td><td>One institution; identified by its slug in JWT + every <code>tenantId</code> field</td><td><code>MULTI_TENANCY.md §1</code></td></tr> <tr><td>Slug</td><td>Unique lowercase URL-safe handle; auto-derived from name</td><td><code>organizations.service.ts:161-169</code></td></tr> <tr><td>Organization status</td><td><code>onboarding / active / inactive / suspended</code></td><td><code>organization.schema.ts:20-25</code></td></tr> <tr><td>Subscription plan/status</td><td><code>free/basic/premium/enterprise</code>; <code>active/inactive/suspended/trial</code></td><td><code>organization.schema.ts:6-18</code></td></tr> <tr><td>Settings (embedded)</td><td><code>attendance {gracePeriod,lateThreshold,halfDayThreshold,workingDays}</code> / <code>academic {gradingScale,passingPercentage}</code> / <code>theme {primaryColor,secondaryColor}</code></td><td><code>update-organization-settings.dto.ts:4-30</code></td></tr> <tr><td>Feature flags (map)</td><td><code>metadata.featureFlags: Record<string,boolean></code> — tenant-level toggles</td><td><code>organizations.service.ts:139-154</code></td></tr> <tr><td>Settings (collection)</td><td><code>{tenantId,key,value,group}</code> unique per tenant+key; groups academic/attendance/grading/notification/theme/general</td><td><code>setting.schema.ts:7-38</code></td></tr> <tr><td>Purge</td><td>Worker deleting all <code>isDeleted</code> docs older than 30 days (all models)</td><td><code>tenant-purge.worker.ts:15,32-42</code></td></tr> <tr><td>Envelope</td><td><code>{success,message,data,meta?,timestamp,requestId}</code></td><td><a href="organizations/07_Component_Library.html">07_API_Conventions.md §2-3</a></td></tr> <tr><td>Permission</td><td><code>resource.action</code> strings incl. <code>organization.*</code>, <code>settings.*</code>, <code>feature-flags.*</code></td><td><code>permissions.constants.ts:2-5,75-80</code></td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-organizations-module"><a class="header" href="#02--user-personas-organizations-module">02 — User Personas (Organizations Module)</a></h1> <ul> <li><a href="organizations/02_User_Personas.html#p1--super-admin-platform">P1 — Super Admin (platform)</a></li> <li><a href="organizations/02_User_Personas.html#p2--org-admin">P2 — Org Admin</a></li> <li><a href="organizations/02_User_Personas.html#p3--invited-admin">P3 — Invited Admin</a></li> <li><a href="organizations/02_User_Personas.html#p4--platform-ops-secondary">P4 — Platform Ops (secondary)</a></li> <li><a href="organizations/02_User_Personas.html#persona--permission--surface-matrix">Persona × permission × surface matrix</a></li> </ul> <blockquote> <p>Roles that touch the Organizations surface. Permissions are <strong>exact</strong> from <code>permissions.constants.ts</code> + <code>role.schema.ts</code>. Note: no <code>organization.create</code> permission exists — provisioning is a platform-admin responsibility by role design (OQ-7 in <a href="organizations/01_Product_Overview.html">01</a>). The client hides/denies surfaces per permission; server remains authoritative.</p> </blockquote> <hr /> <h2 id="p1--super-admin-platform"><a class="header" href="#p1--super-admin-platform">P1 — Super Admin (platform)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Real-world role</td><td>Platform operator / SaaS vendor operations</td></tr> <tr><td>System role</td><td><code>platform_admin</code> (<code>role.schema.ts:9-16</code>) — priority 1000, <code>permissions: []</code>, operates via <code>isPlatformAdmin</code> bypass (<code>jwt-auth.guard.ts:54</code>; <code>base.repository.ts:21-23</code>)</td></tr> <tr><td>Frequency</td><td>Daily (onboarding queue), bursts during migration/ops</td></tr> <tr><td>Entry</td><td><code>/admin/tenants</code> (platform surface, hidden from tenant admins)</td></tr> <tr><td>Permissions (exact)</td><td>No explicit <code>organization.*</code> needed — platform bypass; <code>ALL_PERMISSIONS</code> available if role grants (not granted by default)</td></tr> </tbody></table> </div> <p><strong>Goals</strong></p> <ul> <li>Provision a school in one call: name + optional slug/domain/contact/address/timezone/currency/plan/branding/metadata (<code>create-organization.dto.ts:57-112</code>).</li> <li>Oversee the whole tenant fleet: search by name/slug (<code>organizations.service.ts:78-83</code>), sort, paginate.</li> <li>Suspend problem tenants (<code>PATCH :id</code> → <code>status: suspended</code>, <code>update-organization.dto.ts:130-133</code>) and offboard departed customers (<code>DELETE :id</code>, soft delete).</li> </ul> <p><strong>Pain points</strong></p> <ul> <li>OQ-1: no self-org endpoint exists, but platform admin <em>does</em> have full <code>_id</code> visibility — pain is on the tenant side, not theirs.</li> <li>Offboarding looks final but data survives 30 days (worker retention) — must communicate to customer.</li> <li>Slug/domain conflicts surface as raw 409 messages ("Organization with slug X already exists." — <code>organizations.service.ts:41-43</code>).</li> </ul> <p><strong>Needs / restrictions</strong></p> <ul> <li>Needs create/update/delete + list; must <strong>not</strong> operate tenant data from this surface.</li> <li>Restrictions: none enforced in code today for this controller (OQ-4) — client still gates by <code>isPlatformAdmin</code>.</li> </ul> <p><strong>Mental model:</strong> "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.</p> <h2 id="p2--org-admin"><a class="header" href="#p2--org-admin">P2 — Org Admin</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Real-world role</td><td>Principal / Director / IT admin of one institution</td></tr> <tr><td>System role</td><td><code>org_admin</code> — priority 100, <code>permissions: ALL_PERMISSIONS</code> (<code>role.schema.ts:17-24</code>), including all <code>organization.*</code>, <code>settings.*</code>, <code>feature-flags.*</code></td></tr> <tr><td>Frequency</td><td>Daily (settings rarely; feature flags occasionally; profile quarterly)</td></tr> <tr><td>Entry</td><td><code>/organization</code> (self surface)</td></tr> </tbody></table> </div> <p><strong>Permissions (exact subset relevant to this module)</strong> — <code>permissions.constants.ts</code>:</p> <div class="table-wrapper"><table><thead><tr><th>Permission</th><th>Line</th><th>Screens</th></tr></thead><tbody> <tr><td><code>organization.read</code></td><td>:2</td><td>Org Overview, Branding read</td></tr> <tr><td><code>organization.update</code></td><td>:3</td><td>Org Edit, Branding, Academic year, Subscription</td></tr> <tr><td><code>organization.delete</code></td><td>:4</td><td>Offboarding (self-delete — destructive, gated)</td></tr> <tr><td><code>organization.settings.update</code></td><td>:5</td><td>Settings tabs (embedded settings path)</td></tr> <tr><td><code>settings.read</code> / <code>settings.update</code> / <code>settings.delete</code></td><td>:75-77</td><td>Settings tabs (standalone <code>settings</code> collection path)</td></tr> <tr><td><code>feature-flags.read</code> / <code>feature-flags.update</code> / <code>feature-flags.delete</code></td><td>:78-80</td><td>Feature flags screen</td></tr> <tr><td><code>user.create</code></td><td>:8</td><td>Invite admin (user creation)</td></tr> <tr><td><code>rbac.member.create</code> / <code>rbac.member.read</code></td><td>:15-16</td><td>Invite admin (membership)</td></tr> </tbody></table> </div> <p><strong>Goals</strong></p> <ul> <li>Understand at a glance what the institution is configured as (timezone, currency, academic year, plan, status).</li> <li>Fix a mistake fast: wrong timezone, wrong working days, wrong late threshold — before the next attendance run.</li> <li>Make the app "feel like the school": logo + brand colors.</li> <li>Turn on/off features (e.g., biometric, WhatsApp) as contracts/priorities change.</li> </ul> <p><strong>Pain points</strong></p> <ul> <li>OQ-6: settings exist in two places (embedded + <code>settings</code> collection) — risk of seeing stale/duplicated config.</li> <li>OQ-1: no "my organization" endpoint — today the app cannot fetch the org profile without the org <code>_id</code>.</li> <li>Full-replace semantics: saving a partial settings/flag form silently wipes sibling groups/keys (<code>organizations.service.ts:134,150</code>).</li> <li><code>PATCH :id/feature-flags</code> accepts <code>Record<string,boolean></code> — no key whitelist, no label; the UI must maintain its own flag catalog.</li> </ul> <p><strong>Needs / restrictions</strong></p> <ul> <li>Needs full read+write on self profile; delete is destructive → typed confirm.</li> <li>Must not see other tenants; must not see platform surface.</li> <li>Timezone/currency pickers over free text (server only validates <code>IsString</code> — <code>create-organization.dto.ts:86-94</code>).</li> </ul> <p><strong>Mental model:</strong> "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.</p> <h2 id="p3--invited-admin"><a class="header" href="#p3--invited-admin">P3 — Invited Admin</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Real-world role</td><td>Second administrator (e.g., vice principal, office manager)</td></tr> <tr><td>System role</td><td><code>org_admin</code> again, or a custom role with a permission subset (<code>rbac.controller.ts:39-43</code>)</td></tr> <tr><td>Frequency</td><td>Daily; configure rarely</td></tr> <tr><td>Entry</td><td>Invitation link/email → <code>POST /auth/register</code> (<code>register.dto.ts:4-29</code>) or login</td></tr> <tr><td>Permissions</td><td>Whatever the org admin granted at invite (roles), evaluated server-side via <code>getPermissionsForUser</code> (<code>rbac.service.ts:44-73</code>)</td></tr> </tbody></table> </div> <p><strong>Goals</strong></p> <ul> <li>Get in with zero friction: invite email → register (first/last name, email, phone, password ≥ 8) → usable session (register returns access+refresh pair — <code>auth.service.ts:95-99</code>).</li> <li>Be able to act on the org surface only where granted; see the same screens as P2 minus denied actions.</li> </ul> <p><strong>Pain points</strong></p> <ul> <li>No invitation token mechanism exists in code (register is open; OQ-2) — invite = create user + add member role, then hand over credentials/registration.</li> <li>If registered with a mistyped tenant slug, they land in the wrong/no tenant silently.</li> </ul> <p><strong>Needs / restrictions</strong></p> <ul> <li>Needs register form with tenant slug; needs <code>user.create</code> + <code>rbac.member.create</code> from the inviting admin.</li> <li>Restricted by their role's permission set — the client mirrors it and re-checks on 403.</li> </ul> <p><strong>Mental model:</strong> "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 <code>onboarding</code>.</p> <h2 id="p4--platform-ops-secondary"><a class="header" href="#p4--platform-ops-secondary">P4 — Platform Ops (secondary)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Real-world role</td><td>Support/DevOps</td></tr> <tr><td>System role</td><td>Any platform role or direct DB/queue access</td></tr> <tr><td>Frequency</td><td>Incident-driven</td></tr> <tr><td>Entry</td><td>BullMQ dashboard (<code>/admin/queues</code>, <code>main.ts:65-70</code>) + tenant detail</td></tr> <tr><td>Permissions</td><td>n/a (observability)</td></tr> </tbody></table> </div> <p><strong>Goals</strong></p> <ul> <li>Verify purge timing: worker deletes only <code>isDeleted && deletedAt < now-30d</code> (<code>tenant-purge.worker.ts:32-42</code>).</li> <li>Replay <code>OrganizationCreated</code> notifications from the <code>in-app</code> queue (<code>event-queue-map.ts:13</code>).</li> <li>Diagnose "tenant disappeared" — soft-deleted orgs are invisible to every query (<code>organizations.repository.ts:15-19</code>).</li> </ul> <hr /> <h2 id="persona--permission--surface-matrix"><a class="header" href="#persona--permission--surface-matrix">Persona × permission × surface matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th>Surface</th><th>P1 Super Admin</th><th>P2 Org Admin</th><th>P3 Invited Admin</th></tr></thead><tbody> <tr><td>Tenants list (<code>/admin/tenants</code>)</td><td>✅ (platform flag)</td><td>❌ hidden</td><td>❌ hidden</td></tr> <tr><td>Tenant detail + edit + suspend</td><td>✅</td><td>❌ (own org only)</td><td>❌</td></tr> <tr><td>Org Overview (self)</td><td>✅ (via list)</td><td>✅ <code>organization.read</code></td><td>✅ if role has it</td></tr> <tr><td>Org Edit / Branding</td><td>✅</td><td>✅ <code>organization.update</code></td><td>role-gated</td></tr> <tr><td>Settings tabs</td><td>✅ (any tenant via <code>:id</code>)</td><td>✅ <code>organization.settings.update</code> + <code>settings.*</code></td><td>role-gated</td></tr> <tr><td>Feature flags</td><td>✅</td><td>✅ <code>feature-flags.*</code></td><td>role-gated</td></tr> <tr><td>Invite admin</td><td>✅</td><td>✅ <code>user.create</code> + <code>rbac.member.create</code></td><td>❌ (unless custom)</td></tr> <tr><td>Offboard (delete)</td><td>✅</td><td>✅ <code>organization.delete</code> (typed confirm)</td><td>❌</td></tr> </tbody></table> </div> <p><em>Server remains authoritative: 403 <code>PERMISSION_DENIED</code> / 404 <code>RESOURCE_NOT_FOUND</code> handling per <a href="organizations/../00-shared/06_State_Management.html">00-shared/06 §5</a>.</em></p> <div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journeys-organizations-module"><a class="header" href="#03--user-journeys-organizations-module">03 — User Journeys (Organizations Module)</a></h1> <ul> <li><a href="organizations/03_User_Journey.html#1-tenant-registration--onboarding-super-admin--first-admin">1. Tenant registration & onboarding (Super Admin → first admin)</a></li> <li><a href="organizations/03_User_Journey.html#2-org-profile-edit">2. Org profile edit</a></li> <li><a href="organizations/03_User_Journey.html#3-branding-configuration">3. Branding configuration</a></li> <li><a href="organizations/03_User_Journey.html#4-settings-update-attendance--academic--theme">4. Settings update (attendance / academic / theme)</a></li> <li><a href="organizations/03_User_Journey.html#5-feature-flag-toggling">5. Feature-flag toggling</a></li> <li><a href="organizations/03_User_Journey.html#6-tenant-offboarding--purge">6. Tenant offboarding / purge</a> <ul> <li><a href="organizations/03_User_Journey.html#journey--robustness-matrix">Journey × robustness matrix</a></li> </ul> </li> </ul> <blockquote> <p>End-to-end journeys computed from <code>organizations.controller.ts</code>, <code>organizations.service.ts</code>, <code>settings.controller.ts</code>, <code>feature-flags.controller.ts</code>, <code>auth.service.ts</code> <code>register()</code>, and <code>tenant-purge.worker.ts</code>. 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. <code>(planned)</code> / <code>(forward-looking)</code> marks per global rules.</p> </blockquote> <hr /> <h2 id="1-tenant-registration--onboarding-super-admin--first-admin"><a class="header" href="#1-tenant-registration--onboarding-super-admin--first-admin">1. Tenant registration & onboarding (Super Admin → first admin)</a></h2> <pre><code>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 </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>Decision points:</strong> explicit slug vs auto-slugify; plan (default <code>free</code> — <code>create-organization.dto.ts:96-102</code>); domain (must be globally unique); whether to seed branding/metadata now or later.</li> <li><strong>Loading:</strong> submit button spinner, anti-double-submit; provisioning includes role seeding (may take ~100–300 ms).</li> <li><strong>Failures/recovery:</strong> 409 → edit slug/domain inline, resubmit; 400 → field errors; offline → form blocked with guidance (<code>10_QA_Baseline.md §2</code>), draft kept in memory.</li> <li><strong>Exit:</strong> success → tenant detail; back → list (draft lost — warn on unsaved).</li> <li><strong>Abandonment:</strong> cancel mid-form → nothing persisted (create is atomic server-side; no partial org).</li> <li><strong>Timeout/session expiry:</strong> 401 → silent refresh, fail → re-login preserving draft (<code>00-shared/06 §3.6</code>).</li> <li><strong>Permission denial:</strong> non-platform user → route hidden + 403 screen.</li> <li><strong>Conflict:</strong> two admins creating the same slug → second gets 409; retry with new slug.</li> <li><strong>Multi-device:</strong> creation visible to other platform admins on next refresh/pull.</li> <li><strong>Deep links / push / QR / email:</strong> <code>(forward-looking)</code> invite email with slug link; QR for onboarding stations.</li> </ul> <h2 id="2-org-profile-edit"><a class="header" href="#2-org-profile-edit">2. Org profile edit</a></h2> <pre><code>entry: /organization → "Edit profile" intent: fix name, slug, domain, contact, address, logo, timezone, currency, academic year </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>Entry points:</strong> org overview header, avatar menu → Organization, quick action.</li> <li><strong>Decision points:</strong> changing <code>slug</code> is identity-critical (breaks tenantId link for future registers — warn: "existing admins unaffected, new registrations must use the new slug"); domain must be unique.</li> <li><strong>Loading:</strong> skeleton on load, spinner on save. <strong>Back:</strong> unsaved-changes guard (dialog) → discard/keep editing.</li> <li><strong>Timeout/session expiry/permission denial:</strong> standard (P2 needs <code>organization.update</code>).</li> <li><strong>Offline:</strong> read from last-good cache, banner; write blocked with guidance (no offline queue defined for this module — <code>00-shared/07 §10</code>).</li> <li><strong>Multi-device conflict:</strong> two admins edit → last write wins; <code>version</code> increments but no optimistic-lock guard in repo (<code>organizations.repository.ts:49-58</code> — <code>findOneAndUpdate</code> unconditional); UI refreshes on focus.</li> </ul> <h2 id="3-branding-configuration"><a class="header" href="#3-branding-configuration">3. Branding configuration</a></h2> <pre><code>entry: /organization → "Branding" tab intent: set brand colors + logo so the tenant app carries the school identity </code></pre> <pre class="mermaid">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) </pre> <ul> <li><strong>Entry:</strong> branding tab; <strong>exit:</strong> back to org overview.</li> <li><strong>Failure:</strong> upload failure → retry upload, form state kept; 400/429 standard.</li> <li><strong>Offline:</strong> color changes are local-only until reconnect (no write queue — blocked).</li> <li><strong>Permission denial:</strong> needs <code>organization.update</code>.</li> <li><strong>Multi-device:</strong> branding change applies to all devices on next theme refresh; no realtime push today (<code>(forward-looking)</code> WS topic <code>org.branding.updated</code>).</li> </ul> <h2 id="4-settings-update-attendance--academic--theme"><a class="header" href="#4-settings-update-attendance--academic--theme">4. Settings update (attendance / academic / theme)</a></h2> <pre><code>entry: /organization/settings (tabs: general, attendance, academic, grading, notification, theme) intent: adjust attendance rules (grace/late/half-day/working days), grading, colors </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>Critical:</strong> the API <strong>replaces</strong> the whole <code>settings</code> object; the client form must always submit every group (merged from last-known server state) or sibling tabs' data is wiped (OQ-6, <code>organizations.service.ts:134</code>).</li> <li><strong>Parallel surface:</strong> standalone <code>PUT /api/v1/settings/bulk</code> (<code>settings.controller.ts:43-47</code>) targets the <code>settings</code> collection — used by future module-specific config; keep the two surfaces visually separated ("Organization settings" vs "System settings") to avoid confusion.</li> <li><strong>Decision points:</strong> numeric bounds for <code>gracePeriod/lateThreshold/halfDayThreshold</code> and <code>workingDays</code> 0–6 are <code>(proposed)</code> client-side — no server min/max (<code>update-organization-settings.dto.ts:8-12</code>).</li> <li><strong>Abandonment/timeout/offline/permission</strong> (needs <code>organization.settings.update</code>): standard patterns as §2.</li> </ul> <h2 id="5-feature-flag-toggling"><a class="header" href="#5-feature-flag-toggling">5. Feature-flag toggling</a></h2> <pre><code>entry: /organization → "Feature flags" tab intent: switch tenant-level capabilities on/off (biometric, SMS, WhatsApp, AI reports …) </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>Decision points:</strong> flag catalog (key + label + module) is client-maintained; the API has no key whitelist and <code>PATCH :id/feature-flags</code> accepts any <code>Record<string,boolean></code>. Unknown keys render from the standalone <code>GET /api/v1/feature-flags</code> collection (<code>feature-flags.controller.ts:23-28</code>) which also carries labels/descriptions (schema <code>feature-flag.schema.ts:15-22</code>).</li> <li><strong>Optimistic:</strong> toggles are safe mutations → optimistic + rollback per <code>00-shared/06 §3.5</code>.</li> <li><strong>Effect visibility:</strong> toggles gate other modules' UI via <code>FeatureFlagsCubit</code> (<code>00-shared/06 §4</code>); propagation to other devices on next fetch (<code>(forward-looking)</code> WS <code>org.feature-flags.updated</code>).</li> <li><strong>Permission:</strong> needs <code>feature-flags.update</code> (org_admin has it; custom roles may not).</li> </ul> <h2 id="6-tenant-offboarding--purge"><a class="header" href="#6-tenant-offboarding--purge">6. Tenant offboarding / purge</a></h2> <pre><code>entry: tenant detail → "Delete tenant" (platform) or Org Overview → "Delete organization" (self) intent: remove a tenant from the platform </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>Decision points:</strong> typed confirm (type org name) because the operation is destructive-ish (soft delete; reversible by DB restore within 30 days — <strong>no restore endpoint</strong> in code).</li> <li><strong>Recovery:</strong> accidental delete → contact platform ops; hard purge after 30 days is irreversible.</li> <li><strong>Loading:</strong> delete spinner on confirm; <strong>exit:</strong> back to tenants list (item gone).</li> <li><strong>Timeouts/offline/conflicts:</strong> standard; multi-device: other admins see the tenant vanish on refresh.</li> <li><strong>Email/push entry:</strong> <code>(forward-looking)</code> offboarding confirmation email.</li> </ul> <hr /> <h3 id="journey--robustness-matrix"><a class="header" href="#journey--robustness-matrix">Journey × robustness matrix</a></h3> <div class="table-wrapper"><table><thead><tr><th>Journey</th><th>Permission denial</th><th>Offline</th><th>Timeout/session</th><th>Conflict</th><th>Multi-device</th></tr></thead><tbody> <tr><td>1. Provision</td><td>403 screen</td><td>write blocked, draft kept</td><td>refresh→re-login, draft kept</td><td>409 slug</td><td>visible on refresh</td></tr> <tr><td>2. Profile edit</td><td>hidden action / 403</td><td>read cache, write blocked</td><td>refresh→re-login</td><td>last-write-wins, no optimistic lock</td><td>refresh on focus</td></tr> <tr><td>3. Branding</td><td>403</td><td>local color pick only</td><td>refresh→re-login</td><td>last-write-wins</td><td>next theme refresh</td></tr> <tr><td>4. Settings</td><td>403</td><td>read cache, write blocked</td><td>refresh→re-login</td><td>full-replace wipe risk (submit-all-groups)</td><td>refresh on focus</td></tr> <tr><td>5. Flags</td><td>403 + rollback</td><td>optimistic only (no offline queue)</td><td>refresh→re-login</td><td>last-write-wins map replace</td><td>next fetch</td></tr> <tr><td>6. Purge</td><td>403</td><td>blocked</td><td>refresh→re-login</td><td>double-delete → 404 → refresh</td><td>gone on refresh</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-organizations-module"><a class="header" href="#04--information-architecture-organizations-module">04 — Information Architecture (Organizations Module)</a></h1> <ul> <li><a href="organizations/04_Information_Architecture.html#1-navigation-placement">1. Navigation placement</a></li> <li><a href="organizations/04_Information_Architecture.html#2-screen-hierarchy-self-surface">2. Screen hierarchy (self surface)</a></li> <li><a href="organizations/04_Information_Architecture.html#3-screen-hierarchy-platform-surface">3. Screen hierarchy (platform surface)</a></li> <li><a href="organizations/04_Information_Architecture.html#4-modal-hierarchy-per-00-shared05-5">4. Modal hierarchy (per <code>00-shared/05 §5</code>)</a></li> <li><a href="organizations/04_Information_Architecture.html#5-screen-relationships">5. Screen relationships</a></li> <li><a href="organizations/04_Information_Architecture.html#6-routes--deep-links">6. Routes & deep links</a></li> <li><a href="organizations/04_Information_Architecture.html#7-nested-nav--state-survival">7. Nested nav & state survival</a></li> <li><a href="organizations/04_Information_Architecture.html#8-quick-actions-fabs-context-menus">8. Quick actions, FABs, context menus</a></li> <li><a href="organizations/04_Information_Architecture.html#9-global-search-integration">9. Global search integration</a></li> </ul> <blockquote> <p>Module IA within the global shell (<a href="organizations/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a>). The module owns <strong>two surfaces</strong>: the <strong>tenant self surface</strong> (Org Admin, <code>/organization</code>) and the <strong>platform surface</strong> (Super Admin, <code>/admin/tenants</code>). Routes are <code>(planned)</code> — the client repo does not exist; they follow <code>go_router</code> conventions from <a href="organizations/../00-shared/11_Flutter_App_Architecture.html">00-shared/11 §6</a>.</p> </blockquote> <hr /> <h2 id="1-navigation-placement"><a class="header" href="#1-navigation-placement">1. Navigation placement</a></h2> <div class="table-wrapper"><table><thead><tr><th>Surface</th><th>Destination</th><th>Shell slot</th><th>Visible to</th></tr></thead><tbody> <tr><td>Organization</td><td><code>/organization</code> (Settings group, <a href="organizations/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §2</a>)</td><td>Drawer/rail: "Settings" section, above "Users & Roles"</td><td>Org Admin + custom roles with any <code>organization.*</code>/<code>settings.*</code> perm</td></tr> <tr><td>Tenants (platform)</td><td><code>/admin/tenants</code></td><td>Drawer/rail: "Platform" section</td><td><code>isPlatformAdmin</code> only</td></tr> </tbody></table> </div> <ul> <li>Both surfaces hidden and unroutable for everyone else (<a href="organizations/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §1</a>).</li> <li>Tenants surface lives under a separate "Platform" group so tenant admins never see it.</li> </ul> <h2 id="2-screen-hierarchy-self-surface"><a class="header" href="#2-screen-hierarchy-self-surface">2. Screen hierarchy (self surface)</a></h2> <pre><code>/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) </code></pre> <h2 id="3-screen-hierarchy-platform-surface"><a class="header" href="#3-screen-hierarchy-platform-surface">3. Screen hierarchy (platform surface)</a></h2> <pre><code>/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) </code></pre> <h2 id="4-modal-hierarchy-per-00-shared05-5-1"><a class="header" href="#4-modal-hierarchy-per-00-shared05-5-1">4. Modal hierarchy (per <code>00-shared/05 §5</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Level</th><th>Used for</th><th>Widget</th></tr></thead><tbody> <tr><td>Snackbar</td><td>save success, toggle rollback, purge scheduled</td><td><code>AppSnackbar</code></td></tr> <tr><td>Bottom sheet</td><td>timezone picker (searchable list), currency picker, color presets</td><td><code>AppBottomSheet</code></td></tr> <tr><td>Dialog</td><td>unsaved-changes guard, delete confirm, status change confirm (suspend/activate), registration-slug handoff</td><td><code>AppDialog</code></td></tr> <tr><td>Full-screen</td><td>Org Edit, Branding, Invite admin, Tenant create</td><td>Page routes</td></tr> <tr><td>Loader</td><td>only global submit waits; screens prefer skeletons</td><td><code>AppProgress</code></td></tr> </tbody></table> </div> <p><strong>Destructive rules (<code>00-shared/05 §5</code>):</strong> <code>organization.delete</code> needs <strong>typed confirm</strong> (type the org name); suspend needs a plain confirm dialog with consequence copy.</p> <h2 id="5-screen-relationships-5"><a class="header" href="#5-screen-relationships-5">5. Screen relationships</a></h2> <ul> <li>Overview → Edit → back to Overview (refresh). Overview → Branding → back (refresh).</li> <li>Overview → Settings tabs: each tab fetches the <em>same</em> org doc (<code>GET :id/settings</code>); one global "Save all" submits the merged full <code>settings</code> object (full-replace — <code>organizations.service.ts:134</code>).</li> <li>Overview → Feature flags: fetches <code>GET :id/feature-flags</code> (map) merged with standalone <code>GET /feature-flags</code> (catalog: labels/descriptions/module).</li> <li>Master-detail on tablet/desktop for <code>/admin/tenants</code> (<code>00-shared/05 §3</code>); push-on-top on phone; breadcrumbs on desktop at depth ≥2 (<code>00-shared/05 §3</code>).</li> </ul> <h2 id="6-routes--deep-links"><a class="header" href="#6-routes--deep-links">6. Routes & deep links</a></h2> <div class="table-wrapper"><table><thead><tr><th>Route</th><th>Screen</th><th>Guard</th></tr></thead><tbody> <tr><td><code>/organization</code></td><td>Org Overview</td><td>authed + any <code>organization.*</code>/<code>settings.*</code></td></tr> <tr><td><code>/organization/edit</code></td><td>Org Edit</td><td><code>organization.update</code></td></tr> <tr><td><code>/organization/branding</code></td><td>Branding</td><td><code>organization.update</code></td></tr> <tr><td><code>/organization/settings</code></td><td>Settings tabs</td><td><code>organization.settings.update</code> or <code>settings.*</code></td></tr> <tr><td><code>/organization/feature-flags</code></td><td>Feature flags</td><td><code>feature-flags.read</code> + <code>feature-flags.update</code></td></tr> <tr><td><code>/organization/members</code></td><td>Invite admin</td><td><code>user.create</code> + <code>rbac.member.create</code></td></tr> <tr><td><code>/admin/tenants</code></td><td>Tenants list</td><td><code>isPlatformAdmin</code></td></tr> <tr><td><code>/admin/tenants/new</code></td><td>Tenant create</td><td><code>isPlatformAdmin</code></td></tr> <tr><td><code>/admin/tenants/:id</code> (+ <code>/edit</code>)</td><td>Tenant detail/edit</td><td><code>isPlatformAdmin</code></td></tr> </tbody></table> </div> <p>Deep links:</p> <ul> <li><code>studylyon://organization/settings</code> — admin push to a setting (<code>(forward-looking)</code> push; route itself real).</li> <li><code>studylyon://admin/tenants/:id</code> — support link to a tenant (<code>(forward-looking)</code>).</li> </ul> <h2 id="7-nested-nav--state-survival"><a class="header" href="#7-nested-nav--state-survival">7. Nested nav & state survival</a></h2> <ul> <li>Settings tabs use <code>StatefulShellBranch</code>/<code>TabBarView</code> keep-alive (<code>00-shared/03 AppTabs</code>) so switching tabs preserves unsaved edits; switching bottom-nav destinations preserves tab state (<code>00-shared/05 §3</code>).</li> <li>Feature-flags grouping by <code>module</code> field (<code>feature-flag.schema.ts:21-22</code>) with collapsible sections (<code>AnimatedSize</code>, <code>00-shared/08 §4</code>).</li> </ul> <h2 id="8-quick-actions-fabs-context-menus"><a class="header" href="#8-quick-actions-fabs-context-menus">8. Quick actions, FABs, context menus</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>FAB</th><th>Quick action</th><th>Context menu (rows)</th></tr></thead><tbody> <tr><td>Tenants list</td><td>— (list is platform; row actions suffice)</td><td><code>Ctrl+K</code> search already global; <code>N</code> new tenant (desktop, <code>00-shared/05 §7</code>)</td><td>View · Edit · Suspend · Delete</td></tr> <tr><td>Tenant detail</td><td>—</td><td>Edit (top bar)</td><td>—</td></tr> <tr><td>Org Overview</td><td>—</td><td>Edit profile (top bar action)</td><td>—</td></tr> <tr><td>Members</td><td>"Invite admin"</td><td>—</td><td>Remove member · Change role</td></tr> <tr><td>Feature flags</td><td>—</td><td>—</td><td>Delete flag (standalone collection)</td></tr> </tbody></table> </div> <ul> <li><strong>No FAB</strong> on self-surface overview (editing is toolbar-driven); the single FAB in this module is the optional "Invite admin" on members (<code>AppFAB</code> with <code>heroTag</code> unique per screen, <code>00-shared/03 §D</code>).</li> </ul> <h2 id="9-global-search-integration"><a class="header" href="#9-global-search-integration">9. Global search integration</a></h2> <ul> <li>Tenants list: <code>q</code> filters <code>name</code>/<code>slug</code> regex case-insensitive (<code>organizations.service.ts:78-83</code>), debounced 300 ms (<code>AppSearchBar</code>).</li> <li>Platform admins can reach a tenant via global search only if the search backend indexes organizations <code>(planned)</code>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-organizations-module"><a class="header" href="#05--screen-inventory-organizations-module">05 — Screen Inventory (Organizations Module)</a></h1> <ul> <li><a href="organizations/05_Screen_Inventory.html#s1--org-overview-self">S1 — Org Overview (self)</a></li> <li><a href="organizations/05_Screen_Inventory.html#s2--org-edit-self">S2 — Org Edit (self)</a></li> <li><a href="organizations/05_Screen_Inventory.html#s3--branding">S3 — Branding</a></li> <li><a href="organizations/05_Screen_Inventory.html#s4--settings-tabs">S4 — Settings tabs</a></li> <li><a href="organizations/05_Screen_Inventory.html#s5--feature-flags">S5 — Feature flags</a></li> <li><a href="organizations/05_Screen_Inventory.html#s6--invite-admin-members">S6 — Invite admin (members)</a></li> <li><a href="organizations/05_Screen_Inventory.html#s7--tenants-list-platform">S7 — Tenants list (platform)</a></li> <li><a href="organizations/05_Screen_Inventory.html#s8--tenant-detail--createedit-reuse">S8 — Tenant detail (+ create/edit reuse)</a></li> <li><a href="organizations/05_Screen_Inventory.html#screen--data--analytics-matrix">Screen × data × analytics matrix</a></li> </ul> <blockquote> <p>Every screen in the module. Data contracts derive from <code>organizations.controller.ts</code>, <code>settings.controller.ts</code>, <code>feature-flags.controller.ts</code>, DTOs, and schemas. States follow <a href="organizations/../00-shared/06_State_Management.html">00-shared/06 §3.1</a> (<code>initial/loading/success/error</code>) + empty + offline + permission. Analytics events are <code>(proposed)</code> per <a href="organizations/10_Interaction_Specification.html">10_QA_Baseline.md §8</a> (<code>{module}.{screen}.{action}</code>).</p> </blockquote> <hr /> <h2 id="s1--org-overview-self"><a class="header" href="#s1--org-overview-self">S1 — Org Overview (self)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Route</td><td><code>/organization</code></td></tr> <tr><td>Purpose</td><td>Read-only institution identity summary + entry to all org actions</td></tr> <tr><td>Entry</td><td>Drawer/rail "Organization"; post-login for admins; back-target of edit/branding/settings</td></tr> <tr><td>Exit</td><td>Edit/Branding/Settings/Flags/Members; back → shell home</td></tr> <tr><td>Permission</td><td>any <code>organization.*</code> or <code>settings.*</code> read</td></tr> </tbody></table> </div> <p><strong>Layout (phone / tablet+):</strong> AppBar "Organization" → hero header (logo <code>AppAvatar</code>, name, status chip, plan chip) → <code>AppBanner</code> if <code>status === onboarding</code> → section cards:</p> <div class="table-wrapper"><table><thead><tr><th>Section</th><th>Cards/rows (from <code>organization.schema.ts</code>)</th><th>Widgets</th></tr></thead><tbody> <tr><td>Identity</td><td>name, slug, domain, logoFileId</td><td><code>AppCard</code> + <code>AppInfoRow</code></td></tr> <tr><td>Contact</td><td>contact.email, contact.phone, contact.website (<code>organization.schema.ts:53-58</code>)</td><td><code>AppInfoRow</code></td></tr> <tr><td>Address</td><td>street, city, state, country, zip (<code>:60-67</code>)</td><td><code>AppInfoRow</code></td></tr> <tr><td>Localization</td><td>timezone (default UTC), currency (default USD) (<code>:69-73</code>)</td><td><code>AppInfoRow</code></td></tr> <tr><td>Academic year</td><td>academicYear.startDate, endDate, month (<code>:75-80</code>)</td><td><code>AppInfoRow</code></td></tr> <tr><td>Subscription</td><td>plan (free/basic/premium/enterprise), status (active/inactive/suspended/trial) (<code>:82-94</code>)</td><td><code>AppBadge</code> plan + status</td></tr> <tr><td>Settings summary</td><td>attendance workingDays, theme primaryColor (link → tabs)</td><td><code>AppListTile</code> → <code>/organization/settings</code></td></tr> <tr><td>Feature flags count</td><td>count of enabled keys (link → flags)</td><td><code>AppListTile</code> → <code>/organization/feature-flags</code></td></tr> </tbody></table> </div> <ul> <li><strong>Actions (top bar):</strong> Edit (<code>organization.update</code>), Branding shortcut.</li> <li><strong>States:</strong> loading → <code>AppSkeleton</code> list; error → <code>AppErrorState</code> + retry; empty/unknown → treated as 404 "organization not found"; offline → last-good cache + <code>AppOfflineBanner</code>.</li> <li><strong>Pull-to-refresh:</strong> re-fetch org (bypass cache).</li> <li><strong>Keyboard/tablet/desktop:</strong> no inputs; tablet = 2-column info grid; desktop = wider grid + breadcrumb.</li> <li><strong>A11y:</strong> one <code>headlineMedium</code>; status chips = icon + label (never color-only, <a href="organizations/09_User_Behaviour.html">09 §9</a>); hero logo <code>Semantics</code> name.</li> <li><strong>Analytics (proposed):</strong> <code>organizations.overview.view</code>, <code>organizations.overview.edit.tap</code>, <code>organizations.overview.branding.tap</code>.</li> <li><strong>Data:</strong> <code>GET /api/v1/organizations/:id</code> — id resolution: <strong>OQ-1</strong> (no self endpoint; <code>(planned)</code> <code>/me</code>; stopgap: lookup via list <code>q=<slug></code>).</li> </ul> <h2 id="s2--org-edit-self"><a class="header" href="#s2--org-edit-self">S2 — Org Edit (self)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Route</td><td><code>/organization/edit</code></td></tr> <tr><td>Purpose</td><td>Edit name, slug, domain, contact, address, timezone, currency, academic year, subscription (read-only for non-billing roles)</td></tr> <tr><td>Entry</td><td>Overview "Edit"; Exit: back (unsaved guard) / save → overview</td></tr> <tr><td>Permission</td><td><code>organization.update</code></td></tr> </tbody></table> </div> <p><strong>Sections (form groups, full screen — >3 fields per <a href="organizations/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §5</a>):</strong></p> <ol> <li>Identity: name, slug, domain (<code>update-organization.dto.ts:62-75</code>); 2. Contact: email/phone/website (<code>:85-87</code>); 3. Address: street/city/state/country/zip (<code>:89-94</code>); 4. Localization: timezone (searchable sheet), currency (sheet); 5. Academic year: startDate/endDate (date pickers), month (number); 6. Subscription: plan + status dropdowns (org_admin) — <strong>read-only for custom roles without billing perms; (proposed)</strong> since no dedicated billing permission exists; 7. Branding link-out.</li> </ol> <ul> <li><strong>Widgets:</strong> <code>AppTextField</code>, <code>AppDropdown</code>, <code>AppDatePicker</code>, <code>AppSearchBar</code> (timezone/currency picker sheets).</li> <li><strong>States:</strong> load skeleton; save spinner on primary CTA "Save changes"; 409 → inline field conflict; 400 → field errors; offline → blocked; permission → CTA hidden.</li> <li><strong>Animations:</strong> section entrance stagger <code>m-entrance</code> (40 ms); unsaved guard dialog <code>m-base</code>.</li> <li><strong>Analytics (proposed):</strong> <code>organizations.edit.save.success|error</code>, <code>organizations.edit.slug.conflict</code>.</li> </ul> <h2 id="s3--branding"><a class="header" href="#s3--branding">S3 — Branding</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Route</td><td><code>/organization/branding</code></td></tr> <tr><td>Purpose</td><td>Set <code>branding {primaryColor, secondaryColor, logo, favicon}</code> (<code>organization.schema.ts:114-120</code>; DTO <code>update-organization.dto.ts:125-128</code>)</td></tr> <tr><td>Entry</td><td>Overview → Branding; Exit: save → overview</td></tr> <tr><td>Permission</td><td><code>organization.update</code></td></tr> </tbody></table> </div> <p><strong>Widgets:</strong> live preview panel (theme card rendering current colors, <code>(proposed)</code> — applies <code>primary</code> override per <a href="organizations/02_User_Personas.html">02_Design_Tokens.md §1</a>), color picker (<code>AppColorPicker</code> — custom, <code>(proposed)</code>), logo upload (<code>AppAttachmentTile</code> upload → <code>logoFileId</code>, storage <code>(planned)</code> per <code>IMPLEMENTATION_PLAN.md:24-34</code>), favicon upload (optional).</p> <ul> <li><strong>States:</strong> upload progress per file; save spinner; error on upload → retry tile; offline → pick colors locally, upload blocked.</li> <li><strong>A11y:</strong> color picker also exposes hex text field (color-blind safe, <a href="organizations/09_User_Behaviour.html">09 §9</a>); preview <code>Semantics</code> summary.</li> <li><strong>Analytics (proposed):</strong> <code>organizations.branding.preview</code>, <code>organizations.branding.save</code>.</li> </ul> <h2 id="s4--settings-tabs"><a class="header" href="#s4--settings-tabs">S4 — Settings tabs</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Route</td><td><code>/organization/settings</code></td></tr> <tr><td>Purpose</td><td>Edit embedded settings (attendance/academic/theme) + glance at standalone <code>settings</code> collection groups</td></tr> <tr><td>Entry</td><td>Overview → Settings; Exit: back</td></tr> <tr><td>Permission</td><td><code>organization.settings.update</code> (save); <code>settings.read</code> for read-only tabs</td></tr> </tbody></table> </div> <p><strong>Tabs (from <code>update-organization-settings.dto.ts:4-30</code> + <code>setting.schema.ts:7-14</code>):</strong></p> <div class="table-wrapper"><table><thead><tr><th>Tab</th><th>Fields</th><th>Source</th></tr></thead><tbody> <tr><td>General</td><td>(standalone collection group <code>general</code>)</td><td><code>setting.schema.ts:14</code></td></tr> <tr><td>Attendance</td><td>gracePeriod, lateThreshold, halfDayThreshold (minutes), workingDays[] (0–6)</td><td><code>update-organization-settings.dto.ts:8-12</code></td></tr> <tr><td>Academic</td><td>gradingScale, passingPercentage</td><td><code>:18-21</code></td></tr> <tr><td>Grading</td><td>(standalone group <code>grading</code>)</td><td><code>setting.schema.ts:10</code></td></tr> <tr><td>Notification</td><td>(standalone group <code>notification</code>)</td><td><code>:11</code></td></tr> <tr><td>Theme</td><td>primaryColor, secondaryColor</td><td><code>:26-29</code></td></tr> </tbody></table> </div> <ul> <li><strong>Widgets:</strong> <code>AppTabs</code> keep-alive; numeric <code>AppTextField</code> (number keyboard, <code>(proposed)</code> bounds: thresholds 0–300, workingDays 0–6 with day-chip multi-select <code>AppChips</code>, passingPercentage 0–100); hex color fields.</li> <li><strong>Save model:</strong> one global "Save all" → <code>PATCH :id/settings</code> with <strong>full merged object</strong> (full-replace semantics — <code>organizations.service.ts:134</code>); per-tab autosave is forbidden (would wipe siblings).</li> <li><strong>States:</strong> per-tab loading skeleton; dirty-indicator per tab; save spinner; success snackbar; 400 field errors.</li> <li><strong>Analytics (proposed):</strong> <code>organizations.settings.tab.view</code>, <code>organizations.settings.save</code>.</li> </ul> <h2 id="s5--feature-flags"><a class="header" href="#s5--feature-flags">S5 — Feature flags</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Route</td><td><code>/organization/feature-flags</code></td></tr> <tr><td>Purpose</td><td>View + toggle tenant feature map</td></tr> <tr><td>Entry</td><td>Overview → Feature flags; Exit: back</td></tr> <tr><td>Permission</td><td><code>feature-flags.read</code> (view), <code>feature-flags.update</code> (toggle)</td></tr> </tbody></table> </div> <p><strong>Content:</strong> merged view of (a) org map <code>metadata.featureFlags</code> (<code>GET :id/feature-flags</code> → <code>Record<string,boolean></code>, <code>organizations.service.ts:139-142</code>) and (b) catalog <code>GET /api/v1/feature-flags</code> (<code>feature-flags.controller.ts:23-28</code>) for labels/descriptions/module. Rows grouped by <code>module</code> with collapsible sections; row = <code>AppListTile</code> + <code>AppSwitch</code> (optimistic).</p> <ul> <li><strong>Actions:</strong> toggle → optimistic update, then <code>PATCH :id/feature-flags</code> full map (<code>organizations.service.ts:144-154</code>); rollback + snackbar on error. Delete flag (standalone) → <code>DELETE /feature-flags/:key</code> (<code>feature-flags.controller.ts:54-58</code>).</li> <li><strong>Empty state:</strong> <code>AppEmptyState</code> "No feature flags configured" (both lists empty).</li> <li><strong>Analytics (proposed):</strong> <code>organizations.flags.toggle.{key}</code>.</li> </ul> <h2 id="s6--invite-admin-members"><a class="header" href="#s6--invite-admin-members">S6 — Invite admin (members)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Route</td><td><code>/organization/members</code></td></tr> <tr><td>Purpose</td><td>Add an organization admin (cross-module: user + membership)</td></tr> <tr><td>Entry</td><td>Overview → Members; Exit: back</td></tr> <tr><td>Permission</td><td><code>user.create</code> + <code>rbac.member.create</code></td></tr> </tbody></table> </div> <p><strong>Content:</strong> member list (<code>GET /api/v1/rbac/members</code>, <code>rbac.controller.ts:57-61</code>) + invite form: firstName/lastName/email/phone/roles (<code>user.create</code> + <code>rbac.member.create</code> — <code>rbac.controller.ts:63-67</code>, <code>add-member.dto</code> roles). Row menu: change roles (<code>PATCH /rbac/members/:id</code>, <code>rbac.controller.ts:69-73</code>), remove (<code>DELETE /rbac/members/:id</code>, <code>:75-79</code>).</p> <ul> <li><strong>Note:</strong> exact user-creation fields follow the Users module contract (<code>users.controller.ts:36-40</code>) — see Users module docs. No invitation-token flow exists (OQ-2).</li> <li><strong>States:</strong> list skeleton; invite spinner; 409 duplicate email; empty "No members yet".</li> <li><strong>Analytics (proposed):</strong> <code>organizations.members.invite</code>, <code>organizations.members.role.change</code>.</li> </ul> <h2 id="s7--tenants-list-platform"><a class="header" href="#s7--tenants-list-platform">S7 — Tenants list (platform)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Route</td><td><code>/admin/tenants</code></td></tr> <tr><td>Purpose</td><td>Browse all tenants (paginated)</td></tr> <tr><td>Entry</td><td>Platform drawer; Exit: back</td></tr> <tr><td>Permission</td><td><code>isPlatformAdmin</code> only (OQ-4 — server guard <code>(planned)</code>)</td></tr> </tbody></table> </div> <p><strong>Content:</strong> <code>AppSearchBar</code> (<code>q</code> → name/slug regex, <code>organizations.service.ts:78-83</code>), sort dropdown (<code>sort</code> default <code>-createdAt</code> — <code>:85-87</code>; name/-name/createdAt), paginated list (<code>page</code>,<code>limit</code> default 20, <code>pagination-query.dto.ts:5-30</code>), infinite scroll + <code>hasNext</code> (<code>buildPaginationMeta</code> — <code>:41-54</code>), pull-to-refresh.</p> <ul> <li><strong>Row:</strong> logo, name, slug, status chip, plan badge, created date. Context menu: View · Edit · Suspend · Delete.</li> <li><strong>States:</strong> skeleton; empty ("No tenants match"); error+retry; offline cache + banner.</li> <li><strong>Filters (proposed):</strong> status filter chip row (<code>organization.schema.ts:20-25</code>).</li> <li><strong>Tablet/desktop:</strong> master-detail — tapping row loads detail in right pane (<a href="organizations/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §3</a>).</li> <li><strong>Analytics (proposed):</strong> <code>organizations.tenants.list.view</code>, <code>organizations.tenants.search</code>.</li> </ul> <h2 id="s8--tenant-detail--createedit-reuse"><a class="header" href="#s8--tenant-detail--createedit-reuse">S8 — Tenant detail (+ create/edit reuse)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Route</td><td><code>/admin/tenants/:id</code></td></tr> <tr><td>Purpose</td><td>Platform view of one tenant: identity, subscription, status actions, delete</td></tr> <tr><td>Entry</td><td>Tenants list row; Exit: back (pane)</td></tr> <tr><td>Permission</td><td><code>isPlatformAdmin</code></td></tr> </tbody></table> </div> <p><strong>Content:</strong> same identity/subscription cards as S1 (id-driven), plus status switcher (<code>PATCH :id</code> <code>status</code> — <code>update-organization.dto.ts:130-133</code>), plan/status edit, typed-confirm delete (<code>organizations.service.ts:156-159</code>), link-outs to Settings/Branding/Flags (reusing S3/S4/S5 with <code>:id</code>).</p> <ul> <li><strong>Tenant create (<code>/admin/tenants/new</code>):</strong> full-screen form = <code>CreateOrganizationDto</code> fields (<code>create-organization.dto.ts:57-112</code>) — name required, optional slug/domain/contact/address/timezone/currency/plan/branding/metadata; success → detail with slug handoff banner.</li> <li><strong>States/analytics:</strong> as S7 + <code>organizations.tenants.create.success</code>, <code>organizations.tenants.delete.confirmed</code>.</li> </ul> <hr /> <h2 id="screen--data--analytics-matrix"><a class="header" href="#screen--data--analytics-matrix">Screen × data × analytics matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Primary endpoint</th><th>Load pattern</th><th>Key analytics (proposed)</th></tr></thead><tbody> <tr><td>S1 Overview</td><td><code>GET /organizations/:id</code></td><td>cache+revalidate (TTL 5 min, volatile)</td><td><code>organizations.overview.view</code></td></tr> <tr><td>S2 Edit</td><td><code>GET</code> + <code>PATCH /organizations/:id</code></td><td>no client cache (form)</td><td><code>organizations.edit.save.*</code></td></tr> <tr><td>S3 Branding</td><td><code>GET</code> + <code>PATCH /organizations/:id</code> (+ storage upload <code>(planned)</code>)</td><td>no client cache</td><td><code>organizations.branding.save</code></td></tr> <tr><td>S4 Settings</td><td><code>GET :id/settings</code> + <code>PATCH :id/settings</code></td><td>no client cache</td><td><code>organizations.settings.save</code></td></tr> <tr><td>S5 Flags</td><td><code>GET :id/feature-flags</code> + <code>GET /feature-flags</code> + <code>PATCH :id/feature-flags</code></td><td>5-min cache</td><td><code>organizations.flags.toggle.*</code></td></tr> <tr><td>S6 Members</td><td><code>GET/POST /rbac/members</code>, users endpoints</td><td>5-min cache</td><td><code>organizations.members.invite</code></td></tr> <tr><td>S7 Tenants</td><td><code>GET /organizations</code> (page/limit/sort/q)</td><td>paginated cache <code>sl:{module}:{query}</code></td><td><code>organizations.tenants.list.view</code></td></tr> <tr><td>S8 Tenant detail</td><td><code>GET/PATCH/DELETE /organizations/:id</code></td><td>no client cache</td><td><code>organizations.tenants.delete.confirmed</code></td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-organizations-module"><a class="header" href="#06--screen-specifications-organizations-module">06 — Screen Specifications (Organizations Module)</a></h1> <ul> <li><a href="organizations/06_Screen_Specifications.html#0-global-rules-for-this-module">0. Global rules for this module</a></li> <li><a href="organizations/06_Screen_Specifications.html#s1--org-overview">S1 — Org Overview</a></li> <li><a href="organizations/06_Screen_Specifications.html#s2--org-edit">S2 — Org Edit</a></li> <li><a href="organizations/06_Screen_Specifications.html#s3--branding">S3 — Branding</a></li> <li><a href="organizations/06_Screen_Specifications.html#s4--settings-tabs">S4 — Settings tabs</a></li> <li><a href="organizations/06_Screen_Specifications.html#s5--feature-flags">S5 — Feature flags</a></li> <li><a href="organizations/06_Screen_Specifications.html#s6--invite-admin-members">S6 — Invite admin (members)</a></li> <li><a href="organizations/06_Screen_Specifications.html#s7--tenants-list-platform">S7 — Tenants list (platform)</a></li> <li><a href="organizations/06_Screen_Specifications.html#s8--tenant-detail--createedit">S8 — Tenant detail (+ create/edit)</a></li> </ul> <blockquote> <p>Per-screen build spec. Tokens (T = <a href="organizations/../00-shared/02_Design_Tokens.html">00-shared/02</a>), components (C = <a href="organizations/../00-shared/03_Component_Library.html">00-shared/03</a>), motion (M = <a href="organizations/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §4-5</a>), a11y (A = <a href="organizations/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>), mapping (D = <a href="organizations/../00-shared/04_Design_System_Mapping.html">00-shared/04</a>). Applies to all 8 screens of the module. <code>(proposed)</code> = client-side validation/behavior.</p> </blockquote> <hr /> <h2 id="0-global-rules-for-this-module"><a class="header" href="#0-global-rules-for-this-module">0. Global rules for this module</a></h2> <ul> <li>No FAB except S6 (Invite admin). Toolbar actions carry all primary actions.</li> <li>Forms >3 fields are full-screen pages (<a href="organizations/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §5</a>); dialogs only for confirms/guards.</li> <li>All mutation buttons: 48 dp height, loading spinner replaces label, anti-double-submit (M §6).</li> <li>Light/dark parity, tokens only, no literal colors (<code>D §7</code>).</li> <li>Safe areas: bottom nav/insets respected on phone; keyboard avoidance via <code>Scaffold.resizeToAvoidBottomInset</code> + <code>AppScrollableForm</code> wrapper.</li> <li>Typography: one <code>headlineMedium</code> screen title per screen (A §11); sections <code>titleMedium</code>.</li> <li>Analytics <code>(proposed)</code> per <a href="organizations/05_Screen_Inventory.html">05</a>.</li> </ul> <hr /> <h2 id="s1--org-overview"><a class="header" href="#s1--org-overview">S1 — Org Overview</a></h2> <p><strong>Header:</strong> AppBar (T: 64, <code>surface</code>, <code>titleLarge</code>) — title "Organization", actions: Edit (<code>IconButton</code> + tooltip "Edit organization", shown iff <code>organization.update</code>), Branding (<code>IconButton</code> + tooltip). Hero: <code>AppAvatar</code> logo 64 (fallback initials from name, C §C), name <code>headlineMedium</code>, chips row: status <code>AppBadge</code> (<code>primaryContainer</code> when <code>active</code>, <code>tertiary</code> when <code>onboarding</code>, <code>error</code> when <code>suspended</code> — always icon+label, A §9), plan <code>AppBadge</code> (filled for premium/enterprise, tonal for free/basic).</p> <p><strong>Onboarding banner:</strong> <code>AppBanner</code> severity info, dismissible=false while <code>status === onboarding</code> (<code>organization.schema.ts:122-127</code>): "Finish setting up — configure academic year, attendance rules and branding." → action "Open settings".</p> <p><strong>Sections (cards):</strong> <code>AppCard</code> flat e-0, padding 16, gap 12, section gap 24 (T §3). Each section: <code>AppSectionHeader</code> (<code>titleMedium</code>) + <code>AppInfoRow</code> rows (label <code>bodyMedium</code> onSurfaceVariant, value <code>bodyMedium</code> onSurface). Rows: Identity (name/slug/domain — slug/domain in <code>mono</code> font, T §2), Contact (email/phone/website — email as <code>TextButton</code> link), Address (5 rows), Localization (timezone/currency), Academic year (formatted via <code>Intl</code> locale — e.g., "2026-04-01 → 2027-03-31", month "April"), Subscription (plan + status badges), shortcuts: Settings (<code>AppListTile</code> trailing chevron → S4), Feature flags (<code>AppListTile</code> trailing chevron + count badge → S5), Members (<code>AppListTile</code> → S6, <code>rbac.member.read</code>).</p> <p><strong>States:</strong></p> <ul> <li>Loading: header skeleton (circle 64 + lines) + 4 card skeletons (<code>AppSkeleton list</code>, shimmer <code>m-slow</code>, reduced-motion static, A §8).</li> <li>Error: <code>AppErrorState(code, message, onRetry)</code>; 404 → copy "Organization not found" + reload action; 5xx → generic + requestId hint (C §A).</li> <li>Offline: cached org + <code>AppOfflineBanner</code>; stale badge "Updated {time}".</li> <li>Empty (no data anomaly): treated as 404.</li> </ul> <p><strong>Transitions:</strong> screen enter slide-forward <code>m-base</code>; hero logo fade-rise <code>m-slow</code>; skeleton→content cross-fade <code>m-slow</code> (M §4). <strong>Responsive:</strong> phone 1-col; tablet ≥600 dp 2-col info grid; desktop ≥1200 dp 3-col grid + breadcrumb "Organization". <strong>A11y:</strong> chips read "Status: active", "Plan: premium" (never color-only); logo <code>Semantics("Logo of {name}")</code>; all rows focusable buttons with labels. <strong>Micro-interactions:</strong> row tap ripple <code>m-fast</code>; badge scale-pop on status change (M §5). <strong>Analytics:</strong> <code>organizations.overview.view</code>, <code>organizations.overview.edit.tap</code>, <code>organizations.overview.branding.tap</code>.</p> <hr /> <h2 id="s2--org-edit"><a class="header" href="#s2--org-edit">S2 — Org Edit</a></h2> <p><strong>Header:</strong> AppBar "Edit organization", leading back (pop with unsaved guard), no other actions. Body: scrollable form groups (labels <code>bodyMedium</code>; fields gap 16, T §3). Group 1 Identity: name (required), slug (mono, helper "Used for tenant registration; changing it affects future sign-ups" — <code>organizations.service.ts:107-113</code>), domain (mono, helper "Must be unique across all institutions"). Group 2 Contact: email (<code>emailAddress</code> keyboard, <code>@IsEmail</code> server — <code>update-organization.dto.ts:83-86</code>), phone (<code>phone</code> keyboard), website (<code>url</code> keyboard). Group 3 Address: street, city, state, country, zip (all <code>IsString</code> — <code>:89-94</code>). Group 4 Localization: timezone (readonly field → searchable bottom sheet, <code>AppSearchBar</code> + list, returns IANA name; server default 'UTC' — <code>organization.schema.ts:69-70</code>), currency (sheet: code + symbol, default USD — <code>:72-73</code>). Group 5 Academic year: startDate/endDate (<code>AppDatePicker</code>, min < max validation <code>(proposed)</code>), month (1–12 number <code>(proposed)</code>). Group 6 Subscription: plan <code>AppDropdown</code> (free/basic/premium/enterprise — <code>:6-11</code>), status <code>AppDropdown</code> (active/inactive/suspended/trial — <code>:13-18</code>) — read-only when user lacks billing perms <code>(proposed)</code>, edit disabled + lock icon.</p> <p><strong>Primary CTA:</strong> bottom-pinned "Save changes" <code>AppButton filled</code> fullWidth; loading spinner; disabled until dirty. <strong>Secondary:</strong> "Cancel" (text) → unsaved guard.</p> <p><strong>Dialogs:</strong> unsaved-changes (title "Discard changes?", actions Discard / Keep editing); slug change confirm (warning copy above). <strong>Snackbar:</strong> "Changes saved" (<3 s, success icon); error rollback snackbar (≥4 s).</p> <p><strong>Validation (client, <code>(proposed)</code> unless server-tested):</strong> name non-empty (server: <code>@IsString</code> only — <code>update-organization.dto.ts:62-65</code>); slug lowercase alnum+hyphen regex <code>^[a-z0-9]+(?:-[a-z0-9]+)*$</code> matching <code>slugify</code> output (<code>organizations.service.ts:161-169</code>); domain lowercase FQDN; email <code>emailAddress</code>; zip/phone free text. Server re-validates: 409 → inline on slug/domain fields (scroll-to-field), 400 → per-field details (envelope <code>details[]</code>, <code>http-exception.filter.ts:103-107</code>).</p> <p><strong>Keyboard/touch:</strong> <code>TextInputAction.next</code> between fields, <code>done</code> on last; autofill hints (<code>name</code>, <code>email</code>, <code>address-*</code>, <code>postal-code</code>, <code>tel</code>) per A §10; keyboard avoidance; tablet/desktop 2-col field grid (label-above layout); <code>Enter</code> submits from text fields (M §2). <strong>A11y:</strong> focus moved to first invalid field on failed submit (A §10); errors announced. <strong>Animations:</strong> section entrance stagger <code>m-entrance</code> 40 ms; error highlight shake-free (color+icon only). <strong>Analytics:</strong> <code>organizations.edit.save.success|error</code>, <code>organizations.edit.slug.conflict</code>.</p> <hr /> <h2 id="s3--branding-1"><a class="header" href="#s3--branding-1">S3 — Branding</a></h2> <p><strong>Header:</strong> AppBar "Branding". Body: two-column on tablet/desktop (editor | live preview).</p> <p><strong>Editor column:</strong> Primary color (<code>AppColorPicker</code> — swatch grid + hex <code>AppTextField</code> <code>[0-9A-Fa-f]{6}</code>, <code>(proposed)</code> widget), Secondary color (same), Logo upload (<code>AppAttachmentTile</code> "school_logo.png · uploading 40%" states: idle/uploading/success/error+retry; upload → storage provider <code>(planned)</code>, sets <code>logoFileId</code>/<code>branding.logo</code> — <code>organization.schema.ts:114-120</code>), Favicon upload (optional, same tile).</p> <p><strong>Preview panel:</strong> <code>AppCard</code> e-2 simulating: app bar tinted with primaryColor, button samples (filled/tonal), text swatches, contrast badge computing WCAG ratio vs white/black (<code>(proposed)</code> — warn <4.5:1, A §2), logo render. Branding override model: org <code>primary</code> overrides theme seed; all other roles derive (<a href="organizations/02_User_Personas.html">02_Design_Tokens.md §1</a>).</p> <p><strong>Primary CTA:</strong> "Save branding" → <code>PATCH :id</code> <code>{branding}</code> (<code>update-organization.dto.ts:125-128</code>; server: unvalidated <code>Record<string,unknown></code> — client controls shape). <strong>States:</strong> preview updates instantly on change (<code>m-fast</code> color transitions); save spinner; upload error inline; offline: colors editable locally, upload blocked, save blocked with banner. <strong>A11y:</strong> hex fields label "Primary color hex"; picker swatches have tooltip + name; contrast warning announced; color-blind: also shows hex text (A §9). <strong>Analytics:</strong> <code>organizations.branding.preview</code>, <code>organizations.branding.save.success|error</code>.</p> <hr /> <h2 id="s4--settings-tabs-1"><a class="header" href="#s4--settings-tabs-1">S4 — Settings tabs</a></h2> <p><strong>Header:</strong> AppBar "Organization settings" + global actions: "Save all" (primary, enabled iff any tab dirty). Below: <code>AppTabs</code> (General | Attendance | Academic | Grading | Notification | Theme), <code>TabBarView</code> keep-alive (<a href="organizations/../00-shared/03_Component_Library.html">00-shared/03 AppTabs</a>), indicator <code>primary</code> (D §2).</p> <p><strong>Attendance tab</strong> (embedded settings — <code>update-organization-settings.dto.ts:8-12</code>): gracePeriod (number, suffix "min", <code>(proposed)</code> 0–300), lateThreshold (number, min), halfDayThreshold (number, min), workingDays (multi-select day chips <code>AppChips</code> Sun–Sat, values 0–6, <code>(proposed)</code> ≥1 required), helper "Determines how attendance is counted as late / half-day". <strong>Academic tab:</strong> gradingScale (text, <code>(proposed)</code> presets A+/A/B+…, free text allowed), passingPercentage (number 0–100). <strong>Theme tab:</strong> primaryColor/secondaryColor (hex fields — mirrors branding; saved into settings.theme). <strong>General / Grading / Notification tabs:</strong> read-only reference lists from the standalone <code>settings</code> collection (<code>GET /settings?group=…</code>, <code>settings.controller.ts:24-29</code>) — "managed by module settings" copy; upsert via <code>PUT /settings</code>/<code>PUT /settings/bulk</code> is reserved for module-level config screens <code>(cross-module)</code>.</p> <p><strong>Save model (critical):</strong> "Save all" submits the <strong>full merged</strong> settings object <code>{attendance, academic, theme}</code> to <code>PATCH :id/settings</code> (full-replace — <code>organizations.service.ts:134</code>). Dirty tab indicator (dot on tab, <code>secondaryContainer</code>); per-tab revert (discard) action; after save all tabs clean + snackbar "Settings saved". <strong>States:</strong> per-tab skeleton while loading; global save spinner; 400 → field errors on tab; 409/404 → snackbar. <strong>Dialogs:</strong> none (forms ≤5 fields per tab; save is global). <strong>Sheets:</strong> none. <strong>Keyboard/landscape:</strong> numeric fields <code>number</code> keyboard; landscape = wider 2-col; desktop = max-width 720 form column. <strong>A11y:</strong> chips toggle state announced (<code>Semantics(toggled:)</code>); numeric fields announce units in helper; tab dirty dot read as "unsaved". <strong>Animations:</strong> tab indicator <code>m-base</code>; dirty-dot scale-pop; save success check + snackbar (M §5). <strong>Analytics:</strong> <code>organizations.settings.tab.view.{tab}</code>, <code>organizations.settings.save.success|error</code>.</p> <hr /> <h2 id="s5--feature-flags-1"><a class="header" href="#s5--feature-flags-1">S5 — Feature flags</a></h2> <p><strong>Header:</strong> AppBar "Feature flags" + info <code>IconButton</code> (tooltip "Flags apply to this institution only"). Body: grouped list by <code>module</code> (from catalog <code>GET /feature-flags</code>; keys absent from catalog still shown from map under "Other"). Section header + row: <code>AppListTile</code> title = label ?? key, subtitle = description (mono key chip), trailing <code>AppSwitch</code> (disabled iff no <code>feature-flags.update</code>).</p> <p><strong>Merge logic:</strong> catalog ∪ map; enabled = map[key] ?? (catalog only? false). Toggle → optimistic flip (<a href="organizations/../00-shared/06_State_Management.html">00-shared/06 §3.5</a>) → full-map <code>PATCH :id/feature-flags</code> (<code>organizations.service.ts:144-154</code>) → reconcile with server map; failure → rollback + snackbar "Couldn't update — retry" (with Retry action). Row delete (standalone flag only): <code>AppMenu</code> → "Delete flag" → confirm dialog → <code>DELETE /feature-flags/:key</code> (<code>feature-flags.controller.ts:54-58</code>). <strong>Empty state:</strong> <code>AppEmptyState</code> "No feature flags yet — configure one in the platform catalog" (only when both lists empty). <strong>States:</strong> skeleton rows; loading toggle spinner on the row switch (brief); offline: toggles disabled + banner (no offline queue). <strong>Section expand/collapse:</strong> <code>AnimatedSize</code> <code>m-base</code>, chevron rotate; sections default expanded. <strong>A11y:</strong> switch label = "Enable {flag label}"; result announced via live region; mono keys read as-is. <strong>Analytics:</strong> <code>organizations.flags.toggle.{key}.on|off</code>, <code>organizations.flags.delete</code>.</p> <hr /> <h2 id="s6--invite-admin-members-1"><a class="header" href="#s6--invite-admin-members-1">S6 — Invite admin (members)</a></h2> <p><strong>Header:</strong> AppBar "Team & invites". FAB: <code>AppFAB.extended</code> "Invite admin" (<code>heroTag: 'org-invite'</code>), hidden on scroll-down (C §D). <strong>Body:</strong> section "Members" — list rows (<code>AppListTile</code> leading <code>AppAvatar</code> initials, name, email, role chips, menu). Menu: "Change roles" (sheet multi-select chips from <code>GET /rbac/roles</code> — <code>rbac.controller.ts:27-31</code>; save → <code>PATCH /rbac/members/:id</code> <code>:69-73</code>), "Remove" (dialog → <code>DELETE /rbac/members/:id</code> <code>:75-79</code>). <strong>Invite sheet (full-screen page, >3 fields):</strong> firstName, lastName, email (<code>emailAddress</code>), phone (optional), roles (chips, default <code>org_admin</code>), CTA "Send invitation". Submission: create user (<code>users.controller.ts:36-40</code> — Users module contract) then <code>POST /rbac/members</code> (<code>rbac.controller.ts:63-67</code>). No email/token invite exists (OQ-2) — copy "They'll sign up at register with tenant slug" (<code>(forward-looking)</code> invite email). <strong>States:</strong> list skeleton; invite spinner; 409 duplicate email inline; empty "No members yet". <strong>Responsive:</strong> phone = page push; tablet+ = dialog (≤ 5 fields? use sheet half-height on tablet). <strong>A11y:</strong> FAB label "Invite admin" (A §7); role chips announce selection. <strong>Analytics:</strong> <code>organizations.members.invite.submit</code>, <code>organizations.members.role.change</code>, <code>organizations.members.remove</code>.</p> <hr /> <h2 id="s7--tenants-list-platform-1"><a class="header" href="#s7--tenants-list-platform-1">S7 — Tenants list (platform)</a></h2> <p><strong>Header:</strong> AppBar "Tenants" + count subtitle "12 tenants" (from <code>meta.totalItems</code>). Toolbar row: <code>AppSearchBar</code> (debounce 300 ms, clear suffix), sort <code>AppDropdown</code> (<code>AppMenu</code>): Newest (-createdAt, default), Oldest, Name A–Z, Name Z–A (maps to <code>sort</code> param, <code>pagination-query.dto.ts:21-24</code>), status filter <code>AppChips</code> (All/Active/Onboarding/Suspended/Inactive — <code>(proposed)</code> filter, no server param today; client-side or future <code>status=</code> query). <strong>List:</strong> <code>ListView.builder</code> rows 72 dp: logo 40, name (<code>titleMedium</code>), slug (<code>bodySmall</code> mono), status <code>AppBadge</code> + plan badge, created date (<code>bodySmall</code>), trailing <code>AppMenu</code> (View / Edit / Suspend·Activate / Delete). Infinite scroll: bottom spinner fade <code>m-instant</code>, "End of list" footer <code>bodySmall</code> (<code>meta.hasNext</code> — <code>pagination-query.dto.ts:52</code>). Pull-to-refresh bypasses cache (<a href="organizations/../00-shared/06_State_Management.html">00-shared/06 §3.3</a>). <strong>States:</strong> skeleton list (first load); error <code>AppErrorState</code> + retry; empty <code>AppEmptyState</code> ("No tenants match your search"); offline last-good + banner; permission (non-platform) → 403 screen (route guard). <strong>Tablet/desktop:</strong> master-detail — row tap loads S8 in right pane (≥840 dp, <a href="organizations/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §3</a>); hover row tint <code>m-fast</code>; keyboard: <code>N</code> new tenant, arrows navigate rows (M §2). <strong>Dialogs:</strong> suspend/activate confirm (<code>AppDialog</code> with consequence copy), delete = typed confirm. <strong>Analytics:</strong> <code>organizations.tenants.list.view</code>, <code>organizations.tenants.search</code>, <code>organizations.tenants.filter.status</code>.</p> <hr /> <h2 id="s8--tenant-detail--createedit"><a class="header" href="#s8--tenant-detail--createedit">S8 — Tenant detail (+ create/edit)</a></h2> <p><strong>Header:</strong> AppBar back + tenant name; actions: Edit (<code>organization.update</code>-equivalent, platform), Status menu (Activate/Suspend), Delete (destructive icon). Body reuses S1 card set (id-driven) + status/plan switchers (dropdowns → <code>PATCH :id</code> <code>status</code>/<code>subscriptionStatus</code>/<code>subscriptionPlan</code> — <code>update-organization.dto.ts:115-133</code>). <strong>Delete flow:</strong> <code>AppDialog</code> → typed confirm (type org name; <a href="organizations/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §5</a>) → spinner → <code>DELETE :id</code> (<code>organizations.service.ts:156-159</code>) → snackbar "Tenant deleted — data purged after 30 days" (worker <code>tenant-purge.worker.ts:32-42</code>) → back to list, row removed. <strong>Create (<code>/admin/tenants/new</code>):</strong> full-screen form per <code>CreateOrganizationDto</code> (<code>create-organization.dto.ts:57-112</code>): name*, slug (optional, live-preview of <code>slugify</code> result — <code>organizations.service.ts:38</code>), domain, contact block, address block, timezone, currency, plan, branding (skip link), metadata (JSON field <code>(proposed)</code>). CTA "Create tenant" → success → tenant detail + handoff banner ("Share slug: springfield-academy — the admin registers with this slug"). <strong>States:</strong> detail skeleton; save spinners; 409 slug/domain inline; create success haptic <code>lightImpact</code> (M §3); error states standard. <strong>Analytics:</strong> <code>organizations.tenants.create.success|error</code>, <code>organizations.tenants.status.change</code>, <code>organizations.tenants.delete.confirmed</code>.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-organizations-module"><a class="header" href="#07--component-library-organizations-module">07 — Component Library (Organizations Module)</a></h1> <ul> <li><a href="organizations/07_Component_Library.html#orgheader">OrgHeader</a></li> <li><a href="organizations/07_Component_Library.html#statusbadge">StatusBadge</a></li> <li><a href="organizations/07_Component_Library.html#settingfieldgroup">SettingFieldGroup</a></li> <li><a href="organizations/07_Component_Library.html#colorpickerfield">ColorPickerField</a></li> <li><a href="organizations/07_Component_Library.html#daychips-workingdays">DayChips (workingDays)</a></li> <li><a href="organizations/07_Component_Library.html#flagswitchrow">FlagSwitchRow</a></li> <li><a href="organizations/07_Component_Library.html#tenantrow">TenantRow</a></li> <li><a href="organizations/07_Component_Library.html#slugpreview">SlugPreview</a></li> <li><a href="organizations/07_Component_Library.html#orgsettingsform-s4-composite">OrgSettingsForm (S4 composite)</a></li> <li><a href="organizations/07_Component_Library.html#component--token--flutter-mapping">Component × token × Flutter mapping</a></li> </ul> <blockquote> <p>Module-specific widgets. All shared <code>App*</code> components referenced from <a href="organizations/../00-shared/03_Component_Library.html">00-shared/03</a> 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 <strong>new</strong> components.</p> </blockquote> <hr /> <h2 id="orgheader"><a class="header" href="#orgheader">OrgHeader</a></h2> <ul> <li><strong>Purpose:</strong> hero header for S1/S8 (identity + status chips).</li> <li><strong>Props:</strong> <code>name</code>, <code>logoUrl?</code>, <code>status</code> (OrganizationStatus — <code>organization.schema.ts:20-25</code>), <code>plan</code>, <code>subscriptionStatus</code>, <code>onEdit?</code>, <code>onBranding?</code>.</li> <li><strong>Variants:</strong> <code>compact</code> (list rows S7), <code>hero</code> (detail).</li> <li><strong>States:</strong> loading (skeleton), error (fallback initials), offline.</li> <li><strong>Tokens:</strong> <code>surface</code> bg, <code>e-1</code>, <code>r-lg</code>, <code>titleLarge</code> name, <code>bodySmall</code> slug; badges <code>primaryContainer</code>/<code>tertiary</code>/<code>error</code> with icon+label (A §9).</li> <li><strong>A11y:</strong> single combined <code>Semantics</code> for header ("{name}, status active, plan premium").</li> <li><strong>Flutter:</strong> <code>Row/CircleAvatar</code> + <code>Column</code> + <code>Wrap</code> badges; <code>heroTag</code> for list→detail flight (M §4).</li> </ul> <h2 id="statusbadge"><a class="header" href="#statusbadge">StatusBadge</a></h2> <ul> <li><strong>Purpose:</strong> org lifecycle chip (active/inactive/suspended/onboarding) + subscription chip.</li> <li><strong>Props:</strong> <code>value</code>, <code>type</code> (status|subscription|plan).</li> <li><strong>Mapping:</strong> status → icon (<code>check_circle</code>/<code>pause</code>/<code>block</code>/<code>rocket_launch</code>) + label; plan → filled/tonal badge; never color-only.</li> <li><strong>Flutter:</strong> <code>Badge</code>/<code>Chip</code> wrapper with icon + localized label.</li> </ul> <h2 id="settingfieldgroup"><a class="header" href="#settingfieldgroup">SettingFieldGroup</a></h2> <ul> <li><strong>Purpose:</strong> labelled form group container for S2/S4 with dirty + error aggregation.</li> <li><strong>Props:</strong> <code>title</code>, <code>subtitle?</code>, <code>dirty</code>, <code>errorCount</code>, <code>children</code>.</li> <li><strong>States:</strong> dirty dot (<code>secondaryContainer</code>), group error banner (<code>errorContainer</code>, A §2).</li> <li><strong>Flutter:</strong> <code>Semantics(header:)</code> <code>Column</code> + <code>AnimatedSwitcher</code> for dot.</li> </ul> <h2 id="colorpickerfield"><a class="header" href="#colorpickerfield">ColorPickerField</a></h2> <ul> <li><strong>Purpose:</strong> branding/settings color input (S3/S4) — <code>(proposed)</code> custom widget.</li> <li><strong>Props:</strong> <code>label</code>, <code>value</code> (hex), <code>presets?</code>, <code>onChanged</code>, <code>errorText</code>.</li> <li><strong>Behaviour:</strong> swatch grid + hex text field (sync both ways); opens system color dialog on desktop; contrast helper (WCAG ratio vs onPrimary/onSurface computed live).</li> <li><strong>Validation:</strong> <code>/^#?[0-9A-Fa-f]{6}$/</code> <code>(proposed)</code>; error "Enter a 6-digit hex color".</li> <li><strong>A11y:</strong> label + value semantics; swatches tooltips.</li> <li><strong>Flutter:</strong> <code>Wrap</code> of <code>InkWell</code> swatches + <code>TextField</code> formatter (<code>TextInputFormatter</code>); <code>showDialog</code> color picker for web.</li> </ul> <h2 id="daychips-workingdays"><a class="header" href="#daychips-workingdays">DayChips (workingDays)</a></h2> <ul> <li><strong>Purpose:</strong> multi-select Sun–Sat for attendance settings (S4).</li> <li><strong>Props:</strong> <code>selected</code> (Set<int>), <code>onChanged</code>, <code>disabled</code>.</li> <li><strong>Validation:</strong> ≥1 day <code>(proposed)</code> — error under the chip row.</li> <li><strong>A11y:</strong> chip <code>Semantics(toggled:)</code> + day names.</li> <li><strong>Flutter:</strong> <code>Wrap</code> of <code>FilterChip</code> with localized weekday labels (weekday index 0=Sunday per <code>intl</code>).</li> </ul> <h2 id="flagswitchrow"><a class="header" href="#flagswitchrow">FlagSwitchRow</a></h2> <ul> <li><strong>Purpose:</strong> feature-flag list row (S5) with optimistic toggle.</li> <li><strong>Props:</strong> <code>key</code>, <code>label?</code>, <code>description?</code>, <code>module?</code>, <code>enabled</code>, <code>pending</code>, <code>onToggle</code>, <code>onDelete?</code>.</li> <li><strong>States:</strong> idle / pending (row switch shows mini spinner, row dimmed) / error (row error tint + retry).</li> <li><strong>Behaviour:</strong> optimistic flip → server reconcile → rollback on error with <code>AppSnackbar</code> retry (00-shared/06 §3.5).</li> <li><strong>A11y:</strong> label "Enable {label}", live-region announcement of toggle result.</li> <li><strong>Flutter:</strong> <code>ListTile</code> + <code>Switch</code> with <code>AnimatedSwitcher</code> spinner; <code>Dismissible</code>-free (menu delete only).</li> </ul> <h2 id="tenantrow"><a class="header" href="#tenantrow">TenantRow</a></h2> <ul> <li><strong>Purpose:</strong> tenants-list row (S7).</li> <li><strong>Props:</strong> <code>org</code> (name, slug, status, plan, logo, createdAt), <code>onTap</code>, <code>menu</code> items.</li> <li><strong>States:</strong> hover tint (desktop), selected (master-detail), loading shimmer for images.</li> <li><strong>Flutter:</strong> <code>ListTile</code> 72 dp + <code>MenuAnchor</code>; <code>Hero</code> avatar.</li> </ul> <h2 id="slugpreview"><a class="header" href="#slugpreview">SlugPreview</a></h2> <ul> <li><strong>Purpose:</strong> live slug auto-derivation preview in S8 create form.</li> <li><strong>Props:</strong> <code>name</code>, <code>slugOverride?</code>, <code>onAccept</code>.</li> <li><strong>Behaviour:</strong> shows <code>slugify</code> result (client mirror of <code>organizations.service.ts:161-169</code>); "use this" button fills the slug field.</li> <li><strong>Flutter:</strong> inline <code>Tooltip</code> + <code>TextButton</code>.</li> </ul> <h2 id="orgsettingsform-s4-composite"><a class="header" href="#orgsettingsform-s4-composite">OrgSettingsForm (S4 composite)</a></h2> <ul> <li><strong>Purpose:</strong> the tabbed embedded-settings form with <strong>full-object save</strong>.</li> <li><strong>Props:</strong> <code>attendance</code>, <code>academic</code>, <code>theme</code> (initial values), <code>dirty</code> tracking per tab, <code>onSaveFull(merged)</code>.</li> <li><strong>Rules:</strong> never saves partial; merge = spread last server state + current tab edits.</li> <li><strong>Flutter:</strong> <code>TabBar</code> + <code>TabBarView</code> (keep-alive <code>AutomaticKeepAliveClientMixin</code>).</li> </ul> <hr /> <h2 id="component--token--flutter-mapping"><a class="header" href="#component--token--flutter-mapping">Component × token × Flutter mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Component</th><th>Tokens</th><th>Flutter</th></tr></thead><tbody> <tr><td>OrgHeader</td><td><code>surface</code>, <code>e-1</code>, <code>r-lg</code>, <code>titleLarge</code></td><td><code>Card</code> + <code>Row</code></td></tr> <tr><td>StatusBadge</td><td><code>primaryContainer/tertiary/error</code></td><td><code>Chip</code> + <code>Badge</code></td></tr> <tr><td>SettingFieldGroup</td><td><code>surfaceContainerLow</code>, <code>outlineVariant</code></td><td><code>Container</code> + <code>AnimatedSwitcher</code></td></tr> <tr><td>ColorPickerField</td><td><code>surfaceVariant</code>, <code>outline</code>, <code>error</code></td><td><code>TextField</code> + <code>Wrap</code></td></tr> <tr><td>DayChips</td><td><code>secondaryContainer</code> selected</td><td><code>FilterChip</code></td></tr> <tr><td>FlagSwitchRow</td><td><code>surface</code>, <code>e-0</code>, <code>onSurfaceVariant</code></td><td><code>ListTile</code> + <code>Switch</code></td></tr> <tr><td>TenantRow</td><td><code>surface</code>, <code>r-md</code>, <code>e-0</code></td><td><code>ListTile</code> + <code>MenuAnchor</code></td></tr> <tr><td>SlugPreview</td><td><code>primary</code>, <code>mono</code></td><td><code>TextButton</code> + <code>Text</code></td></tr> </tbody></table> </div> <p><strong>Golden tests:</strong> every component above at 3 sizes + dark mode (<a href="organizations/../00-shared/03_Component_Library.html">00-shared/03 §G</a>).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-organizations-module"><a class="header" href="#08--form-specifications-organizations-module">08 — Form Specifications (Organizations Module)</a></h1> <ul> <li><a href="organizations/08_Form_Specifications.html#f1--create-tenant-s8-create-createorganizationdto">F1 — Create tenant (S8 create, <code>CreateOrganizationDto</code>)</a></li> <li><a href="organizations/08_Form_Specifications.html#f2--edit-org-profile-s2-updateorganizationdto">F2 — Edit org profile (S2, <code>UpdateOrganizationDto</code>)</a></li> <li><a href="organizations/08_Form_Specifications.html#f3--branding-s3">F3 — Branding (S3)</a></li> <li><a href="organizations/08_Form_Specifications.html#f4--organization-settings-s4-updateorganizationsettingsdto">F4 — Organization settings (S4, <code>UpdateOrganizationSettingsDto</code>)</a></li> <li><a href="organizations/08_Form_Specifications.html#f5--register-first-admin--cross-module-auth">F5 — Register (first admin) — cross-module (Auth)</a></li> <li><a href="organizations/08_Form_Specifications.html#f6--settings-collection-upsert-module-config--cross-module">F6 — Settings collection upsert (module config) — cross-module</a></li> <li><a href="organizations/08_Form_Specifications.html#f7--feature-flag-standalone-catalog">F7 — Feature flag (standalone catalog)</a></li> <li><a href="organizations/08_Form_Specifications.html#cross-form-rules">Cross-form rules</a></li> </ul> <blockquote> <p>Field-by-field form specs. Server rules are <strong>exact</strong> from DTOs (<code>create-organization.dto.ts</code>, <code>update-organization.dto.ts</code>, <code>update-organization-settings.dto.ts</code>, <code>register.dto.ts</code>, <code>update-setting.dto.ts</code>, <code>update-feature-flag.dto.ts</code>). Rules marked <code>(proposed)</code> are client-side only — the backend imposes no min/max/length on those fields. Server <code>ValidationPipe</code>: whitelist + forbidNonWhitelisted + transform (<code>main.ts:50-57</code>).</p> </blockquote> <hr /> <h2 id="f1--create-tenant-s8-create-createorganizationdto"><a class="header" href="#f1--create-tenant-s8-create-createorganizationdto">F1 — Create tenant (S8 create, <code>CreateOrganizationDto</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Label</th><th>Type/Keyboard</th><th>Validation (server)</th><th>Client rules <code>(proposed)</code></th><th>Default</th></tr></thead><tbody> <tr><td>name</td><td>Institution name</td><td>text</td><td>required <code>@IsString</code> (<code>create-organization.dto.ts:59-60</code>)</td><td>required, 2–120 chars, trimmed</td><td>—</td></tr> <tr><td>slug</td><td>Tenant slug</td><td>text (mono)</td><td>optional <code>@IsString</code> (<code>:63-65</code>)</td><td><code>^[a-z0-9]+(?:-[a-z0-9]+)*$</code>; live <code>SlugPreview</code> of auto-value</td><td>auto from name</td></tr> <tr><td>domain</td><td>Domain</td><td>text (mono)</td><td>optional <code>@IsString</code> (<code>:68-70</code>)</td><td>lowercase FQDN; unique platform-wide (409)</td><td>—</td></tr> <tr><td>contact.email</td><td>Contact email</td><td>email</td><td><code>@IsEmail</code> (<code>:16-17</code>)</td><td>valid email</td><td>—</td></tr> <tr><td>contact.phone</td><td>Contact phone</td><td>phone</td><td><code>@IsString</code> (<code>:20-21</code>)</td><td>7–20 digits/+/spaces</td><td>—</td></tr> <tr><td>contact.website</td><td>Website</td><td>url</td><td><code>@IsString</code> (<code>:24-25</code>)</td><td>http(s) URL</td><td>—</td></tr> <tr><td>address.*</td><td>street/city/state/country/zip</td><td>text</td><td><code>@IsString</code> each (<code>:35-54</code>)</td><td>free text, 120 max</td><td>—</td></tr> <tr><td>timezone</td><td>Timezone</td><td>sheet picker (searchable)</td><td><code>@IsString</code> (<code>:87-89</code>)</td><td>IANA name from picker</td><td><code>UTC</code> (<code>organization.schema.ts:69-70</code>)</td></tr> <tr><td>currency</td><td>Currency</td><td>sheet picker</td><td><code>@IsString</code> (<code>:92-94</code>)</td><td>ISO code from picker</td><td><code>USD</code> (<code>:72-73</code>)</td></tr> <tr><td>subscriptionPlan</td><td>Plan</td><td>dropdown</td><td><code>@IsEnum(SubscriptionPlan)</code> (<code>:100-102</code>)</td><td>enum free/basic/premium/enterprise</td><td><code>free</code></td></tr> <tr><td>branding</td><td>Branding</td><td>(link to S3)</td><td><code>@IsObject</code> (<code>:106-107</code>)</td><td>deferred</td><td>—</td></tr> <tr><td>metadata</td><td>Metadata</td><td>JSON textarea <code>(proposed)</code></td><td><code>@IsObject</code> (<code>:110-111</code>)</td><td>valid JSON object</td><td>—</td></tr> </tbody></table> </div> <p><strong>Submission:</strong> CTA "Create tenant"; on success → tenant detail + slug handoff banner. Duplicate email? N/A (contact email not unique). Slug/domain conflicts → 409 inline.</p> <h2 id="f2--edit-org-profile-s2-updateorganizationdto"><a class="header" href="#f2--edit-org-profile-s2-updateorganizationdto">F2 — Edit org profile (S2, <code>UpdateOrganizationDto</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Label</th><th>Rules</th><th>Notes</th></tr></thead><tbody> <tr><td>name</td><td>Institution name</td><td><code>@IsString</code> optional (<code>update-organization.dto.ts:63-65</code>)</td><td>2–120 <code>(proposed)</code></td></tr> <tr><td>slug</td><td>Tenant slug</td><td>optional string (<code>:68-70</code>)</td><td>changing = identity change → confirm dialog; conflict 409 (<code>organizations.service.ts:107-113</code>)</td></tr> <tr><td>domain</td><td>Domain</td><td>optional string (<code>:73-75</code>)</td><td>conflict 409 if owned by other org (<code>:115-122</code>)</td></tr> <tr><td>logoFileId</td><td>Logo</td><td>optional string (<code>:79-80</code>)</td><td>set via upload flow (storage <code>(planned)</code>)</td></tr> <tr><td>contact.*</td><td>Contact</td><td>optional <code>@IsEmail</code>/<code>@IsString</code> (<code>:85-87</code>)</td><td>email validated</td></tr> <tr><td>address.*</td><td>Address</td><td>optional strings (<code>:89-94</code>)</td><td>free text</td></tr> <tr><td>timezone / currency</td><td>Localization</td><td>optional strings (<code>:97-104</code>)</td><td>pickers</td></tr> <tr><td>academicYear.startDate / endDate</td><td>Academic year</td><td>optional <code>Date</code> (<code>:109-111</code>)</td><td><code>IsDate</code> via implicit transform; start < end <code>(proposed)</code></td></tr> <tr><td>academicYear.month</td><td>Year start month</td><td>optional <code>number</code> (<code>:112</code>)</td><td>1–12 <code>(proposed)</code></td></tr> <tr><td>subscriptionPlan / subscriptionStatus</td><td>Subscription</td><td>enums (<code>:115-123</code>)</td><td>dropdowns; read-only for non-billing <code>(proposed)</code></td></tr> <tr><td>branding</td><td>Branding</td><td><code>@IsObject</code> (<code>:126-128</code>)</td><td>see F3</td></tr> <tr><td>status</td><td>Status</td><td><code>@IsEnum(OrganizationStatus)</code> (<code>:130-133</code>)</td><td>platform surface only (S8)</td></tr> <tr><td>metadata</td><td>Metadata</td><td><code>@IsObject</code> (<code>:137-138</code>)</td><td>JSON</td></tr> </tbody></table> </div> <p><strong>Submission:</strong> "Save changes" → <code>PATCH :id</code>; partial update allowed (PATCH semantics, <code>$set dto</code> — <code>organizations.service.ts:124</code>); version increments (<code>organizations.repository.ts:55</code>).</p> <h2 id="f3--branding-s3"><a class="header" href="#f3--branding-s3">F3 — Branding (S3)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Label</th><th>Rules</th><th>Notes</th></tr></thead><tbody> <tr><td>primaryColor</td><td>Primary color</td><td>hex <code>/^#?[0-9A-Fa-f]{6}$/</code> <code>(proposed)</code></td><td>server: unvalidated <code>Record<string,unknown></code> (<code>update-organization.dto.ts:126-128</code>); theme override per <a href="organizations/02_User_Personas.html">02_Design_Tokens.md §1</a></td></tr> <tr><td>secondaryColor</td><td>Secondary color</td><td>hex <code>(proposed)</code></td><td>same</td></tr> <tr><td>logo</td><td>Logo</td><td>file upload → id (storage <code>(planned)</code>)</td><td><code>organization.schema.ts:118</code>; preview tile</td></tr> <tr><td>favicon</td><td>Favicon</td><td>file upload (optional)</td><td><code>:119</code></td></tr> </tbody></table> </div> <p><strong>Submission:</strong> whole <code>branding</code> object → <code>PATCH :id</code>. Contrast check ≥4.5:1 vs white for primary as CTA bg <code>(proposed)</code> warning.</p> <h2 id="f4--organization-settings-s4-updateorganizationsettingsdto"><a class="header" href="#f4--organization-settings-s4-updateorganizationsettingsdto">F4 — Organization settings (S4, <code>UpdateOrganizationSettingsDto</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Label</th><th>Type</th><th>Server rule</th><th>Client <code>(proposed)</code></th><th>Default</th></tr></thead><tbody> <tr><td>attendance.gracePeriod</td><td>Grace period (min)</td><td>number</td><td><code>@IsObject</code> container only (<code>update-organization-settings.dto.ts:8-12</code>)</td><td>0–300, integer</td><td>—</td></tr> <tr><td>attendance.lateThreshold</td><td>Late threshold (min)</td><td>number</td><td>same</td><td>0–300</td><td>—</td></tr> <tr><td>attendance.halfDayThreshold</td><td>Half-day threshold (min)</td><td>number</td><td>same</td><td>0–300; > lateThreshold hint</td><td>—</td></tr> <tr><td>attendance.workingDays</td><td>Working days</td><td>day chips (int 0–6)</td><td><code>number[]</code></td><td>≥1 selected; values 0–6</td><td>—</td></tr> <tr><td>academic.gradingScale</td><td>Grading scale</td><td>text</td><td><code>:18-21</code></td><td>presets + free text</td><td>—</td></tr> <tr><td>academic.passingPercentage</td><td>Passing %</td><td>number</td><td>same</td><td>0–100</td><td>—</td></tr> <tr><td>theme.primaryColor / secondaryColor</td><td>Theme colors</td><td>hex</td><td><code>:26-29</code></td><td>same as F3</td><td>—</td></tr> </tbody></table> </div> <p><strong>Submission — CRITICAL full-replace:</strong> <code>PATCH :id/settings</code> sends the <strong>complete merged</strong> object <code>{attendance, academic, theme}</code> — partial bodies wipe sibling groups (<code>organizations.service.ts:134</code>). Dirty tracking per tab; one global save.</p> <h2 id="f5--register-first-admin--cross-module-auth"><a class="header" href="#f5--register-first-admin--cross-module-auth">F5 — Register (first admin) — cross-module (Auth)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Label</th><th>Type</th><th>Server rule (exact)</th></tr></thead><tbody> <tr><td>firstName</td><td>First name</td><td>text</td><td><code>@IsString</code> (<code>register.dto.ts:6-8</code>)</td></tr> <tr><td>lastName</td><td>Last name</td><td>text</td><td><code>@IsString</code> (<code>:9-11</code>)</td></tr> <tr><td>email</td><td>Email</td><td>email</td><td><code>@IsEmail</code> (<code>:13-15</code>); 409 if used (<code>auth.service.ts:56-58</code>)</td></tr> <tr><td>password</td><td>Password</td><td>password</td><td><code>@IsString</code> + <code>@MinLength(8)</code> (<code>:17-20</code>); Argon2id hashed (<code>auth.service.ts:71</code>)</td></tr> <tr><td>phone</td><td>Phone</td><td>phone</td><td>optional <code>@IsString</code> (<code>:22-25</code>)</td></tr> <tr><td>tenantId</td><td>Tenant slug</td><td>text (mono)</td><td><code>@IsString</code> required (<code>:27-29</code>); <strong>no org-existence check</strong> (OQ-2)</td></tr> </tbody></table> </div> <p><strong>Submission:</strong> rate 5/min (<code>auth.controller.ts:31</code>); success returns tokens directly (no redirect); autofill hints <code>name</code>, <code>email</code>, <code>new-password</code> (A §10).</p> <h2 id="f6--settings-collection-upsert-module-config--cross-module"><a class="header" href="#f6--settings-collection-upsert-module-config--cross-module">F6 — Settings collection upsert (module config) — cross-module</a></h2> <p><code>UpdateSettingDto</code>: key (required), value (required, any), group enum academic/attendance/grading/notification/theme/general (<code>setting.schema.ts:7-14</code>), label, description. <code>PUT /settings</code> upsert (<code>settings.controller.ts:37-41</code>); bulk via <code>PUT /settings/bulk</code> (<code>:43-47</code>). Used by S4 reference tabs (read) — write surface owned by feature modules <code>(cross-module)</code>.</p> <h2 id="f7--feature-flag-standalone-catalog"><a class="header" href="#f7--feature-flag-standalone-catalog">F7 — Feature flag (standalone catalog)</a></h2> <p><code>UpdateFeatureFlagDto</code>: key (required string), enabled (required boolean — <code>update-feature-flag.dto.ts:9-11</code>), label/description/module (optional). <code>PUT /feature-flags</code> upsert (<code>feature-flags.controller.ts:42-46</code>), bulk <code>:48-52</code>, delete <code>:54-58</code>. Upsert only writes <code>enabled</code> + <code>label</code> (<code>feature-flag.repository.ts:32-43</code>).</p> <hr /> <h2 id="cross-form-rules"><a class="header" href="#cross-form-rules">Cross-form rules</a></h2> <ul> <li><strong>Validation timing:</strong> on submit + after first error on change; error clears on edit (<a href="organizations/../00-shared/03_Component_Library.html">00-shared/03 AppTextField</a>).</li> <li><strong>Submit loading:</strong> spinner in CTA, anti-double-submit; success haptic <code>lightImpact</code>, error <code>mediumImpact</code> (M §3).</li> <li><strong>Offline:</strong> forms render from last-good cache (S2/S4), submit disabled + banner; drafts kept in-memory (no offline queue — <a href="organizations/../00-shared/07_API_Conventions.html">00-shared/07 §10</a>).</li> <li><strong>Conflict UX:</strong> 409 → inline on the specific field (slug/domain/email), scroll-to-field, focus.</li> <li><strong>Server errors:</strong> 400 details mapped per field (<code>http-exception.filter.ts:103-107</code>); unknown fields → "Unexpected field" (forbidNonWhitelisted).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-organizations-module"><a class="header" href="#09--user-behaviour-organizations-module">09 — User Behaviour (Organizations Module)</a></h1> <ul> <li><a href="organizations/09_User_Behaviour.html#1-behavioural-expectations">1. Behavioural expectations</a></li> <li><a href="organizations/09_User_Behaviour.html#2-common-mistakes--corrections">2. Common mistakes + corrections</a></li> <li><a href="organizations/09_User_Behaviour.html#3-fast-paths">3. Fast paths</a></li> <li><a href="organizations/09_User_Behaviour.html#4-power-users">4. Power users</a></li> <li><a href="organizations/09_User_Behaviour.html#5-new-users">5. New users</a></li> <li><a href="organizations/09_User_Behaviour.html#6-expert-users">6. Expert users</a></li> <li><a href="organizations/09_User_Behaviour.html#7-a11y-users">7. A11y users</a></li> <li><a href="organizations/09_User_Behaviour.html#8-interrupted-sessions--retries">8. Interrupted sessions & retries</a></li> <li><a href="organizations/09_User_Behaviour.html#9-abandonment">9. Abandonment</a></li> <li><a href="organizations/09_User_Behaviour.html#10-frustrations--ux-solutions">10. Frustrations + UX solutions</a></li> </ul> <blockquote> <p>Expected behaviours, mistakes, fast paths, persona-class needs, interrupted sessions, retries, abandonment, frustrations + UX solutions. Derived from code semantics.</p> </blockquote> <hr /> <h2 id="1-behavioural-expectations"><a class="header" href="#1-behavioural-expectations">1. Behavioural expectations</a></h2> <div class="table-wrapper"><table><thead><tr><th>Context</th><th>Expected behaviour</th><th>Why (source)</th></tr></thead><tbody> <tr><td>Save profile → overview</td><td>Overview reflects change instantly</td><td><code>PATCH</code> returns updated doc (<code>organizations.service.ts:124-126</code>); client reconciles</td></tr> <tr><td>Toggle a feature flag</td><td>Immediate flip, rollback on failure</td><td>full-replace map API (<code>organizations.service.ts:144-154</code>) → optimistic per <a href="organizations/../00-shared/06_State_Management.html">00-shared/06 §3.5</a></td></tr> <tr><td>Change working days</td><td>Affects future attendance counts only; no retro warning</td><td>settings replace semantics (<code>:129-137</code>)</td></tr> <tr><td>Delete tenant</td><td>Row gone immediately; understood as reversible 30 days</td><td>soft delete (<code>organizations.repository.ts:60-66</code>) + purge worker (<code>tenant-purge.worker.ts:32-42</code>)</td></tr> <tr><td>Create tenant with name "St. Mary's School"</td><td>Slug auto = <code>st-marys-school</code></td><td><code>slugify</code> (<code>organizations.service.ts:161-169</code>) — user expects predictability; show preview (F1 <code>SlugPreview</code>)</td></tr> <tr><td>Change plan/status</td><td>Banner/status updates everywhere</td><td>PATCH <code>$set</code> (<code>:124</code>)</td></tr> </tbody></table> </div> <h2 id="2-common-mistakes--corrections"><a class="header" href="#2-common-mistakes--corrections">2. Common mistakes + corrections</a></h2> <div class="table-wrapper"><table><thead><tr><th>Mistake</th><th>UX solution</th></tr></thead><tbody> <tr><td>Mistyped slug/domain → 409</td><td>Inline field error + suggested alternatives (name-derived slug via <code>SlugPreview</code>)</td></tr> <tr><td>Editing slug late (admins already registered with it)</td><td>Pre-submit confirm dialog: "Existing admins keep working; new registrations must use the new slug"</td></tr> <tr><td>Saving one settings tab → siblings wiped</td><td>Full-object save model (S4); dirty-dot per tab; "Save all" only; never per-tab save</td></tr> <tr><td>Toggling flags fast (double-tap)</td><td>Anti-double-submit on toggle; final state from server reconcile</td></tr> <tr><td>Register with wrong tenant slug</td><td><code>(forward-looking)</code> pre-submit hint + "tenant not found" guidance on login failure; server validation <code>(planned)</code> OQ-2</td></tr> <tr><td>Deleting the wrong tenant</td><td>Typed confirm (type org name) — <a href="organizations/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §5</a></td></tr> <tr><td>Setting timezone/currency via free text</td><td>Searchable picker sheets; free text still allowed server-side (<code>@IsString</code>) but discouraged</td></tr> <tr><td>Half-day threshold < late threshold</td><td>Cross-field validation hint <code>(proposed)</code></td></tr> </tbody></table> </div> <h2 id="3-fast-paths"><a class="header" href="#3-fast-paths">3. Fast paths</a></h2> <ul> <li>Desktop: <code>N</code> new tenant (S7); <code>Ctrl+K</code> global search; <code>Enter</code> submits forms (M §2).</li> <li>Overview → any edit surface in 1 tap (toolbar actions); settings deep link from onboarding banner.</li> <li>Toggle rows don't require save (flags are immediate); settings require explicit "Save all" (destructive-ish).</li> <li>Tenants list keeps sort/search while paging (debounced <code>q</code>, <code>pagination-query.dto.ts:21-29</code>).</li> </ul> <h2 id="4-power-users"><a class="header" href="#4-power-users">4. Power users</a></h2> <p>Platform Super Admins batch-provision: support multi-select on list <code>(proposed)</code> and keyboard-driven status change; expect <code>meta.totalItems</code> accuracy after each delete (refetch meta).</p> <h2 id="5-new-users"><a class="header" href="#5-new-users">5. New users</a></h2> <ul> <li>First-run: onboarding banner drives them to S4 (academic year + attendance) before other modules.</li> <li>Invited admin: registration pre-fill from invite context <code>(forward-looking)</code>; slug handoff copy in S8 create success.</li> </ul> <h2 id="6-expert-users"><a class="header" href="#6-expert-users">6. Expert users</a></h2> <ul> <li>Keyboard-only desktop flows; expect status change without dialog navigation friction (menu → confirm → done ≤3 steps).</li> <li>Expect full <code>settings</code> object integrity — Surface "last saved {time}" per tab.</li> </ul> <h2 id="7-a11y-users"><a class="header" href="#7-a11y-users">7. A11y users</a></h2> <ul> <li>VoiceOver/TalkBack: badge states, toggle results, error focus (A §5, §10); color picker hex fallback; never color-only status.</li> <li>Keyboard: focus order = toolbar → search → filters → list; ESC closes menus/sheets; typed confirm dialog traps focus (A §6).</li> </ul> <h2 id="8-interrupted-sessions--retries"><a class="header" href="#8-interrupted-sessions--retries">8. Interrupted sessions & retries</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interruption</th><th>Behaviour</th></tr></thead><tbody> <tr><td>App kill mid-form</td><td>Form drafts lost (in-memory only) — guidance: save early; <code>(proposed)</code> autosave to prefs</td></tr> <tr><td>Network loss mid-save</td><td>Pending spinner → error snackbar "Couldn't save — retry" (idempotent PATCH; safe to retry)</td></tr> <tr><td>401 mid-save</td><td>Silent refresh → replay; refresh failure → session-expiry overlay, draft preserved (<a href="organizations/../00-shared/06_State_Management.html">00-shared/06 §3.6</a>)</td></tr> <tr><td>Rate limited (429)</td><td>"Too many requests — retry in Ns", backoff, no auto-retry (<a href="organizations/07_Component_Library.html">07_API_Conventions.md §4</a>)</td></tr> <tr><td>5xx</td><td>Generic + requestId; retry offered (never surface internals — <code>http-exception.filter.ts:60-64</code>)</td></tr> </tbody></table> </div> <h2 id="9-abandonment"><a class="header" href="#9-abandonment">9. Abandonment</a></h2> <ul> <li>Cancel edit → unsaved-guard dialog (Keep editing / Discard); discard = back, no server call (nothing sent until submit).</li> <li>Tenants list pagination resets on filter change — fine (page 1); note: <code>page</code> beyond <code>totalPages</code> returns empty list (meta still says <code>hasNext:false</code> — <code>pagination-query.dto.ts:46-53</code>) → show "End of results".</li> </ul> <h2 id="10-frustrations--ux-solutions"><a class="header" href="#10-frustrations--ux-solutions">10. Frustrations + UX solutions</a></h2> <div class="table-wrapper"><table><thead><tr><th>Frustration</th><th>Root cause</th><th>Solution</th></tr></thead><tbody> <tr><td>"Why is my org missing?"</td><td>OQ-1 — no self-org endpoint</td><td><code>(planned)</code> <code>/organizations/me</code>; stopgap lookup documented; never guess</td></tr> <tr><td>"My settings disappeared"</td><td>full-replace <code>$set {settings}</code> (<code>organizations.service.ts:134</code>)</td><td>full-object save model + wipe-prevention QA (S4)</td></tr> <tr><td>"Which settings apply?"</td><td>dual surfaces: embedded vs <code>settings</code> collection (OQ-6)</td><td>visual separation + copy on reference tabs</td></tr> <tr><td>"I see other schools!"</td><td>unscoped list + unguarded controller (OQ-4)</td><td>platform-only gating client-side; server scope <code>(planned)</code></td></tr> <tr><td>"Flag toggle didn't stick"</td><td>full-map replace races (last-write-wins)</td><td>optimistic + reconcile + pull-to-refresh; WS push <code>(forward-looking)</code></td></tr> <tr><td>"Delete isn't permanent?"</td><td>30-day purge retention</td><td>explicit snackbar/copy "purged after 30 days"; no restore endpoint (ops-only)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-organizations-module"><a class="header" href="#10--interaction-specification-organizations-module">10 — Interaction Specification (Organizations Module)</a></h1> <ul> <li><a href="organizations/10_Interaction_Specification.html#1-touch-gestures-phonetablet">1. Touch gestures (phone/tablet)</a></li> <li><a href="organizations/10_Interaction_Specification.html#2-desktopweb-mouse--keyboard">2. Desktop/web (mouse + keyboard)</a></li> <li><a href="organizations/10_Interaction_Specification.html#3-haptics-mobile">3. Haptics (mobile)</a></li> <li><a href="organizations/10_Interaction_Specification.html#4-motion-language-module">4. Motion language (module)</a></li> <li><a href="organizations/10_Interaction_Specification.html#5-micro-interaction-catalogue-module-additions">5. Micro-interaction catalogue (module additions)</a></li> <li><a href="organizations/10_Interaction_Specification.html#6-frustration-controls">6. Frustration controls</a></li> </ul> <blockquote> <p>Module specialization of <a href="organizations/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>. Covers every input modality for the 8 screens. Motion tokens M = <a href="organizations/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §4-5</a>; haptics §3.</p> </blockquote> <hr /> <h2 id="1-touch-gestures-phonetablet-1"><a class="header" href="#1-touch-gestures-phonetablet-1">1. Touch gestures (phone/tablet)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Gesture</th><th>Behaviour</th><th>Where</th></tr></thead><tbody> <tr><td>Tap</td><td>Primary action, ripple <code>m-fast</code> (150 ms)</td><td>Rows, chips, switches, CTAs everywhere</td></tr> <tr><td>Long-press</td><td>Context menu (<code>AppMenu</code>)</td><td>Tenant row (S7), member row (S6), flag row (S5, delete)</td></tr> <tr><td>Double-tap</td><td>Nothing module-specific (no media)</td><td>—</td></tr> <tr><td>Swipe left/right</td><td>None — destructive actions are menu+confirm only (delete is typed-confirm)</td><td>S7/S8</td></tr> <tr><td>Pull down</td><td>Refresh (bypasses cache)</td><td>S1, S4 (tab content), S5, S6, S7</td></tr> <tr><td>Pinch</td><td>None</td><td>—</td></tr> <tr><td>Hardware back / gesture</td><td>Pop to previous; from S2/S3/S4 with unsaved changes → unsaved-guard dialog; root tab → shell</td><td>S2/S3/S4</td></tr> <tr><td>Edge swipe</td><td>Drawer</td><td>Shell</td></tr> <tr><td>Tap outside sheet</td><td>Dismiss timezone/currency pickers (returns value if selected)</td><td>S2</td></tr> </tbody></table> </div> <h2 id="2-desktopweb-mouse--keyboard-1"><a class="header" href="#2-desktopweb-mouse--keyboard-1">2. Desktop/web (mouse + keyboard)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Input</th><th>Behaviour</th></tr></thead><tbody> <tr><td>Hover</td><td><code>m-fast</code> tint + tooltip ≥500 ms on all icon buttons (Edit, Branding, Info, row menus)</td></tr> <tr><td>Right-click</td><td>Context menu = long-press menu (S7/S6/S5 rows)</td></tr> <tr><td>Wheel</td><td>Scroll lists; Ctrl+wheel zoom on logo preview (S3)</td></tr> <tr><td>ESC</td><td>Closes menu/sheet/dialog/typed-confirm; cancels search (S7 clears <code>q</code>)</td></tr> <tr><td>Tab/Shift+Tab</td><td>Logical focus: toolbar → search → filter chips → list → pagination; visible <code>primary</code> ring 2dp (A §6)</td></tr> <tr><td>Enter</td><td>Submit focused form (S2/S4/F1); in dialog: confirms</td></tr> <tr><td>Ctrl/Cmd+Enter</td><td>Submit settings "Save all" from text areas</td></tr> <tr><td><code>N</code></td><td>New tenant (S7) — <a href="organizations/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §7</a></td></tr> <tr><td>Ctrl+K</td><td>Global search (shell)</td></tr> <tr><td>Arrows</td><td>Navigate menus, dropdowns, day chips (S4)</td></tr> <tr><td>Space</td><td>Toggle focused switch/chip</td></tr> </tbody></table> </div> <h2 id="3-haptics-mobile-7"><a class="header" href="#3-haptics-mobile-7">3. Haptics (mobile)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Haptic</th></tr></thead><tbody> <tr><td>Save success (S2/S3/S4/F1)</td><td><code>lightImpact</code></td></tr> <tr><td>Save/toggle error + rollback</td><td><code>mediumImpact</code></td></tr> <tr><td>Long-press menu open</td><td><code>selectionClick</code></td></tr> <tr><td>Pull-to-refresh completion</td><td><code>lightImpact</code></td></tr> <tr><td>Typed-confirm (delete) submit</td><td><code>heavyImpact</code></td></tr> <tr><td>Toggle flip success</td><td><code>lightImpact</code> (single, not per toggle burst)</td></tr> </tbody></table> </div> <p>Off when system haptics disabled; none on web/desktop (M §3).</p> <h2 id="4-motion-language-module"><a class="header" href="#4-motion-language-module">4. Motion language (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Transition</th><th>Token</th><th>Detail</th></tr></thead><tbody> <tr><td>Screen enter/exit</td><td><code>m-base</code> 250 ms</td><td>slide-forward; forms slide-reverse on back</td></tr> <tr><td>Skeleton→content</td><td><code>m-slow</code> 400 ms cross-fade</td><td>no pop</td></tr> <tr><td>Hero: list row → tenant detail</td><td><code>m-slow</code></td><td>logo avatar flight (S7→S8)</td></tr> <tr><td>Tab switch</td><td><code>m-base</code></td><td>indicator slide</td></tr> <tr><td>Toggle switch</td><td><code>m-fast</code></td><td>standard M3 switch; row dim + spinner swap via <code>AnimatedSwitcher</code></td></tr> <tr><td>Dirty dot appear</td><td><code>m-fast</code> scale-pop</td><td>S4 tabs</td></tr> <tr><td>Section expand (flags by module)</td><td><code>m-base</code></td><td><code>AnimatedSize</code>, chevron rotate</td></tr> <tr><td>Color preview change</td><td><code>m-fast</code></td><td><code>AnimatedContainer</code> on swatch/preview panel</td></tr> <tr><td>Dialog/sheet</td><td><code>m-base</code></td><td>scale+fade in, slide-down dismiss</td></tr> <tr><td>Snackbar</td><td><code>m-base</code></td><td>slide-up, queue single (<a href="organizations/../00-shared/03_Component_Library.html">00-shared/03 §A</a>)</td></tr> <tr><td>Infinite-scroll loader</td><td><code>m-instant</code> fade</td><td>S7</td></tr> <tr><td>Stagger</td><td><code>m-entrance</code> 300 ms, 40 ms stagger</td><td>S2/S8 form sections (first view only)</td></tr> </tbody></table> </div> <p><strong>Reduced motion:</strong> all of the above collapse to fades ≤ <code>m-instant</code>; no stagger, no shimmer, no hero flight, no parallax (A §8, M §4).</p> <h2 id="5-micro-interaction-catalogue-module-additions"><a class="header" href="#5-micro-interaction-catalogue-module-additions">5. Micro-interaction catalogue (module additions)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Detail</th></tr></thead><tbody> <tr><td>Row press</td><td>Ink ripple + 2% surface darken within 100 ms (M §6)</td></tr> <tr><td>Status badge change</td><td>scale-pop + icon morph (<code>AnimatedSwitcher</code>)</td></tr> <tr><td>SlugPreview</td><td>mono text updates per keystroke (debounce 150 ms) with "use this" appearing</td></tr> <tr><td>Save success</td><td>brief check icon in CTA (400 ms) → snackbar</td></tr> <tr><td>Typed-confirm</td><td>destructive button disabled until typed text equals org name; <code>heavyImpact</code> on enable</td></tr> </tbody></table> </div> <h2 id="6-frustration-controls-1"><a class="header" href="#6-frustration-controls-1">6. Frustration controls</a></h2> <ul> <li>Every interactive element responds ≤100 ms; all network actions show pending state.</li> <li>Anti-double-submit on every CTA and every flag toggle.</li> <li>429 → countdown text, no auto-retry (<a href="organizations/07_Component_Library.html">07_API_Conventions.md §4</a>).</li> <li>No full-screen flashes; ripple only (M §6).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-organizations-module"><a class="header" href="#11--design-system-mapping-organizations-module">11 — Design System Mapping (Organizations Module)</a></h1> <ul> <li><a href="organizations/11_Design_System_Mapping.html#1-module-component--m3fluttertokens">1. Module component → M3/Flutter/tokens</a></li> <li><a href="organizations/11_Design_System_Mapping.html#2-screen-level-mapping-tokens">2. Screen-level mapping (tokens)</a></li> <li><a href="organizations/11_Design_System_Mapping.html#3-typography-mapping-per-element">3. Typography mapping (per element)</a></li> <li><a href="organizations/11_Design_System_Mapping.html#4-spacing-4-dp-grid-00-shared02-3">4. Spacing (4 dp grid, 00-shared/02 §3)</a></li> <li><a href="organizations/11_Design_System_Mapping.html#5-color-roles-in-module">5. Color roles in module</a></li> <li><a href="organizations/11_Design_System_Mapping.html#6-iconography">6. Iconography</a></li> <li><a href="organizations/11_Design_System_Mapping.html#7-motion-mapping-module">7. Motion mapping (module)</a></li> <li><a href="organizations/11_Design_System_Mapping.html#8-layout-tokens">8. Layout tokens</a></li> <li><a href="organizations/11_Design_System_Mapping.html#9-theming-rules-enforced-d-7">9. Theming rules (enforced, D §7)</a></li> </ul> <blockquote> <p>Module extension of <a href="organizations/../00-shared/04_Design_System_Mapping.html">00-shared/04</a>. Maps module components + screens to M3, tokens, spacing, type, elevation, radius, color, icon, motion.</p> </blockquote> <hr /> <h2 id="1-module-component--m3fluttertokens"><a class="header" href="#1-module-component--m3fluttertokens">1. Module component → M3/Flutter/tokens</a></h2> <div class="table-wrapper"><table><thead><tr><th>Component</th><th>M3 role</th><th>Flutter</th><th>Tokens (02)</th><th>Elevation</th><th>Radius</th></tr></thead><tbody> <tr><td>OrgHeader</td><td>Card</td><td><code>Card</code> + <code>Row</code></td><td><code>surface</code>, <code>onSurface</code></td><td><code>e-1</code></td><td><code>r-lg</code> 12</td></tr> <tr><td>StatusBadge</td><td>Badge/Chip</td><td><code>Badge</code>/<code>Chip</code></td><td><code>primaryContainer</code>/<code>tertiary</code>/<code>error</code> + icon</td><td><code>e-0</code></td><td><code>r-full</code></td></tr> <tr><td>SettingFieldGroup</td><td>Grouped field</td><td><code>Column</code> + <code>AnimatedSwitcher</code></td><td><code>surfaceContainerLow</code>, <code>outlineVariant</code></td><td><code>e-0</code></td><td><code>r-md</code> 8</td></tr> <tr><td>ColorPickerField</td><td>Input + swatches</td><td><code>TextField</code> + <code>Wrap</code></td><td><code>surfaceVariant</code> fill, <code>outline</code> border, <code>primary</code> focus</td><td><code>e-1</code></td><td><code>r-md</code></td></tr> <tr><td>DayChips</td><td>Filter chip</td><td><code>FilterChip</code></td><td>selected <code>secondaryContainer</code> / idle <code>surfaceVariant</code></td><td><code>e-0</code></td><td><code>r-sm</code> 4</td></tr> <tr><td>FlagSwitchRow</td><td>List row</td><td><code>ListTile</code> + <code>Switch</code></td><td><code>surface</code>, <code>onSurfaceVariant</code> trailing</td><td><code>e-0</code></td><td>—</td></tr> <tr><td>TenantRow</td><td>List row</td><td><code>ListTile</code> 72 + <code>MenuAnchor</code></td><td><code>surface</code>, <code>primary</code> selected</td><td><code>e-0</code></td><td><code>r-md</code></td></tr> <tr><td>SlugPreview</td><td>Inline action</td><td><code>TextButton</code> + <code>Text</code></td><td><code>primary</code>, mono</td><td><code>e-0</code></td><td>—</td></tr> <tr><td>OrgSettingsForm</td><td>Tabbed form</td><td><code>TabBar</code> + <code>TabBarView</code></td><td>indicator <code>primary</code></td><td>—</td><td>—</td></tr> </tbody></table> </div> <h2 id="2-screen-level-mapping-tokens"><a class="header" href="#2-screen-level-mapping-tokens">2. Screen-level mapping (tokens)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Background</th><th>Cards</th><th>Primary CTA</th><th>Icons (Material Symbols outlined)</th></tr></thead><tbody> <tr><td>S1 Overview</td><td><code>surface</code></td><td><code>AppCard</code> flat <code>surfaceContainerLow</code></td><td>toolbar Edit <code>IconButton</code></td><td><code>edit</code>, <code>palette</code>, <code>settings</code>, <code>flag</code>, <code>group</code>, <code>chevron_right</code></td></tr> <tr><td>S2 Edit</td><td><code>surface</code></td><td>field groups <code>surfaceContainerLow</code></td><td><code>FilledButton</code> "Save changes"</td><td><code>arrow_back</code>, <code>schedule</code>, <code>calendar_month</code></td></tr> <tr><td>S3 Branding</td><td><code>surface</code></td><td>preview <code>AppCard e-2</code></td><td><code>FilledButton</code> "Save branding"</td><td><code>palette</code>, <code>upload_file</code>, <code>contrast</code></td></tr> <tr><td>S4 Settings</td><td><code>surface</code></td><td>per-tab forms</td><td><code>FilledButton</code> "Save all"</td><td><code>event_busy</code> (attendance), <code>school</code>, <code>format_paint</code></td></tr> <tr><td>S5 Flags</td><td><code>surface</code></td><td>grouped rows</td><td>switches only</td><td><code>flag</code>, <code>expand_more</code>, <code>delete</code></td></tr> <tr><td>S6 Members</td><td><code>surface</code></td><td>rows + invite page</td><td>FAB <code>AppFAB.extended</code></td><td><code>person_add</code>, <code>more_vert</code></td></tr> <tr><td>S7 Tenants</td><td><code>surface</code></td><td><code>TenantRow</code></td><td>(none; row menus)</td><td><code>search</code>, <code>filter_list</code>, <code>more_vert</code>, <code>business</code></td></tr> <tr><td>S8 Detail/Create</td><td><code>surface</code></td><td>S1 cards + status card</td><td>"Create tenant"/"Save"</td><td><code>arrow_back</code>, <code>edit</code>, <code>block</code>, <code>delete</code>, <code>rocket_launch</code></td></tr> </tbody></table> </div> <h2 id="3-typography-mapping-per-element"><a class="header" href="#3-typography-mapping-per-element">3. Typography mapping (per element)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Element</th><th>Token</th><th>Widget</th></tr></thead><tbody> <tr><td>Screen titles</td><td><code>headlineMedium</code> 28/400/36</td><td>S1/S8 titles</td></tr> <tr><td>Card/section headers</td><td><code>titleMedium</code> 16/500/24</td><td><code>AppSectionHeader</code></td></tr> <tr><td>List row titles</td><td><code>titleMedium</code></td><td>TenantRow, member rows</td></tr> <tr><td>Slug/domain/ids</td><td><code>mono</code> 14/400/20</td><td>slug chips, slug fields</td></tr> <tr><td>Labels/helper</td><td><code>bodyMedium</code> 14/400/20</td><td>form labels, chip labels</td></tr> <tr><td>Timestamps/captions</td><td><code>bodySmall</code> 12/400/16</td><td>"Created 2026-08-01"</td></tr> <tr><td>Badges</td><td><code>labelMedium</code> 12/500/16</td><td>StatusBadge</td></tr> <tr><td>Buttons/tabs</td><td><code>labelLarge</code> 14/500/20</td><td>CTAs, tabs</td></tr> <tr><td>Empty state</td><td><code>displaySmall</code> 36/400/44</td><td>S5/S7 empty states</td></tr> </tbody></table> </div> <h2 id="4-spacing-4-dp-grid-00-shared02-3"><a class="header" href="#4-spacing-4-dp-grid-00-shared02-3">4. Spacing (4 dp grid, <a href="organizations/../00-shared/02_Design_Tokens.html">00-shared/02 §3</a>)</a></h2> <p>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.</p> <h2 id="5-color-roles-in-module"><a class="header" href="#5-color-roles-in-module">5. Color roles in module</a></h2> <div class="table-wrapper"><table><thead><tr><th>Role</th><th>Usage</th></tr></thead><tbody> <tr><td><code>primary</code></td><td>CTAs, focus, selected tab, links, selected nav</td></tr> <tr><td><code>secondary</code></td><td>success snackbars, "use this slug"</td></tr> <tr><td><code>tertiary</code></td><td>onboarding banner, warnings (working-day warnings)</td></tr> <tr><td><code>error</code></td><td>destructive (delete), validation, error states</td></tr> <tr><td><code>primaryContainer</code></td><td>active status badge, primaryContainer tonal CTA</td></tr> <tr><td><code>surfaceVariant</code></td><td>input fills, idle chips</td></tr> <tr><td>Tenant branding override: org <code>branding.primaryColor</code>/<code>settings.theme.primaryColor</code> → <code>ColorScheme.fromSeed</code> re-derivation (D §7.5); live preview in S3 (proposed wiring).</td><td></td></tr> </tbody></table> </div> <h2 id="6-iconography-2"><a class="header" href="#6-iconography-2">6. Iconography</a></h2> <p>Material Symbols outlined 24 dp default; status icons <strong>filled</strong> (<code>check_circle</code>, <code>pause_circle</code>, <code>block</code>, <code>rocket_launch</code>); empty-state icons 32–40; every icon has tooltip + <code>Semantics.label</code> (D §4, A §7).</p> <h2 id="7-motion-mapping-module"><a class="header" href="#7-motion-mapping-module">7. Motion mapping (module)</a></h2> <p>Page transition <code>m-base</code> (FadeUpwards Android / Cupertino iOS — D §5); dialog/sheet <code>m-base</code>; hero <code>m-slow</code>; ripple <code>m-fast</code>; stagger <code>m-entrance</code>; skeleton shimmer <code>m-slow</code> loop (reduced-motion static); snackbar <code>m-base</code>.</p> <h2 id="8-layout-tokens"><a class="header" href="#8-layout-tokens">8. Layout tokens</a></h2> <p>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).</p> <h2 id="9-theming-rules-enforced-d-7"><a class="header" href="#9-theming-rules-enforced-d-7">9. Theming rules (enforced, D §7)</a></h2> <ol> <li>No literal colors — tokens only.</li> <li>No literal spacing/durations — <code>AppSpacing</code>/<code>AppMotion</code>.</li> <li>Dark mode first-class: <code>surface</code> #141414, cards <code>surfaceContainerLow</code> #1D1B1B, parity QA per <a href="organizations/10_Interaction_Specification.html">10_QA_Baseline.md §6</a>.</li> <li>Branding override only touches <code>primary</code> seed; rest derives algorithmically (<a href="organizations/02_User_Personas.html">02 §1</a>).</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-organizations-module"><a class="header" href="#12--api-mapping-organizations-module">12 — API Mapping (Organizations Module)</a></h1> <ul> <li><a href="organizations/12_API_Mapping.html#e1--create-organization">E1 — Create organization</a></li> <li><a href="organizations/12_API_Mapping.html#e2--list-organizations-tenants">E2 — List organizations (tenants)</a></li> <li><a href="organizations/12_API_Mapping.html#e3--get-organization">E3 — Get organization</a></li> <li><a href="organizations/12_API_Mapping.html#e4--update-organization">E4 — Update organization</a></li> <li><a href="organizations/12_API_Mapping.html#e5--delete-organization-soft">E5 — Delete organization (soft)</a></li> <li><a href="organizations/12_API_Mapping.html#e6--get-embedded-settings">E6 — Get embedded settings</a></li> <li><a href="organizations/12_API_Mapping.html#e7--update-embedded-settings">E7 — Update embedded settings</a></li> <li><a href="organizations/12_API_Mapping.html#e8--get-org-feature-flag-map">E8 — Get org feature-flag map</a></li> <li><a href="organizations/12_API_Mapping.html#e9--update-org-feature-flag-map">E9 — Update org feature-flag map</a></li> <li><a href="organizations/12_API_Mapping.html#e10--standalone-settings-collection">E10 — Standalone settings collection</a></li> <li><a href="organizations/12_API_Mapping.html#e11--standalone-feature-flags">E11 — Standalone feature flags</a></li> <li><a href="organizations/12_API_Mapping.html#e12--register-first-admin">E12 — Register (first admin)</a></li> <li><a href="organizations/12_API_Mapping.html#e13--rbac-invite-admin">E13 — RBAC (invite admin)</a></li> <li><a href="organizations/12_API_Mapping.html#client-contract-summary-all-screens">Client contract summary (all screens)</a></li> </ul> <blockquote> <p>Exact endpoints per screen. Wire contract per <a href="organizations/../00-shared/07_API_Conventions.html">00-shared/07</a>: base <code>/api/v1</code>, Bearer JWT, success <code>{success:true,message:"OK",data,meta?,timestamp,requestId}</code>, error envelope with codes. Only shapes in code are used. <code>(planned)</code>/<code>(forward-looking)</code> marked.</p> </blockquote> <hr /> <h2 id="e1--create-organization"><a class="header" href="#e1--create-organization">E1 — Create organization</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>POST /api/v1/organizations</code> (<code>organizations.controller.ts:23-27</code>)</td></tr> <tr><td>Guard</td><td>none in code today (OQ-4); <strong>client restricts to platform admin</strong>; JWT+RBAC guard <code>(planned)</code></td></tr> <tr><td>Request</td><td><code>CreateOrganizationDto</code> (<code>create-organization.dto.ts:57-112</code>) — name required; slug/domain/contact/address/timezone/currency/plan/branding/metadata optional</td></tr> <tr><td>Response</td><td>201 envelope, <code>data</code> = org doc (<code>status</code> forced <code>onboarding</code> — <code>organizations.service.ts:55</code>); no <code>meta</code></td></tr> <tr><td>Errors</td><td>400 <code>VALIDATION_ERROR</code>; 409 <code>DUPLICATE_RESOURCE</code> (slug/domain, <code>:40-50</code>); 429 <code>RATE_LIMITED</code>; 5xx</td></tr> <tr><td>Client</td><td>S8 create form; loading spinner; on success → tenant detail + slug handoff</td></tr> <tr><td>Cache</td><td>none (write)</td></tr> <tr><td>Realtime</td><td>event <code>OrganizationCreated</code> → in-app queue job <code>org-created-notification</code> (<code>event-queue-map.ts:13</code>) — notification to tenant admins <code>(forward-looking)</code> UI</td></tr> </tbody></table> </div> <h2 id="e2--list-organizations-tenants"><a class="header" href="#e2--list-organizations-tenants">E2 — List organizations (tenants)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>GET /api/v1/organizations?page&limit&sort&q</code> (<code>organizations.controller.ts:29-33</code>)</td></tr> <tr><td>Params</td><td><code>page</code> ≥1 default 1; <code>limit</code> 1–100 default 20; <code>sort</code> (<code>-field</code> desc); <code>q</code> → <code>$or</code> regex on name/slug case-insensitive (<code>organizations.service.ts:78-83</code>); default sort <code>-createdAt</code> (<code>:85-87</code>)</td></tr> <tr><td>Response</td><td>paginated: <code>data</code> array + <code>meta {page,limit,totalItems,totalPages,hasNext,hasPrevious}</code> (<code>pagination-query.dto.ts:32-54</code>)</td></tr> <tr><td>Errors</td><td>400 (bad params); 429; 5xx</td></tr> <tr><td>Client</td><td>S7 tenants list; search debounce 300 ms; infinite scroll on <code>hasNext</code>; pull-to-refresh bypasses cache</td></tr> <tr><td>Cache</td><td>client paginated cache <code>sl:{tenantId}:orgs:{query}</code> TTL 5 min (volatile); server Redis cache n/a (no cache key in code for this route)</td></tr> <tr><td><strong>Security note</strong></td><td>repo is <strong>not tenant-scoped</strong> (<code>organizations.repository.ts:15-19</code>) — platform-only gating mandatory (OQ-4)</td></tr> </tbody></table> </div> <h2 id="e3--get-organization"><a class="header" href="#e3--get-organization">E3 — Get organization</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>GET /api/v1/organizations/:id</code> (<code>organizations.controller.ts:35-40</code>)</td></tr> <tr><td>Response</td><td>200 envelope, <code>data</code> = org doc</td></tr> <tr><td>Errors</td><td>400 <code>VALIDATION_ERROR</code> (bad ObjectId → CastError mapping <code>http-exception.filter.ts:47-55</code>); 404 <code>RESOURCE_NOT_FOUND</code> (<code>organizations.service.ts:72</code>); cross-tenant id → 404 (no leak)</td></tr> <tr><td>Client</td><td>S1/S8 detail; <code>:id</code> resolution: platform = from route; <strong>self = OQ-1</strong> (<code>(planned)</code> <code>/organizations/me</code>; stopgap <code>GET /organizations?q=<tenantId slug></code>)</td></tr> <tr><td>Cache</td><td>no client cache (detail views, <a href="organizations/../00-shared/06_State_Management.html">00-shared/06 §3.3</a>); stale-while-revalidate OK</td></tr> </tbody></table> </div> <h2 id="e4--update-organization"><a class="header" href="#e4--update-organization">E4 — Update organization</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>PATCH /api/v1/organizations/:id</code> (<code>organizations.controller.ts:42-47</code>)</td></tr> <tr><td>Request</td><td><code>UpdateOrganizationDto</code> (<code>update-organization.dto.ts:61-139</code>) — partial; <code>$set</code> merge, <code>version</code> +1 (<code>organizations.repository.ts:49-58</code>)</td></tr> <tr><td>Response</td><td>200 updated doc</td></tr> <tr><td>Errors</td><td>404; 409 slug/domain conflict (<code>organizations.service.ts:107-122</code>); 400 enums invalid</td></tr> <tr><td>Client</td><td>S2 form, S8 status/plan switchers, S3 branding save; conflict → inline field error</td></tr> <tr><td>Realtime</td><td><code>OrganizationUpdated</code> interface exists (<code>organization-created.event.ts:10-16</code>) but <strong>not emitted</strong> — no audit signal (OQ-3)</td></tr> </tbody></table> </div> <h2 id="e5--delete-organization-soft"><a class="header" href="#e5--delete-organization-soft">E5 — Delete organization (soft)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>DELETE /api/v1/organizations/:id</code> (<code>organizations.controller.ts:49-54</code>)</td></tr> <tr><td>Behaviour</td><td>soft delete: <code>isDeleted:true, deletedAt, deletedBy</code> + version (<code>organizations.repository.ts:60-66</code>); returns 200, <code>data</code> = null (void handler)</td></tr> <tr><td>Errors</td><td>404 (<code>organizations.service.ts:158</code>)</td></tr> <tr><td>Client</td><td>S8 typed-confirm dialog; snackbar "purged after 30 days"; row removed</td></tr> <tr><td>Purge</td><td>worker <code>TENANT_PURGE</code> (<code>tenant-purge.worker.ts:17-47</code>): deletes all models' <code>isDeleted && deletedAt < now−30d</code>, idempotent; enqueue from module <code>(planned)</code> OQ-5; GDPR erasure for users (<code>:27-30,49-56</code>)</td></tr> </tbody></table> </div> <h2 id="e6--get-embedded-settings"><a class="header" href="#e6--get-embedded-settings">E6 — Get embedded settings</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>GET /api/v1/organizations/:id/settings</code> (<code>organizations.controller.ts:56-62</code>)</td></tr> <tr><td>Response</td><td><code>data</code> = <code>org.settings ?? {}</code> — object or <code>{}</code> (never null)</td></tr> <tr><td>Errors</td><td>404 (via <code>findById</code>)</td></tr> <tr><td>Client</td><td>S4 form load</td></tr> </tbody></table> </div> <h2 id="e7--update-embedded-settings"><a class="header" href="#e7--update-embedded-settings">E7 — Update embedded settings</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>PATCH /api/v1/organizations/:id/settings</code> (<code>organizations.controller.ts:64-72</code>)</td></tr> <tr><td>Request</td><td><code>UpdateOrganizationSettingsDto</code> (<code>update-organization-settings.dto.ts:4-30</code>)</td></tr> <tr><td>Response</td><td>200 updated org doc (settings <strong>replaced</strong> wholesale — <code>organizations.service.ts:134</code>)</td></tr> <tr><td>Errors</td><td>404; 400 (<code>workingDays</code> elements must be numbers)</td></tr> <tr><td>Client</td><td>S4 "Save all" — always full merged object; wipe risk if partial (OQ-6)</td></tr> </tbody></table> </div> <h2 id="e8--get-org-feature-flag-map"><a class="header" href="#e8--get-org-feature-flag-map">E8 — Get org feature-flag map</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>GET /api/v1/organizations/:id/feature-flags</code> (<code>organizations.controller.ts:74-79</code>)</td></tr> <tr><td>Response</td><td><code>data</code> = <code>Record<string,boolean></code> (<code>metadata.featureFlags ?? {}</code> — <code>organizations.service.ts:139-142</code>)</td></tr> <tr><td>Client</td><td>S5 merge source</td></tr> </tbody></table> </div> <h2 id="e9--update-org-feature-flag-map"><a class="header" href="#e9--update-org-feature-flag-map">E9 — Update org feature-flag map</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>PATCH /api/v1/organizations/:id/feature-flags</code> (<code>organizations.controller.ts:81-89</code>)</td></tr> <tr><td>Request</td><td>raw body <code>Record<string,boolean></code> — <strong>no DTO</strong>, no whitelist; non-boolean → 400 <code>VALIDATION_ERROR</code> (type check on cast, <code>transform:false</code> for raw body — actually plain body bypasses DTO: values not validated server-side except by schema typing; see OQ note)</td></tr> <tr><td>Response</td><td>200 updated map (full replace <code>'metadata.featureFlags': flags</code> — <code>organizations.service.ts:144-154</code>)</td></tr> <tr><td>Client</td><td>S5 optimistic toggle → full map submit; rollback on error</td></tr> </tbody></table> </div> <h2 id="e10--standalone-settings-collection"><a class="header" href="#e10--standalone-settings-collection">E10 — Standalone settings collection</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoints</td><td><code>GET /api/v1/settings?group=</code> (<code>settings.controller.ts:24-29</code>); <code>GET /settings/:key</code> (<code>:31-35</code>); <code>PUT /settings</code> upsert (<code>:37-41</code>); <code>PUT /settings/bulk</code> (<code>:43-47</code>); <code>DELETE /settings/:key</code> (<code>:49-53</code>) — <strong>all JwtAuthGuard-protected</strong> (<code>:19</code>)</td></tr> <tr><td>Request</td><td><code>UpdateSettingDto</code> — key/value/group/label/description (<code>update-setting.dto.ts:5-26</code>); upsert on <code>{tenantId,key}</code> unique (<code>setting.schema.ts:38</code>)</td></tr> <tr><td>Response</td><td>setting doc(s); 404 on missing key (<code>settings.service.ts:19-21</code>)</td></tr> <tr><td>Client</td><td>S4 reference tabs (read by group); writes owned by feature modules <code>(cross-module)</code></td></tr> </tbody></table> </div> <h2 id="e11--standalone-feature-flags"><a class="header" href="#e11--standalone-feature-flags">E11 — Standalone feature flags</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoints</td><td><code>GET /api/v1/feature-flags?module=</code> (<code>feature-flags.controller.ts:23-28</code>); <code>GET /feature-flags/enabled</code> (<code>:30-34</code>); <code>GET /feature-flags/:key</code> (<code>:36-40</code>); <code>PUT</code> upsert (<code>:42-46</code>); <code>PUT /bulk</code> (<code>:48-52</code>); <code>DELETE /:key</code> (<code>:54-58</code>) — <strong>JwtAuthGuard-protected</strong></td></tr> <tr><td>Request</td><td><code>UpdateFeatureFlagDto</code> — key/enabled (required), label/description/module optional (<code>update-feature-flag.dto.ts:4-27</code>); upsert writes enabled+label only (<code>feature-flag.repository.ts:32-43</code>)</td></tr> <tr><td>Response</td><td>flag doc; 404 missing key (<code>feature-flags.service.ts:28-30</code>)</td></tr> <tr><td>Client</td><td>S5 catalog (labels/descriptions/module grouping); delete via menu</td></tr> </tbody></table> </div> <h2 id="e12--register-first-admin"><a class="header" href="#e12--register-first-admin">E12 — Register (first admin)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>POST /api/v1/auth/register</code> (<code>auth.controller.ts:30-36</code>) — public, <code>@RateLimit auth 5/min</code> (<code>:31</code>)</td></tr> <tr><td>Request</td><td><code>RegisterDto</code> (<code>register.dto.ts:4-29</code>) — firstName/lastName/email/password(≥8)/phone?/<strong>tenantId (slug)</strong></td></tr> <tr><td>Response</td><td>201 <code>data {accessToken, refreshToken}</code> (<code>auth.service.ts:95-99</code>); side effects: user created + <code>auth_account</code>, member <code>org_admin</code>, role seeding, <code>UserRegistered</code> event → welcome email (<code>event-queue-map.ts:7</code>)</td></tr> <tr><td>Errors</td><td>409 email exists (<code>auth.service.ts:56-58</code>); 400; 429</td></tr> <tr><td>Client</td><td>onboarding flow; no org-existence check (OQ-2)</td></tr> </tbody></table> </div> <h2 id="e13--rbac-invite-admin"><a class="header" href="#e13--rbac-invite-admin">E13 — RBAC (invite admin)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoints</td><td><code>GET /api/v1/rbac/members</code> (<code>rbac.controller.ts:57-61</code>); <code>POST /rbac/members</code> (<code>:63-67</code>); <code>PATCH /rbac/members/:id</code> (<code>:69-73</code>); <code>DELETE /rbac/members/:id</code> (<code>:75-79</code>); <code>GET /rbac/roles</code> (<code>:27-31</code>) — all <code>org_admin</code> + JwtAuthGuard (<code>:19-23</code>)</td></tr> <tr><td>Request</td><td><code>AddMemberDto {userId, roles[]}</code></td></tr> <tr><td>Client</td><td>S6 list/invite/role change/remove</td></tr> </tbody></table> </div> <hr /> <h2 id="client-contract-summary-all-screens"><a class="header" href="#client-contract-summary-all-screens">Client contract summary (all screens)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Concern</th><th>Rule</th></tr></thead><tbody> <tr><td>Auth</td><td>Bearer JWT; 401 → single-flight refresh → replay; fail → session expiry (<a href="organizations/../00-shared/06_State_Management.html">00-shared/06 §3.6</a>)</td></tr> <tr><td>Optimistic</td><td>only flag toggles (E9); all other writes show server result (<a href="organizations/07_Component_Library.html">07 §9</a>)</td></tr> <tr><td>Idempotency</td><td>PATCH/DELETE retry-safe; no <code>Idempotency-Key</code> support confirmed (B6)</td></tr> <tr><td>Offline</td><td>reads from last-good cache + banner; writes blocked (no module offline queue)</td></tr> <tr><td>Pagination</td><td><code>page/limit/sort/q</code> + <code>meta</code> exact (<a href="organizations/07_Component_Library.html">07 §5</a>); infinite scroll driven by <code>hasNext</code></td></tr> <tr><td>Realtime</td><td>WS topics n/a for org config today; <code>(forward-looking)</code> <code>org.branding.updated</code>, <code>org.feature-flags.updated</code></td></tr> <tr><td>Error mapping</td><td><a href="organizations/../00-shared/06_State_Management.html">00-shared/06 §5</a> table: 400 field errors, 403 hide/deny, 404 empty/not-found, 409 inline conflict, 429 backoff, 5xx generic+requestId</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-organizations-module"><a class="header" href="#13--state-management-organizations-module">13 — State Management (Organizations Module)</a></h1> <ul> <li><a href="organizations/13_State_Management.html#1-cubit-map">1. Cubit map</a></li> <li><a href="organizations/13_State_Management.html#2-state-machine-generic-per-00-shared06-31">2. State machine (generic per 00-shared/06 §3.1)</a></li> <li><a href="organizations/13_State_Management.html#3-orgdetailcubit">3. OrgDetailCubit</a></li> <li><a href="organizations/13_State_Management.html#4-orgeditcubit">4. OrgEditCubit</a></li> <li><a href="organizations/13_State_Management.html#5-orgsettingscubit">5. OrgSettingsCubit</a></li> <li><a href="organizations/13_State_Management.html#6-featureflagscubit">6. FeatureFlagsCubit</a></li> <li><a href="organizations/13_State_Management.html#7-tenantscubit-paginatedlistmixinorganization">7. TenantsCubit (<code>PaginatedListMixin<Organization></code>)</a></li> <li><a href="organizations/13_State_Management.html#8-caching--staleness-module-ttls">8. Caching & staleness (module TTLs)</a></li> <li><a href="organizations/13_State_Management.html#9-realtime--cross-cubit">9. Realtime & cross-cubit</a></li> <li><a href="organizations/13_State_Management.html#10-testing-hooks">10. Testing hooks</a></li> </ul> <blockquote> <p>Cubit architecture per <a href="organizations/../00-shared/06_State_Management.html">00-shared/06</a>. One cubit per screen; base <code>LoadState</code> (<code>Initial/Loading/Success/Error(ApiException)</code>), <code>PaginatedListMixin</code>, cache + SWR, optimistic updates, connectivity. All <code>(proposed)</code> client design.</p> </blockquote> <hr /> <h2 id="1-cubit-map-4"><a class="header" href="#1-cubit-map-4">1. Cubit map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Cubit</th><th>Screen(s)</th><th>Data sources</th><th>Notes</th></tr></thead><tbody> <tr><td><code>OrgDetailCubit</code></td><td>S1 Overview, S8 detail</td><td>E3 <code>GET /organizations/:id</code></td><td>shared by self + platform (id from route/context); TTL 5 min SWR</td></tr> <tr><td><code>OrgEditCubit</code></td><td>S2 Edit, S3 Branding, S8 create/edit</td><td>E1/E4 + storage upload <code>(planned)</code></td><td>form state not cached; submit → full DTO</td></tr> <tr><td><code>OrgSettingsCubit</code></td><td>S4 Settings tabs</td><td>E6 <code>GET :id/settings</code> + E7 <code>PATCH</code>; reference tabs E10</td><td>full-object save; per-tab dirty flags</td></tr> <tr><td><code>FeatureFlagsCubit</code></td><td>S5</td><td>E8 map + E11 catalog</td><td>merged view; optimistic toggle; rollback</td></tr> <tr><td><code>MembersCubit</code></td><td>S6</td><td>E13 rbac endpoints + users module</td><td>invite/role/remove</td></tr> <tr><td><code>TenantsCubit</code></td><td>S7</td><td>E2 list</td><td><code>PaginatedListMixin<Organization></code>; q/sort/filter state</td></tr> <tr><td><code>OnboardingCubit</code></td><td>global</td><td>org status + register</td><td>drives banner + registration flow <code>(forward-looking)</code></td></tr> </tbody></table> </div> <h2 id="2-state-machine-generic-per-00-shared06-31"><a class="header" href="#2-state-machine-generic-per-00-shared06-31">2. State machine (generic per <a href="organizations/../00-shared/06_State_Management.html">00-shared/06 §3.1</a>)</a></h2> <pre class="mermaid">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) </pre> <h2 id="3-orgdetailcubit"><a class="header" href="#3-orgdetailcubit">3. OrgDetailCubit</a></h2> <pre><code class="language-dart">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; } </code></pre> <p>Events: <code>LoadOrg(id)</code>, <code>RefreshOrg()</code>, <code>OrgUpdated(org)</code> (post-save reconcile from Edit/Branding/Settings cubits via shared bus or direct repository cache invalidation — prefer cache-bust key + refetch).</p> <p><strong>Load flow:</strong> <code>repo.getOrg(id)</code> → cache hit (<code>sl:{tenant}:org:{id}</code>, TTL 5 min) → emit Success(stale:true) + background refetch → Success(stale:false). Miss → Loading → Success/Error. <strong>Self-view (OQ-1):</strong> <code>loadSelf()</code> resolves id via <code>(planned)</code> <code>/organizations/me</code>; fallback (documented stopgap) list-lookup by slug — flagged in code comment.</p> <h2 id="4-orgeditcubit"><a class="header" href="#4-orgeditcubit">4. OrgEditCubit</a></h2> <p>State: <code>form</code> (OrgFormModel), <code>submitting</code>, <code>fieldErrors</code>, <code>conflicts {slug?, domain?}</code>, <code>saveResult</code>. Events: <code>InitForm(org)</code>, <code>FieldChanged(field, value)</code>, <code>SlugPreview(name)</code>, <code>Submit()</code>, <code>Discard()</code>. Reducers: submit → submitting → on 200 Success (emit <code>OrgUpdated</code> for dependents) → snackbar; on 409 → <code>conflicts[field]</code> inline, no form reset; on 400 → <code>fieldErrors</code> mapped from envelope <code>details[]</code> (<code>http-exception.filter.ts:103-107</code>).</p> <pre class="mermaid">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 </pre> <h2 id="5-orgsettingscubit"><a class="header" href="#5-orgsettingscubit">5. OrgSettingsCubit</a></h2> <p>State: <code>initial</code> (per tab), <code>dirty {attendance, academic, theme}</code> (bitmap), <code>saving</code>, <code>error</code>. Events: <code>LoadSettings(id)</code>, <code>TabChanged(tab)</code>, <code>FieldChanged(tab, field, value)</code>, <code>SaveAll()</code>, <code>RevertTab(tab)</code>. <strong>Key reducer:</strong> <code>SaveAll()</code> always serializes the <strong>merged full object</strong> from <code>lastServerSettings</code> + dirty fields (full-replace safety — <code>organizations.service.ts:134</code>). Success → clear dirty + snackbar. Failure → keep dirty + error snackbar (retry safe).</p> <pre class="mermaid">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 </pre> <h2 id="6-featureflagscubit"><a class="header" href="#6-featureflagscubit">6. FeatureFlagsCubit</a></h2> <p>State: <code>flags: Map<String, FlagView></code> where <code>FlagView {key, label?, description?, module?, enabled, pending}</code>; <code>loading</code>, <code>error</code>. Events: <code>Load()</code>, <code>Toggle(key)</code>, <code>Retry(key)</code>, <code>DeleteFlag(key)</code>. Optimistic flow: <code>Toggle</code> → set <code>enabled=!enabled, pending=true</code> → repo <code>setFlagMap(fullNewMap)</code> (E9) → on 200 reconcile + <code>pending=false</code> + lightImpact; on error → <strong>rollback</strong> + <code>pending=false</code> + error snackbar with Retry (retry re-submits last-intended map). Map = merge(catalog keys, server map keys).</p> <pre class="mermaid">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) </pre> <h2 id="7-tenantscubit-paginatedlistmixinorganization"><a class="header" href="#7-tenantscubit-paginatedlistmixinorganization">7. TenantsCubit (<code>PaginatedListMixin<Organization></code>)</a></h2> <p>State: <code>page (1)</code>, <code>limit (20)</code>, <code>sort (-createdAt)</code>, <code>q</code>, <code>statusFilter (proposed)</code>, <code>items</code>, <code>hasNext</code>, <code>isLoadingMore</code>, <code>loadState</code>. Events: <code>LoadFirst()</code>, <code>LoadMore()</code>, <code>Search(q)</code>, <code>ChangeSort(sort)</code>, <code>ChangeFilter(status)</code>, <code>Refresh()</code>, <code>TenantDeleted(id)</code> (remove row + refetch meta). Contract mirrors API exactly (<code>pagination-query.dto.ts:5-54</code>): on Search/ChangeSort/Filter → reset <code>page=1</code>, clear items, fetch; <code>hasNext</code> drives infinite scroll; <code>Refresh</code> bypasses cache.</p> <pre class="mermaid">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, ...) </pre> <h2 id="8-caching--staleness-module-ttls"><a class="header" href="#8-caching--staleness-module-ttls">8. Caching & staleness (module TTLs)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Data</th><th>Cache key</th><th>TTL</th><th>Notes</th></tr></thead><tbody> <tr><td>Org detail</td><td><code>sl:{tenant}:org:{id}</code></td><td>5 min SWR</td><td>RefreshIndicator bypasses</td></tr> <tr><td>Tenants page</td><td><code>sl:{tenant}:orgs:{page}:{limit}:{sort}:{q}</code></td><td>5 min</td><td>full query key (includes filter <code>(proposed)</code>)</td></tr> <tr><td>Settings (embedded)</td><td>no client cache</td><td>—</td><td>form data; refetch on entry</td></tr> <tr><td>Flag map</td><td><code>sl:{tenant}:flags</code></td><td>5 min</td><td>invalidated on toggle success</td></tr> <tr><td>Flag catalog</td><td><code>sl:{tenant}:flag-catalog</code></td><td>24 h (reference)</td><td>labels/descriptions</td></tr> <tr><td>Members</td><td><code>sl:{tenant}:members</code></td><td>5 min</td><td>invalidated on invite/role change</td></tr> </tbody></table> </div> <h2 id="9-realtime--cross-cubit"><a class="header" href="#9-realtime--cross-cubit">9. Realtime & cross-cubit</a></h2> <ul> <li>No org-config WS topics today; <code>(forward-looking)</code>: subscribe <code>org.branding.updated</code>, <code>org.feature-flags.updated</code> → invalidate caches + refetch (B2: WS protocol unverified).</li> <li>Cross-cubit invalidation: after E4/E7/E9 success → bump <code>org:{id}</code> cache version; Overview re-fetches on focus (<code>AppLifecycle</code>/route pop).</li> <li><code>FeatureFlagsCubit</code> is the module-level toggle source for other modules' gating (<code>00-shared/06 §4</code>).</li> </ul> <h2 id="10-testing-hooks"><a class="header" href="#10-testing-hooks">10. Testing hooks</a></h2> <ul> <li>Pure-Dart cubits, mocked repositories; widget tests per state machine (Loading/Success/Error/Empty) + optimistic rollback (<code>00-shared/06 §6</code>).</li> <li>Golden: full-object-save form with 3 tabs dirty states.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-organizations-module"><a class="header" href="#14--qa-checklist-organizations-module">14 — QA Checklist (Organizations Module)</a></h1> <ul> <li><a href="organizations/14_QA_Checklist.html#1-functional-p0">1. Functional (P0)</a></li> <li><a href="organizations/14_QA_Checklist.html#2-security--isolation-p0">2. Security & isolation (P0)</a></li> <li><a href="organizations/14_QA_Checklist.html#3-visual--design-tokens">3. Visual & design tokens</a></li> <li><a href="organizations/14_QA_Checklist.html#4-a11y-p0-per-changed-screen-a-12">4. A11y (P0 per changed screen, A §12)</a></li> <li><a href="organizations/14_QA_Checklist.html#5-performance-10-1">5. Performance (10 §1)</a></li> <li><a href="organizations/14_QA_Checklist.html#6-offline-matrix-10-2">6. Offline matrix (10 §2)</a></li> <li><a href="organizations/14_QA_Checklist.html#7-tablet--desktop--foldable">7. Tablet / desktop / foldable</a></li> <li><a href="organizations/14_QA_Checklist.html#8-localization">8. Localization</a></li> <li><a href="organizations/14_QA_Checklist.html#9-animations--motion">9. Animations & motion</a></li> <li><a href="organizations/14_QA_Checklist.html#10-analytics-proposed-contract">10. Analytics (proposed contract)</a></li> <li><a href="organizations/14_QA_Checklist.html#11-regression-guardrails-code-facts-to-never-break">11. Regression guardrails (code facts to never break)</a></li> </ul> <blockquote> <p>Module checklist layered on <a href="organizations/../00-shared/10_QA_Baseline.html">00-shared/10</a> baseline. Severity: <strong>P0</strong> ship-blocking.</p> </blockquote> <hr /> <h2 id="1-functional-p0"><a class="header" href="#1-functional-p0">1. Functional (P0)</a></h2> <ul> <li><input disabled="" type="checkbox"/> Provision: <code>POST /organizations</code> with name only → slug auto-derived exactly per <code>slugify</code> (<code>organizations.service.ts:161-169</code>); status = <code>onboarding</code>; 7 default roles seeded (<code>organizations.service.ts:58</code>; <code>role.schema.ts:8-65</code>).</li> <li><input disabled="" type="checkbox"/> Slug conflict → 409 <code>DUPLICATE_RESOURCE</code> inline; domain conflict on update with own-org exemption (<code>organizations.service.ts:115-122</code>).</li> <li><input disabled="" type="checkbox"/> Full-replace semantics: <code>PATCH :id/settings</code> with only <code>{attendance}</code> <strong>wipes</strong> academic/theme — client must always submit full object; verify no wipe path in UI (S4).</li> <li><input disabled="" type="checkbox"/> Full-replace flags: <code>PATCH :id/feature-flags</code> with <code>{a:true}</code> removes key <code>b</code> — UI must submit merged map; rollback restores prior map exactly.</li> <li><input disabled="" type="checkbox"/> Delete → soft-deleted org absent from <code>GET /organizations</code> + <code>GET /:id</code> (404) (<code>organizations.repository.ts:15-19,60-66</code>).</li> <li><input disabled="" type="checkbox"/> Invalid ObjectId → 400 <code>VALIDATION_ERROR</code> "Invalid resource identifier." (<code>http-exception.filter.ts:47-55,92</code>).</li> <li><input disabled="" type="checkbox"/> Settings collection: <code>PUT /settings</code> upsert idempotent; bulk <code>PUT /settings/bulk</code>; delete 404 path (<code>settings.service.ts:36-41</code>).</li> <li><input disabled="" type="checkbox"/> Flags catalog: upsert persists only enabled+label (<code>feature-flag.repository.ts:32-43</code>); delete soft.</li> <li><input disabled="" type="checkbox"/> Register: duplicate email 409; password <8 → 400; tokens usable immediately (<code>auth.service.ts:54-121</code>).</li> <li><input disabled="" type="checkbox"/> Pagination contract: <code>meta.totalPages/hasNext/hasPrevious</code> exact (<code>pagination-query.dto.ts:41-54</code>); page > totalPages → empty data, <code>hasNext:false</code>.</li> <li><input disabled="" type="checkbox"/> <code>q</code> search case-insensitive on name+slug; sort <code>-createdAt</code> default (<code>organizations.service.ts:78-87</code>).</li> </ul> <h2 id="2-security--isolation-p0"><a class="header" href="#2-security--isolation-p0">2. Security & isolation (P0)</a></h2> <ul> <li><input disabled="" type="checkbox"/> <strong>OQ-4:</strong> tenants list + tenant detail reachable by tenant admin today (unguarded controller, unscoped repo) — client MUST gate to <code>isPlatformAdmin</code>; server guard <code>(planned)</code>. Test: tenant-admin token → route hidden + 403 screen.</li> <li><input disabled="" type="checkbox"/> Cross-tenant <code>:id</code> access → 404 (never 403-leak-of-existence); verify both self + platform surfaces.</li> <li><input disabled="" type="checkbox"/> JWT <code>tenantId</code> never sent in body (except public register <code>tenantId</code> — by design, <code>register.dto.ts:27-29</code>); all other requests derive from token.</li> <li><input disabled="" type="checkbox"/> Tokens in secure storage; no token in analytics/logs (<a href="organizations/10_Interaction_Specification.html">10_QA_Baseline.md §3</a>).</li> <li><input disabled="" type="checkbox"/> 5xx internals never rendered — generic + requestId only.</li> <li><input disabled="" type="checkbox"/> Typed-confirm delete: destructive button disabled until org name typed exactly.</li> <li><input disabled="" type="checkbox"/> No <code>Idempotency-Key</code> dependency assumed for PATCH/DELETE (retry-safe by nature); register POST is rate-limited, no auto-retry.</li> </ul> <h2 id="3-visual--design-tokens"><a class="header" href="#3-visual--design-tokens">3. Visual & design tokens</a></h2> <ul> <li><input disabled="" type="checkbox"/> No literal colors/spacing/durations in widget code (D §7); tokens only.</li> <li><input disabled="" type="checkbox"/> Light + dark parity on all 8 screens (surface/cards contrast ≥3:1 chrome, ≥4.5:1 text — <a href="organizations/09_User_Behaviour.html">09 §2</a>).</li> <li><input disabled="" type="checkbox"/> Status/plan badges: icon + label, never color-only (A §9); colorblind check (primary/error/success pairs with icons).</li> <li><input disabled="" type="checkbox"/> Slug/domain rendered <code>mono</code>; numerals tabular (<a href="organizations/02_User_Personas.html">02 §2</a>).</li> <li><input disabled="" type="checkbox"/> Golden tests: 8 screens × 3 sizes × light/dark (<a href="organizations/10_Interaction_Specification.html">10 §9</a>).</li> </ul> <h2 id="4-a11y-p0-per-changed-screen-a-12"><a class="header" href="#4-a11y-p0-per-changed-screen-a-12">4. A11y (P0 per changed screen, A §12)</a></h2> <ul> <li><input disabled="" type="checkbox"/> TalkBack + VoiceOver full walkthrough: S1 → S4 save flow, S5 toggle, S8 typed-confirm.</li> <li><input disabled="" type="checkbox"/> Keyboard-only desktop: focus order toolbar → search → chips → list; ESC closes everything; <code>N</code> new tenant; typed-confirm traps focus.</li> <li><input disabled="" type="checkbox"/> Text scale 2×: no clipping in cards/forms/badges (S4 chip rows wrap; header wraps).</li> <li><input disabled="" type="checkbox"/> Toggle results announced via live region; errors announced + focus to first invalid field.</li> <li><input disabled="" type="checkbox"/> Color picker hex fallback; contrast warning announced.</li> <li><input disabled="" type="checkbox"/> Focus ring visible <code>primary</code> 2 dp offset 2.</li> </ul> <h2 id="5-performance-10-1"><a class="header" href="#5-performance-10-1">5. Performance (<a href="organizations/10_Interaction_Specification.html">10 §1</a>)</a></h2> <ul> <li><input disabled="" type="checkbox"/> Tenants list: skeleton ≤200 ms; content ≤2 s network; 0 dropped frames scrolling 100-row list (<code>ListView.builder</code>).</li> <li><input disabled="" type="checkbox"/> <code>PATCH</code> round-trip feedback ≤100 ms press-state; save spinner consistent.</li> <li><input disabled="" type="checkbox"/> No duplicate parallel requests for same org id (cache + single-flight).</li> <li><input disabled="" type="checkbox"/> Flag map merge on 50+ flags: no rebuild jank (grouped <code>AnimatedSize</code>, list virtualization).</li> <li><input disabled="" type="checkbox"/> Memory: no growth across 20 min of tab-switching (keep-alive disposal policy).</li> </ul> <h2 id="6-offline-matrix-10-2"><a class="header" href="#6-offline-matrix-10-2">6. Offline matrix (<a href="organizations/10_Interaction_Specification.html">10 §2</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Scenario</th><th>Expected</th></tr></thead><tbody> <tr><td>Airplane → S1/S7</td><td>cached data + <code>AppOfflineBanner</code> + stale badge</td></tr> <tr><td>Offline → S2/S4 submit</td><td>blocked with guidance; draft kept in-memory; no silent loss on back (guard)</td></tr> <tr><td>Offline → S5 toggle</td><td>switch disabled + banner (no offline queue)</td></tr> <tr><td>Offline → online</td><td>auto-refresh on next interaction; no partial writes</td></tr> <tr><td>3G throttle</td><td>skeletons, no ANR, retry works</td></tr> <tr><td>Network loss mid-save</td><td>pending → error snackbar "Couldn't save — retry"; safe retry (PATCH idempotent)</td></tr> </tbody></table> </div> <h2 id="7-tablet--desktop--foldable"><a class="header" href="#7-tablet--desktop--foldable">7. Tablet / desktop / foldable</a></h2> <ul> <li><input disabled="" type="checkbox"/> ≥840 dp: S7 master-detail; breadcrumbs at depth ≥2; hover states; scrollbars visible.</li> <li><input disabled="" type="checkbox"/> 600–839 dp: S1 two-column info grid; settings tabs readable.</li> <li><input disabled="" type="checkbox"/> Foldable: cover + unfolded layouts no overlap; 200% text reflow.</li> <li><input disabled="" type="checkbox"/> Landscape tablet: keyboard doesn't cover form fields (scrollable form).</li> </ul> <h2 id="8-localization-1"><a class="header" href="#8-localization-1">8. Localization</a></h2> <ul> <li><input disabled="" type="checkbox"/> All strings via keys (<code>app_en.arb</code> + org-locale); server business messages mapped to keys where available, fallback for 4xx business text (<a href="organizations/07_Component_Library.html">07 §11</a>).</li> <li><input disabled="" type="checkbox"/> Date/number formatting via <code>Intl</code> (academic year, created dates).</li> <li><input disabled="" type="checkbox"/> RTL readiness: no hardcoded alignments (badges/rows use logical direction).</li> <li><input disabled="" type="checkbox"/> Working-day chips localized weekday names (Sunday-first per locale).</li> <li><input disabled="" type="checkbox"/> Smoke strings (fr/hi/es) don't break S4 labels or S1 cards.</li> </ul> <h2 id="9-animations--motion"><a class="header" href="#9-animations--motion">9. Animations & motion</a></h2> <ul> <li><input disabled="" type="checkbox"/> All transitions use <code>m-*</code> tokens; reduced-motion: fades ≤ <code>m-instant</code>, no stagger/shimmer/hero.</li> <li><input disabled="" type="checkbox"/> No dropped frames during hero S7→S8 (profile 120 fps).</li> <li><input disabled="" type="checkbox"/> Haptics only per <a href="organizations/10_Interaction_Specification.html">10_Interaction_Specification.md §3</a>; none on desktop.</li> </ul> <h2 id="10-analytics-proposed-contract"><a class="header" href="#10-analytics-proposed-contract">10. Analytics (proposed contract)</a></h2> <ul> <li><input disabled="" type="checkbox"/> Events fire per <a href="organizations/05_Screen_Inventory.html">05</a> inventory: <code>organizations.overview.view</code>, <code>organizations.edit.save.success|error</code>, <code>organizations.settings.save.*</code>, <code>organizations.flags.toggle.{key}</code>, <code>organizations.tenants.list.view|search|delete.confirmed</code>.</li> <li><input disabled="" type="checkbox"/> No PII beyond necessity; consent-gated (A4).</li> </ul> <h2 id="11-regression-guardrails-code-facts-to-never-break"><a class="header" href="#11-regression-guardrails-code-facts-to-never-break">11. Regression guardrails (code facts to never break)</a></h2> <ul> <li><input disabled="" type="checkbox"/> Settings/flag save always full-object (test asserts sibling keys survive).</li> <li><input disabled="" type="checkbox"/> Slug change flows through confirm dialog; domain conflict check compares <code>existing.id !== id</code> (<code>organizations.service.ts:117</code>).</li> <li><input disabled="" type="checkbox"/> Create never accepts <code>status</code> from client (forced <code>onboarding</code>, <code>:55</code>); update can change status.</li> <li><input disabled="" type="checkbox"/> Register rate limit 5/min — UI countdown, no auto-retry (<code>auth.controller.ts:31</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-organizations-module"><a class="header" href="#15--flutter-implementation-guide-organizations-module">15 — Flutter Implementation Guide (Organizations Module)</a></h1> <ul> <li><a href="organizations/15_Flutter_Implementation_Guide.html#1-structure">1. Structure</a></li> <li><a href="organizations/15_Flutter_Implementation_Guide.html#2-models--enums-exact-from-schema">2. Models & enums (exact from schema)</a></li> <li><a href="organizations/15_Flutter_Implementation_Guide.html#3-repositories">3. Repositories</a></li> <li><a href="organizations/15_Flutter_Implementation_Guide.html#4-cubits-see-13">4. Cubits (see <code>13</code>)</a></li> <li><a href="organizations/15_Flutter_Implementation_Guide.html#5-navigation-go_router">5. Navigation (go_router)</a></li> <li><a href="organizations/15_Flutter_Implementation_Guide.html#6-theme">6. Theme</a></li> <li><a href="organizations/15_Flutter_Implementation_Guide.html#7-extensions">7. Extensions</a></li> <li><a href="organizations/15_Flutter_Implementation_Guide.html#8-localization">8. Localization</a></li> <li><a href="organizations/15_Flutter_Implementation_Guide.html#9-testing">9. Testing</a></li> <li><a href="organizations/15_Flutter_Implementation_Guide.html#10-performance">10. Performance</a></li> <li><a href="organizations/15_Flutter_Implementation_Guide.html#11-open-items-to-wire-when-backend-lands">11. Open items to wire when backend lands</a></li> </ul> <blockquote> <p>Module extension of <a href="organizations/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a>. Structure, widgets, cubits, repositories, DTOs, models, navigation, theme, extensions, localization, testing, performance. Forward-looking.</p> </blockquote> <hr /> <h2 id="1-structure"><a class="header" href="#1-structure">1. Structure</a></h2> <pre><code>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 </code></pre> <h2 id="2-models--enums-exact-from-schema"><a class="header" href="#2-models--enums-exact-from-schema">2. Models & enums (exact from schema)</a></h2> <pre><code class="language-dart">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 </code></pre> <p><code>Organization</code> fields mirror <code>organization.schema.ts:28-152</code>: 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 <code>String</code>; dates <code>DateTime</code> (parse ISO from envelope timestamps). <strong>Never send DTOs to widgets</strong> (<code>00-shared/11 §4</code>).</p> <h2 id="3-repositories"><a class="header" href="#3-repositories">3. Repositories</a></h2> <p><code>OrganizationRepository</code> (dio, via <code>AppDio</code> bearer/refresh/error interceptors — <code>00-shared/11 §5</code>):</p> <ul> <li><code>createOrg(CreateOrgDto) → Organization</code> (E1)</li> <li><code>listOrgs({page,limit,sort,q}) → Paginated<Organization></code> (E2)</li> <li><code>getOrg(id) → Organization</code> (E3)</li> <li><code>updateOrg(id, UpdateOrgDto) → Organization</code> (E4)</li> <li><code>deleteOrg(id) → void</code> (E5)</li> <li><code>getSettings(id) → OrgSettings</code> (E6), <code>updateSettings(id, OrgSettings) → Organization</code> (E7)</li> <li><code>getFlagMap(id) → Map<String,bool></code> (E8), <code>setFlagMap(id, Map<String,bool>) → Map<String,bool></code> (E9)</li> <li><code>getSelfOrg() → Organization</code> — OQ-1: calls <code>(planned)</code> <code>/organizations/me</code>; stopgap: <code>listOrgs(q: tenantIdSlug)</code> documented in code.</li> </ul> <p><code>OrgConfigRepository</code>: settings collection E10 + flags catalog E11.</p> <p>Typed exceptions: <code>ApiException(code, status, fieldDetails, message)</code> from error interceptor (<code>00-shared/06 §5</code>).</p> <h2 id="4-cubits-see-13"><a class="header" href="#4-cubits-see-13">4. Cubits (see <code>13</code>)</a></h2> <p><code>OrgDetailCubit</code>, <code>OrgEditCubit</code>, <code>OrgSettingsCubit</code>, <code>FeatureFlagsCubit</code>, <code>MembersCubit</code>, <code>TenantsCubit</code> (with <code>PaginatedListMixin<Organization></code> — <code>00-shared/06 §3.2</code>). Full-object save guard in <code>OrgSettingsCubit.SaveAll</code> (merge from <code>lastServer</code> + dirty). Optimistic rollback in <code>FeatureFlagsCubit.Toggle</code>. All pure-Dart, DI via <code>get_it</code> lazy factories (<code>00-shared/11 §2</code>).</p> <h2 id="5-navigation-go_router"><a class="header" href="#5-navigation-go_router">5. Navigation (go_router)</a></h2> <pre><code class="language-dart">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', ...), </code></pre> <p><code>platformAdminGuard</code> checks <code>user.isPlatformAdmin</code> (<code>jwt-auth.guard.ts:54</code>). Permission guards mirror <code>permissions.constants.ts</code> (<code>organization.*</code> :2-5, <code>settings.*</code> :75-77, <code>feature-flags.*</code> :78-80); server remains authoritative (403 → route redirect to 403 screen). Master-detail via <code>StatefulShellBranch</code> at ≥840 dp (<code>00-shared/05 §3</code>). Deep links <code>(forward-looking)</code>: <code>studylyon://organization/settings</code>, <code>studylyon://admin/tenants/:id</code>.</p> <h2 id="6-theme-2"><a class="header" href="#6-theme-2">6. Theme</a></h2> <p>Branding override: <code>AppTheme.fromBranding(seed: org.branding.primaryColor)</code> → <code>ColorScheme.fromSeed</code> (D §7.5, <code>02 §1</code>); preview mode in S3 renders with overridden scheme without persisting. <code>themeMode</code> from system or org pin <code>(proposed)</code> (OQ/D). No literal tokens in widgets (<code>02 §10</code>, <code>04 §7</code>).</p> <h2 id="7-extensions-2"><a class="header" href="#7-extensions-2">7. Extensions</a></h2> <p>Reuse shared (<code>00-shared/11 §8</code>): <code>DateTime.toDisplayDate</code>, <code>String.initials</code>, <code>int.toMoney</code>, <code>context.showAppSnackbar</code>. Module additions: <code>OrganizationStatus.displayName</code>, <code>Organization.statusSemantics</code>, <code>Map<String,bool>.flagKeysBy(module)</code>, <code>String.toSlug</code> (wraps <code>slugify</code>).</p> <h2 id="8-localization-2"><a class="header" href="#8-localization-2">8. Localization</a></h2> <p>Keys under <code>features/organizations/</code> namespace in <code>.arb</code> (en + fr + hi smoke): <code>org.title</code>, <code>org.status.onboarding</code>, <code>org.settings.saveAll</code>, <code>org.flags.toggle.rollback</code>, <code>org.tenants.delete.confirm</code>, <code>org.slug.conflict</code>, weekday labels for chips. Server messages rendered via error-code→key map with business-4xx fallback (<code>00-shared/11 §9</code>, <code>07 §11</code>).</p> <h2 id="9-testing-1"><a class="header" href="#9-testing-1">9. Testing</a></h2> <div class="table-wrapper"><table><thead><tr><th>Layer</th><th>Coverage</th></tr></thead><tbody> <tr><td>Unit</td><td><code>slugify</code> parity tests vs <code>organizations.service.ts:161-169</code> vectors ("St. Mary's School" → <code>st-marys-school</code>); enum mapping; formatters; full-object merge logic</td></tr> <tr><td>Cubit</td><td><code>OrgSettingsCubit</code> full-object save (assert sibling groups present in payload); <code>FeatureFlagsCubit</code> rollback; <code>TenantsCubit</code> pagination + search reset</td></tr> <tr><td>Widget</td><td>S1 loading/error/empty; S4 3-tab dirty states; S5 toggle pending/rollback; S7 skeleton/infinite-scroll; typed-confirm disabled state</td></tr> <tr><td>Golden</td><td>8 screens × light/dark × 3 sizes; new components (<code>07 §Golden</code>)</td></tr> <tr><td>Integration</td><td>provision → register → overview journey; settings save → reload → values intact</td></tr> <tr><td>E2E (device cloud)</td><td>P0: platform creates tenant; admin registers; edits settings; toggles flag; deletes tenant</td></tr> </tbody></table> </div> <p>Run: <code>flutter analyze</code>, <code>flutter test</code>, <code>flutter test integration_test</code> (<code>00-shared/11 §12</code>).</p> <h2 id="10-performance-1"><a class="header" href="#10-performance-1">10. Performance</a></h2> <ul> <li><code>ListView.builder</code> for S5/S6/S7; <code>AutomaticKeepAliveClientMixin</code> for S4 tabs; <code>RepaintBoundary</code> around S3 preview.</li> <li>Flag list grouped sections render lazily; collapse via <code>AnimatedSize</code> without layout rebuild storms.</li> <li><code>SlugPreview</code> debounced 150 ms; search debounce 300 ms.</li> <li>Caches keyed <code>sl:{tenant}:org…</code> with TTLs from <code>13 §8</code>; <code>RefreshIndicator</code> bypasses cache.</li> <li>Profile on mid-range device against <code>10_QA_Baseline.md §1</code> budgets.</li> </ul> <h2 id="11-open-items-to-wire-when-backend-lands"><a class="header" href="#11-open-items-to-wire-when-backend-lands">11. Open items to wire when backend lands</a></h2> <ol> <li><code>GET /organizations/me</code> (OQ-1) — remove stopgap lookup.</li> <li>JWT+RBAC guard + tenant scoping on organizations controller (OQ-4) — swap <code>platformAdminGuard</code> for server truth.</li> <li><code>TenantPurgeJob</code> enqueue (OQ-5) — surface "purge scheduled" state if API returns it.</li> <li>Storage provider (R2/Appwrite) — logo upload real path (<code>IMPLEMENTATION_PLAN.md:24-34</code>).</li> <li>WS topics <code>org.branding.updated</code> / <code>org.feature-flags.updated</code> — subscribe + cache invalidation.</li> <li>Institution-type discriminator (Phase 6, <code>IMPLEMENTATION_PLAN.md:251-287</code>) — add <code>institutionType</code> to model + onboarding step <code>(planned)</code>.</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-parents-module"><a class="header" href="#01--product-overview-parents-module">01 — Product Overview (Parents Module)</a></h1> <ul> <li><a href="parents/01_Product_Overview.html#1-purpose">1. Purpose</a></li> <li><a href="parents/01_Product_Overview.html#2-business-goals">2. Business goals</a></li> <li><a href="parents/01_Product_Overview.html#3-user-goals">3. User goals</a></li> <li><a href="parents/01_Product_Overview.html#4-stakeholders">4. Stakeholders</a></li> <li><a href="parents/01_Product_Overview.html#5-why-this-exists">5. Why this exists</a></li> <li><a href="parents/01_Product_Overview.html#6-dependencies">6. Dependencies</a></li> <li><a href="parents/01_Product_Overview.html#7-success-metrics-proposed">7. Success metrics (proposed)</a></li> <li><a href="parents/01_Product_Overview.html#8-edge-cases">8. Edge cases</a></li> <li><a href="parents/01_Product_Overview.html#9-assumptions-module">9. Assumptions (module)</a></li> <li><a href="parents/01_Product_Overview.html#10-open-questions-module-level-global-ledger-in-00-shared12">10. Open questions (module-level; global ledger in 00-shared/12)</a></li> <li><a href="parents/01_Product_Overview.html#11-glossary-this-module">11. Glossary (this module)</a></li> </ul> <blockquote> <p>StudyLyon — multi-tenant ERP / School Management API. This package designs the <strong>Parents module client</strong> (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 <code>src/modules/parents/**</code>, <code>src/modules/students/**</code>, <code>src/modules/users/**</code>, <code>src/modules/rbac/**</code>, <code>src/infrastructure/**</code>, <code>studylyon-blueprint/04-Modules/Parents.md</code>, and <code>studylyon-blueprint/03-Database/COLLECTIONS.md</code>. No feature is invented; gaps are flagged <code>(planned)</code> / <code>(forward-looking)</code> / <code>(proposed)</code> and itemized in the <strong>Assumptions & Open Questions</strong> section.</p> </blockquote> <hr /> <h2 id="1-purpose-16"><a class="header" href="#1-purpose-16">1. Purpose</a></h2> <p>The Parents module manages <strong>guardian identity</strong> (occupation, company, income, emergency-contact priority, pickup authorization) and the <strong>many-to-many graph</strong> between guardians and students (<code>student_parent_links</code>). The blueprint defines the module as "Guardians and emergency contacts" (<code>04-Modules/Parents.md:3</code>) with four responsibilities: guardian profile, student–parent linking, relationship type, and emergency-contact/pickup/financial flags (<code>04-Modules/Parents.md:11-17</code>).</p> <div class="table-wrapper"><table><thead><tr><th>Responsibility</th><th>Source</th></tr></thead><tbody> <tr><td>Parent profile CRUD (occupation, company, income, emergency flags)</td><td><code>parent.schema.ts:8-32</code>, <code>parent.controller.ts:29-46</code></td></tr> <tr><td>One <code>parents</code> doc per <code>users</code> doc (unique <code>tenantId+userId</code>)</td><td><code>parent.schema.ts:36</code></td></tr> <tr><td>Link parents ↔ students (many-to-many) with relationship metadata</td><td><code>student-parent-link.schema.ts:16-41</code>, <code>student-parent-link.service.ts:16-41</code></td></tr> <tr><td>Relationship type enum (mother, father, guardian, grandparent, relative, foster_parent)</td><td><code>student-parent-link.schema.ts:7-14</code></td></tr> <tr><td><code>isPrimaryGuardian</code> — primary-communication flag on the link</td><td><code>student-parent-link.schema.ts:27-29</code></td></tr> <tr><td>Financial responsibility / pickup authorization / emergency priority</td><td><code>student-parent-link.schema.ts:30-38</code>, <code>parent.schema.ts:24-29</code></td></tr> <tr><td>Domain events → BullMQ (in-app notification, audit)</td><td><code>parent.service.ts:38-45,78-86,92-99</code>, <code>event-queue-map.ts:37-39</code></td></tr> <tr><td>Soft delete (never hard delete; links survive parent deletion)</td><td><code>parent.service.ts:89-100</code>, <code>base.repository.ts:68-74</code></td></tr> </tbody></table> </div> <blockquote> <p><strong>Identity split (critical architectural rule):</strong> <code>parents</code> stores guardian <em>business</em> data only. Name, email, phone, avatar live on <code>users</code> (<code>COLLECTIONS.md §2.3</code> "Domain collections store domain data"; <code>users/schemas/user.schema.ts:16-78</code>). The client must always resolve a parent's display identity through the linked <code>User</code> (<code>parent.schema.ts:9-10</code>).</p> </blockquote> <h2 id="2-business-goals-15"><a class="header" href="#2-business-goals-15">2. Business goals</a></h2> <div class="table-wrapper"><table><thead><tr><th>Goal</th><th>Measure / evidence</th></tr></thead><tbody> <tr><td>One guardian profile, many children</td><td>Link table is the only M2M (<code>student-parent-link.schema.ts:18-25</code>); no child list stored on <code>parents</code></td></tr> <tr><td>Never duplicate profile data</td><td><code>parents.userId</code> required ref (<code>parent.schema.ts:9</code>); identity lives in <code>users</code></td></tr> <tr><td>Primary-guardian semantics per child</td><td><code>isPrimaryGuardian</code> per <strong>link</strong> (<code>student-parent-link.schema.ts:27</code>)</td></tr> <tr><td>Emergency-contact order per child</td><td><code>emergencyPriority</code> on both profile (default 0, <code>parent.schema.ts:24-25</code>) and link (default 0, <code>student-parent-link.schema.ts:36-37</code>)</td></tr> <tr><td>Pickup authorization per child</td><td><code>pickupAuthorization</code> (profile, default false) + <code>pickupAllowed</code> (link, default <strong>true</strong>) — note the opposite defaults (<code>parent.schema.ts:27-28</code>, <code>student-parent-link.schema.ts:33-34</code>)</td></tr> <tr><td>Removing a link never deletes a parent linked elsewhere</td><td><code>DELETE /parents</code> only soft-deletes the parent doc (<code>parent.service.ts:89-100</code>); link rows are independent (<code>student-parent-link.schema.ts</code>)</td></tr> <tr><td>Multi-tenant isolation</td><td>every query scoped <code>tenantId + isDeleted:false</code> (<code>base.repository.ts:20-30</code>); parent index <code>{tenantId, userId}</code> unique (<code>parent.schema.ts:36</code>)</td></tr> </tbody></table> </div> <h2 id="3-user-goals-13"><a class="header" href="#3-user-goals-13">3. User goals</a></h2> <ul> <li><strong>Org admin / admission staff:</strong> 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.</li> <li><strong>Parent / guardian:</strong> log in and see <strong>only their linked children</strong> (privacy boundary — persona: "Access: Only linked children", <code>USER_PERSONAS.md:48-53</code>), identify their own relationship per child (mother/father/…), see who is the primary guardian, update their own emergency-contact/pickup flags.</li> <li><strong>Multiple-guardian households:</strong> mother + father + grandparent each have their own login; each sees the same children; primary flag is per child.</li> <li><strong>Admission staff:</strong> during enrollment, attach guardians to the newly created student record (<code>POST /students</code> → then link).</li> </ul> <h2 id="4-stakeholders-13"><a class="header" href="#4-stakeholders-13">4. Stakeholders</a></h2> <p>Institution admins, admission/front-desk staff, teachers (view linked guardians), parents/guardians (self-service, forward-looking), accountants (financial-responsibility flag feeds billing — <code>fees</code> module), platform support (audit trail via <code>ParentUpdated</code> / <code>ParentDeleted</code> audit events, <code>event-queue-map.ts:38-39</code>), QA + design + engineering.</p> <h2 id="5-why-this-exists-14"><a class="header" href="#5-why-this-exists-14">5. Why this exists</a></h2> <p>Guardians are the school's primary external stakeholder: they receive attendance, results, and fee communications (blueprint: "Notifications — absentee/result alerts to linked parents", <code>04-Modules/Parents.md:49</code>). Correctness of the <strong>link graph</strong> and the <strong>primary-guardian flag</strong> 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.</p> <h2 id="6-dependencies-8"><a class="header" href="#6-dependencies-8">6. Dependencies</a></h2> <div class="table-wrapper"><table><thead><tr><th>Dependency</th><th>Role</th><th>Source</th></tr></thead><tbody> <tr><td>Users module</td><td>identity profile the parent links to (<code>userId</code>)</td><td><code>parent.schema.ts:9</code>; <code>users.service.ts:49</code></td></tr> <tr><td>Students module</td><td>students must exist before linking</td><td><code>student-parent-link.service.ts:27</code> (imports <code>StudentRepository</code>)</td></tr> <tr><td>RBAC</td><td>global <code>RbacGuard</code> + <code>parent</code> role (<code>student.read</code> only)</td><td><code>app.module.ts:131</code>, <code>role.schema.ts:50-56</code></td></tr> <tr><td>Events / BullMQ</td><td><code>ParentCreated</code> → in-app <code>parent-created</code>; <code>ParentUpdated</code>/<code>ParentDeleted</code> → audit</td><td><code>event-queue-map.ts:37-39</code></td></tr> <tr><td>Mongo collections</td><td><code>parents</code>, <code>student_parent_links</code></td><td><code>COLLECTIONS.md</code> (§ parents / § student_parent_links)</td></tr> <tr><td>BaseRepository / TenantContext</td><td>tenant scoping + soft-delete filtering</td><td><code>base.repository.ts:20-36</code></td></tr> </tbody></table> </div> <h2 id="7-success-metrics-proposed"><a class="header" href="#7-success-metrics-proposed">7. Success metrics (proposed)</a></h2> <ul> <li>Zero cross-tenant parent reads (repository scoping structural, <code>base.repository.ts:20-30</code>).</li> <li>Link graph integrity: every link's <code>studentId</code>/<code>parentId</code> resolves to a non-deleted doc in the same tenant (<strong>today not enforced</strong> — see OQ-3).</li> <li>Parent self-service adoption: % of parents who log in (requires <code>(forward-looking)</code> my-children endpoints — see OQ-1).</li> <li>Duplicate-link incidents = 0 (today not prevented — OQ-2).</li> <li>Emergency-contact list accuracy: % of children with ≥ 1 primary guardian (not enforced).</li> </ul> <h2 id="8-edge-cases-6"><a class="header" href="#8-edge-cases-6">8. Edge cases</a></h2> <ul> <li><strong>Duplicate profile:</strong> second <code>POST /parents</code> with the same <code>userId</code> → 409 <code>DUPLICATE_RESOURCE</code> "Parent profile already exists for this user." (<code>parent.service.ts:30-34</code>, enforced by unique index <code>parent.schema.ts:36</code>).</li> <li><strong>Parent not found:</strong> <code>GET/PATCH/DELETE /parents/:id</code> and <code>GET /parents/:id/students</code> → 404 <code>RESOURCE_NOT_FOUND</code> "Parent not found." (<code>parent.service.ts:51,77,91,70</code>).</li> <li><strong>Link to missing student:</strong> <code>POST /parents/link/:studentId</code> calls <code>studentRepo.findById(studentId)</code> but <strong>discards the result</strong> — a link to a non-existent student is silently created (OQ-3; <code>student-parent-link.service.ts:27</code>).</li> <li><strong>Duplicate link:</strong> same <code>(studentId, parentId)</code> twice → two link docs allowed (index non-unique, <code>student-parent-link.schema.ts:46</code>) (OQ-2).</li> <li><strong>Multiple primary guardians:</strong> no server rule prevents two links with <code>isPrimaryGuardian:true</code> for one student (OQ-5).</li> <li><strong>Unlink:</strong> <code>DELETE /parents/link/:linkId</code> soft-deletes the link; if it was the only link, no parent deletion happens and no re-promotion of another guardian occurs (OQ-4).</li> <li><strong>Delete parent with active links:</strong> parent soft-deleted, links remain orphaned (pointing at a soft-deleted parent) — no cascade (OQ-3).</li> <li><strong>Invalid relationship string:</strong> <code>LinkParentDto.relationship</code> is <code>@IsString()</code> with no enum validation (<code>link-parent.dto.ts:19-20</code>); schema enum rejects on save → Mongoose <code>ValidationError</code> → 500 <code>INTERNAL_SERVER_ERROR</code> instead of 400 (OQ-6).</li> <li><strong>Invalid ObjectId in <code>:id</code>:</strong> <code>CastError</code> → 400 <code>VALIDATION_ERROR</code> "Invalid resource identifier." (<code>http-exception.filter.ts:48,92</code>).</li> <li><strong>Parent list ignores <code>q</code> and <code>sort</code>:</strong> <code>find()</code> applies only skip/limit (<code>parent.service.ts:58-61</code>) — search UI must not rely on <code>q</code> (OQ-7).</li> </ul> <h2 id="9-assumptions-module-10"><a class="header" href="#9-assumptions-module-10">9. Assumptions (module)</a></h2> <ul> <li>Mobile client is <strong>forward-looking</strong>: backend is complete; this package is the UI-side spec (same policy as the auth package — PRD Phase 1 excludes native apps, <a href="parents/../00-shared/12_Assumptions_&_Open_Questions.html">00-shared/12 A1</a>).</li> <li><strong>RBAC is not wired to these endpoints.</strong> Global <code>RbacGuard</code> runs (<code>app.module.ts:131</code>), but <code>ParentController</code> declares only <code>@UseGuards(JwtAuthGuard)</code> (<code>parent.controller.ts:23</code>) and no <code>@Permissions(...)</code>; the <code>parent.*</code> permissions in the blueprint (<code>04-Modules/Parents.md:64-70</code>) <strong>do not exist</strong> in <code>ALL_PERMISSIONS</code> (<code>permissions.constants.ts:1-97</code>). 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).</li> <li><strong>There is no "my parent profile" resolution.</strong> No <code>GET /parents/me</code> and no <code>userId</code> filter on <code>GET /parents</code>; a logged-in parent cannot resolve their own parent profile or children with today's API. The <strong>my-children</strong> surface is <code>(forward-looking)</code> and needs either a new endpoint or a client-side mapping (OQ-1). Parent role carries only <code>student.read</code> (<code>role.schema.ts:55</code>).</li> <li><strong>Parent user accounts are created by admins</strong> via <code>POST /users</code> (no invite/password bootstrap specific to parents; no <code>auth_accounts</code> creation from the users module) (OQ-9). <code>ParentCreated</code> fires only after <code>POST /parents</code> (<code>parent.service.ts:38-45</code>).</li> <li><strong>No email on parent creation:</strong> <code>ParentCreated</code> routes to the <code>in-app</code> queue (<code>event-queue-map.ts:37</code>); <code>email.worker.ts</code> handles only <code>UserRegistered</code> and <code>PasswordResetRequested</code> (<code>email.worker.ts:25-42</code>).</li> <li>Blueprint link routes (<code>POST /students/:id/parents</code>, <code>DELETE /students/:id/parents/:linkId</code>, <code>04-Modules/Parents.md:29-30</code>) differ from the implemented controller routes (<code>/parents/link/...</code>, <code>parent.controller.ts:47-60</code>). <strong>Implementation wins</strong>; blueprint is historical.</li> </ul> <h2 id="10-open-questions-module-level-global-ledger-in-00-shared12-6"><a class="header" href="#10-open-questions-module-level-global-ledger-in-00-shared12-6">10. Open questions (module-level; global ledger in <a href="parents/../00-shared/12_Assumptions_&_Open_Questions.html">00-shared/12</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Item</th><th>Impact</th></tr></thead><tbody> <tr><td>OQ-1</td><td>No endpoint to fetch "my parent profile / my children" for the <code>parent</code> role (no <code>/parents/me</code>, no <code>userId</code> filter). Add?</td><td>my-children screen is <code>(forward-looking)</code>; privacy boundary cannot be exercised today</td></tr> <tr><td>OQ-2</td><td>Duplicate <code>(studentId, parentId)</code> links not prevented (non-unique index, no check). Add unique index + 409?</td><td>QA duplicate-link case, link sheet double-submit</td></tr> <tr><td>OQ-3</td><td><code>linkStudentParent</code> ignores the <code>findById</code> result; links to non-existent students, and links referencing soft-deleted parents, are stored. Enforce?</td><td>QA link-validation case; data integrity</td></tr> <tr><td>OQ-4</td><td>Unlink does not reassign <code>isPrimaryGuardian</code> when the primary is unlinked; no rule enforces exactly one primary per student.</td><td>primary-guardian switch UX; "who gets billed/called"</td></tr> <tr><td>OQ-5</td><td><code>isPrimaryGuardian</code> on two links for one student is allowed. Client must self-enforce (or server adds exclusivity).</td><td>QA primary-guardian case</td></tr> <tr><td>OQ-6</td><td><code>relationship</code> in <code>LinkParentDto</code> is free string (<code>@IsString()</code>), schema enum is the only gate → invalid value yields 500, not 400. Add <code>@IsEnum(RelationshipType)</code>?</td><td>client must always send valid enum; error mapping</td></tr> <tr><td>OQ-7</td><td><code>GET /parents</code> ignores <code>q</code> and <code>sort</code> query params (only page/limit applied, <code>parent.service.ts:58-61</code>).</td><td>parents list has no search/sort; UI must not fake it</td></tr> <tr><td>OQ-8</td><td>No <code>parent.*</code> permissions exist in <code>ALL_PERMISSIONS</code>; endpoints JWT-only. When does RBAC land?</td><td>role-gating of every screen; who may link/unlink</td></tr> <tr><td>OQ-9</td><td>No parent-invitation flow (user + auth account + welcome email). Backend <code>auth_accounts</code> creation is out of the users module's scope.</td><td>how a parent obtains login credentials</td></tr> <tr><td>OQ-10</td><td><code>GET /parents/:id/students</code> returns raw link docs (<code>studentId</code> ObjectIds, <code>student-parent-link.service.ts:34-36</code>) — no <code>populate</code> or student summary. Client must join.</td><td>linked-children cards need N+1 fetches or a <code>(planned)</code> enriched endpoint</td></tr> </tbody></table> </div> <h2 id="11-glossary-this-module-10"><a class="header" href="#11-glossary-this-module-10">11. Glossary (this module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Term</th><th>Meaning</th></tr></thead><tbody> <tr><td>Parent / Guardian</td><td>Business profile (<code>parents</code>) linked to identity (<code>users.userId</code>)</td></tr> <tr><td>Link</td><td><code>student_parent_links</code> doc: one parent–student relationship with metadata</td></tr> <tr><td>RelationshipType</td><td><code>mother | father | guardian | grandparent | relative | foster_parent</code> (<code>student-parent-link.schema.ts:7-14</code>)</td></tr> <tr><td>Primary guardian</td><td><code>isPrimaryGuardian</code> flag <strong>per link</strong>; intended recipient of primary communications (<code>04-Modules/Parents.md:57</code>)</td></tr> <tr><td>Emergency priority</td><td><code>emergencyContactPriority</code> (profile) / <code>emergencyPriority</code> (link), default 0</td></tr> <tr><td>Pickup</td><td><code>pickupAuthorization</code> (profile, default false) / <code>pickupAllowed</code> (link, default true)</td></tr> <tr><td>Financial responsibility</td><td><code>financialResponsibility</code> (link, default false)</td></tr> <tr><td>Envelope</td><td><code>{success,message,data,meta?,timestamp,requestId}</code> (<code>response-envelope.interceptor.ts:11-62</code>)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-parents-module"><a class="header" href="#02--user-personas-parents-module">02 — User Personas (Parents Module)</a></h1> <ul> <li><a href="parents/02_User_Personas.html#1-organization-admin-org_admin">1. Organization Admin (org_admin)</a></li> <li><a href="parents/02_User_Personas.html#2-admission--front-desk-staff-crm-adjacent">2. Admission / Front-desk Staff (CRM-adjacent)</a></li> <li><a href="parents/02_User_Personas.html#3-parent--guardian-self-service-forward-looking">3. Parent / Guardian (self-service, <code>(forward-looking)</code>)</a></li> <li><a href="parents/02_User_Personas.html#4-multiple-guardian-household-mother--father--grandparent">4. Multiple-Guardian Household (mother + father + grandparent)</a></li> <li><a href="parents/02_User_Personas.html#5-teacher--staff-read-only-consumer">5. Teacher / Staff (read-only consumer)</a></li> <li><a href="parents/02_User_Personas.html#6-system-actors">6. System actors</a></li> <li><a href="parents/02_User_Personas.html#7-role--parents-surface-matrix">7. Role → parents-surface matrix</a></li> </ul> <blockquote> <p>Roles, goals, permissions, and expected behavior for every consumer of the Parents module. Permission values are <strong>exact</strong> from <code>rbac/schemas/role.schema.ts</code> (DEFAULT_ROLES) and <code>rbac/permissions.constants.ts</code> (ALL_PERMISSIONS). <strong>Server fact:</strong> parent endpoints are JWT-only today — <code>parent.controller.ts:23</code> declares only <code>JwtAuthGuard</code>; no <code>@Permissions()</code> metadata; no <code>parent.*</code> permission exists in <code>ALL_PERMISSIONS</code> (<code>permissions.constants.ts:1-97</code>). Permission columns below state the <em>intended</em> model (from blueprint <code>04-Modules/Parents.md:64-70</code>, flagged <code>(planned)</code>) and the <em>actual</em> server posture.</p> </blockquote> <hr /> <h2 id="1-organization-admin-org_admin"><a class="header" href="#1-organization-admin-org_admin">1. Organization Admin (org_admin)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Permissions (actual)</td><td><strong>ALL_PERMISSIONS</strong> (<code>role.schema.ts:23</code>) — but none of them are parent-scoped; all parent ops work for them anyway (JWT-only)</td></tr> <tr><td>Frequency</td><td>Daily during admissions/custody changes; occasional otherwise</td></tr> <tr><td>Goal</td><td>Complete guardian picture: create profiles, link parents to students, manage primary guardians, resolve billing/pickup disputes</td></tr> <tr><td>Core job</td><td><code>POST /parents</code> (with existing <code>userId</code> from <code>POST /users</code>), <code>POST /parents/link/:studentId</code>, <code>PATCH /parents/:id</code>, <code>DELETE /parents/link/:linkId</code></td></tr> <tr><td>Pain points</td><td>Duplicate profiles per parent (only prevented by 409 on same <code>userId</code>); no search on the parent list (<code>parent.service.ts:58-61</code> ignores <code>q</code>); no audit UI yet</td></tr> <tr><td>Mental model</td><td>"Every child needs at least one guardian who answers the phone."</td></tr> <tr><td>Expected behaviour</td><td>List parents → detail (linked children) → edit → link/unlink; confirm destructive unlink</td></tr> </tbody></table> </div> <h2 id="2-admission--front-desk-staff-crm-adjacent"><a class="header" href="#2-admission--front-desk-staff-crm-adjacent">2. Admission / Front-desk Staff (CRM-adjacent)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Permissions (actual)</td><td>role-scoped (e.g., <code>student.read</code> + <code>crm.read</code>/<code>crm.lead.manage</code> — <code>permissions.constants.ts:33-34,47</code>); parent endpoints are JWT-only so they <em>can</em> operate today</td></tr> <tr><td>Frequency</td><td>Peak season: dozens of admissions/week</td></tr> <tr><td>Goal</td><td>Enrollment flow: student record exists → attach mother/father/guardian → mark primary → set pickup</td></tr> <tr><td>Core job</td><td><code>POST /students</code> (<code>student.controller.ts:38</code>), then <code>POST /parents/link/:studentId</code> with <code>relationship</code>, <code>isPrimaryGuardian</code>, <code>pickupAllowed</code></td></tr> <tr><td>Pain points</td><td>Link is a separate step after student creation (no auto-link on enrollment despite blueprint "Consumes: StudentCreated (auto-link…)" <code>04-Modules/Parents.md:41</code> — <strong>not implemented</strong>); parents of siblings must be re-linked per child</td></tr> <tr><td>Mental model</td><td>"Enroll → then 'who do we call?'"</td></tr> <tr><td>Note</td><td>The blueprint link routes (<code>POST /students/:id/parents</code>, <code>04-Modules/Parents.md:29-30</code>) are <strong>not</strong> what the controller implements — use <code>/parents/link/:studentId</code> (<code>parent.controller.ts:47-52</code>).</td></tr> </tbody></table> </div> <h2 id="3-parent--guardian-self-service-forward-looking"><a class="header" href="#3-parent--guardian-self-service-forward-looking">3. Parent / Guardian (self-service, <code>(forward-looking)</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Permissions (actual)</td><td>role <code>parent</code> = <code>['student.read']</code> only (<code>role.schema.ts:50-56</code>) — <em><em>no parent.</em> permission</em>*; profile created by admin</td></tr> <tr><td>Frequency</td><td>2–10×/month during term (attendance alerts, results, fees)</td></tr> <tr><td>Goal</td><td>See <strong>only own linked children</strong> (privacy boundary, <code>USER_PERSONAS.md:48-53</code>), check own relationship/primary flag, update emergency/pickup flags, receive absentee/result alerts (<code>04-Modules/Parents.md:49</code>)</td></tr> <tr><td>Core job</td><td>Log in → my children → per-child details (attendance/results via <strong>other</strong> modules, <code>student.read</code>) → my profile edit</td></tr> <tr><td>Pain points</td><td>Cannot resolve "my profile" today: no <code>/parents/me</code>, no <code>userId</code> filter on the list (OQ-1 in <a href="parents/01_Product_Overview.html">01_Product_Overview.md</a>)</td></tr> <tr><td>Restrictions</td><td>Sees data <strong>only</strong> for linked children; cross-tenant access structurally impossible (<code>base.repository.ts:20-30</code>)</td></tr> <tr><td>Mental model</td><td>"This school app knows which kids are mine."</td></tr> <tr><td>Platform note</td><td>Parent <code>student.read</code> is honored by other modules' authorization patterns <code>(planned)</code>; the parents module itself has no owner check (OQ-8)</td></tr> </tbody></table> </div> <h2 id="4-multiple-guardian-household-mother--father--grandparent"><a class="header" href="#4-multiple-guardian-household-mother--father--grandparent">4. Multiple-Guardian Household (mother + father + grandparent)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Permissions (actual)</td><td>each guardian = separate <code>User</code> → separate <code>parents</code> doc → separate links (<code>parent.schema.ts:36</code> unique per userId)</td></tr> <tr><td>Goal</td><td>Each guardian sees the shared children; exactly one primary per child for communications; pickup rights split</td></tr> <tr><td>Core job</td><td>Admin links all three via <code>POST /parents/link/:studentId</code> per child; primary set per link</td></tr> <tr><td>Pain points</td><td>Server allows <strong>multiple primaries</strong> per student (no exclusivity, OQ-5); switching primary = PATCH of two links (old false / new true) — <strong>no dedicated endpoint</strong>; unlink of primary does not auto-promote (OQ-4)</td></tr> <tr><td>Expected behaviour</td><td>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</td></tr> <tr><td>Mental model</td><td>"Grandma can pick up, but Dad gets the bills." (billing flag = <code>financialResponsibility</code>, <code>student-parent-link.schema.ts:30-32</code>)</td></tr> </tbody></table> </div> <h2 id="5-teacher--staff-read-only-consumer"><a class="header" href="#5-teacher--staff-read-only-consumer">5. Teacher / Staff (read-only consumer)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Permissions (actual)</td><td><code>student.read</code> (teacher/staff, <code>role.schema.ts:31,39</code>)</td></tr> <tr><td>Goal</td><td>From a student profile, reach linked guardians for a call/note (reuses <code>GET /parents/link/student/:studentId</code>, <code>parent.controller.ts:53-57</code>)</td></tr> <tr><td>Restrictions</td><td>Must not edit guardian data; today the JWT-only API would allow it — UI hides edit for this role</td></tr> <tr><td>Mental model</td><td>"Who do I contact for this child?"</td></tr> </tbody></table> </div> <h2 id="6-system-actors"><a class="header" href="#6-system-actors">6. System actors</a></h2> <ul> <li><strong>EventBus → QueueBridge:</strong> <code>ParentCreated</code> → <code>in-app</code> <code>parent-created</code> job; <code>ParentUpdated</code>/<code>ParentDeleted</code> → <code>audit-write</code> jobs (<code>event-queue-map.ts:37-39</code>). No email job for parent events (<code>email.worker.ts:25-42</code>).</li> <li><strong>Audit logger:</strong> append-only records of parent create/update/delete.</li> </ul> <h2 id="7-role--parents-surface-matrix"><a class="header" href="#7-role--parents-surface-matrix">7. Role → parents-surface matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th>Surface</th><th>org_admin</th><th>admission staff</th><th>parent</th><th>teacher/staff</th></tr></thead><tbody> <tr><td>Parents list (<code>/parents</code>)</td><td>✓</td><td>✓</td><td>—</td><td>—</td></tr> <tr><td>Parent detail + linked children</td><td>✓</td><td>✓</td><td>—</td><td>—</td></tr> <tr><td>Create / edit parent profile</td><td>✓</td><td>✓</td><td>self-edit <code>(forward-looking)</code></td><td>—</td></tr> <tr><td>Link / unlink guardians</td><td>✓</td><td>✓</td><td>—</td><td>—</td></tr> <tr><td>Set / switch primary guardian</td><td>✓</td><td>✓</td><td>view-only</td><td>—</td></tr> <tr><td>My children + child switcher</td><td>—</td><td>—</td><td><code>(forward-looking)</code></td><td>—</td></tr> <tr><td>Student detail → guardians</td><td>✓</td><td>✓</td><td>own children only</td><td>read</td></tr> </tbody></table> </div> <p>Intended permission model <code>(planned)</code>: <code>parent.read</code>, <code>parent.create</code>, <code>parent.update</code>, <code>parent.delete</code>, <code>parent.link</code> (<code>04-Modules/Parents.md:64-70</code>) — none present in <code>ALL_PERMISSIONS</code> today (<code>permissions.constants.ts</code>). UI must gate on these once seeded and treat server as JWT-only until then.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journeys-parents-module"><a class="header" href="#03--user-journeys-parents-module">03 — User Journeys (Parents Module)</a></h1> <ul> <li><a href="parents/03_User_Journey.html#1-create-parent-profile">1. Create parent profile</a></li> <li><a href="parents/03_User_Journey.html#2-link-parent-to-student">2. Link parent to student</a></li> <li><a href="parents/03_User_Journey.html#3-unlink-parent--student--switch-primary-guardian">3. Unlink parent ↔ student / switch primary guardian</a></li> <li><a href="parents/03_User_Journey.html#4-parent-logs-in--sees-own-children-self-service-forward-looking">4. Parent logs in → sees own children (self-service, <code>(forward-looking)</code>)</a></li> <li><a href="parents/03_User_Journey.html#5-parent-updates-own-profile-self-service-forward-looking">5. Parent updates own profile (self-service, <code>(forward-looking)</code>)</a></li> <li><a href="parents/03_User_Journey.html#6-cross-cutting">6. Cross-cutting</a></li> </ul> <blockquote> <p>End-to-end journeys computed from <code>parent.controller.ts</code>, <code>parent.service.ts</code>, <code>student-parent-link.service.ts</code>, <code>student.controller.ts</code>, and <code>users.service.ts</code>. Each journey: entry, intent, decision points, system responses, loading, failures, recovery, exit, back navigation, abandonment, permission denial, offline. <code>(planned)</code> / <code>(forward-looking)</code> marks per global rules.</p> </blockquote> <hr /> <h2 id="1-create-parent-profile"><a class="header" href="#1-create-parent-profile">1. Create parent profile</a></h2> <pre><code>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) </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>Decision points:</strong> which <code>User</code> (search by name/email via <code>GET /users?q=</code>, <code>users.service.ts:90-115</code>); pickup authorization default <strong>off</strong> (<code>parent.schema.ts:28</code>).</li> <li><strong>Loading:</strong> CTA spinner; anti-double-submit (<code>00-shared/08 §6</code>).</li> <li><strong>Failure covers:</strong> duplicate user (409), invalid id (400), offline (banner, blocked), rate limit (429 countdown), 5xx (generic + requestId).</li> <li><strong>Exit:</strong> success → detail; back = list. <strong>Abandonment:</strong> form state lost; user pick kept client-side only.</li> <li><strong>Privacy:</strong> parentId is tenant-scoped; no cross-tenant read possible (<code>base.repository.ts:20-30</code>).</li> </ul> <h2 id="2-link-parent-to-student"><a class="header" href="#2-link-parent-to-student">2. Link parent to student</a></h2> <pre><code>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 </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>Decision points:</strong> relationship (6 values, <code>student-parent-link.schema.ts:7-14</code>); primary guardian toggle; pickup default <strong>true</strong> on the link (<code>student-parent-link.schema.ts:33-34</code>).</li> <li><strong>Known server gaps (design around them):</strong> <ul> <li>Duplicate link not detected → second row appears (OQ-2) → client should pre-check existing links and warn "already linked".</li> <li>Missing student not detected → link row created anyway (OQ-3).</li> <li><code>relationship</code> is free text server-side (<code>link-parent.dto.ts:19-20</code>) → client must send only enum values (OQ-6).</li> </ul> </li> <li><strong>Primary-guardian UX:</strong> if the student already has a primary, warn before creating a second primary (server allows it, OQ-5).</li> <li><strong>Exit:</strong> success → sheet closes; back = cancel sheet.</li> </ul> <h2 id="3-unlink-parent--student--switch-primary-guardian"><a class="header" href="#3-unlink-parent--student--switch-primary-guardian">3. Unlink parent ↔ student / switch primary guardian</a></h2> <pre><code>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 </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>Switch primary guardian</strong> (two-step, no dedicated endpoint): <code>PATCH</code> the new primary link → <code>{isPrimaryGuardian:true}</code> then <code>PATCH</code> the old → <code>{isPrimaryGuardian:false}</code> (<code>PATCH /parents/:id</code> <strong>cannot</strong> touch links — links are only changed by create/delete; so the client calls link-creation? <strong>No</strong> — there is <strong>no link-update endpoint at all</strong>: <code>StudentParentLinkService</code> exposes only <code>linkStudentParent</code>, <code>findByStudent</code>, <code>removeLink</code> (<code>student-parent-link.service.ts:16-41</code>). → Switching primary requires <strong>delete + recreate</strong> the links (OQ-4/OQ-5).</li> <li><strong>Unlink of the primary guardian:</strong> warn "This student will have no primary guardian"; server does not auto-promote (OQ-4).</li> <li><strong>Unlink never deletes the parent</strong> — parent profile persists even with zero links (<code>04-Modules/Parents.md:58</code>).</li> <li><strong>Permission denial:</strong> JWT-only today; UI gates by intended role <code>(planned)</code>.</li> </ul> <h2 id="4-parent-logs-in--sees-own-children-self-service-forward-looking"><a class="header" href="#4-parent-logs-in--sees-own-children-self-service-forward-looking">4. Parent logs in → sees own children (self-service, <code>(forward-looking)</code>)</a></h2> <pre><code>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) </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>Privacy boundary:</strong> the <em>intent</em> is "only linked children" (<code>USER_PERSONAS.md:48-53</code>); today the parent role only carries <code>student.read</code> and has <strong>no API to enumerate its own profile</strong> (OQ-1). This journey is the top <code>(forward-looking)</code> item.</li> <li><strong>Child switcher:</strong> persists selected child in app state (<code>13_State_Management.md</code>); deep links <code>studylyon://students/:id</code> open a child if linked.</li> <li><strong>Offline:</strong> last-good children list cached (<code>00-shared/06 §3.3</code>).</li> </ul> <h2 id="5-parent-updates-own-profile-self-service-forward-looking"><a class="header" href="#5-parent-updates-own-profile-self-service-forward-looking">5. Parent updates own profile (self-service, <code>(forward-looking)</code>)</a></h2> <pre><code>entry: my profile → edit intent: update occupation/company/emergency priority/pickup authorization </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>Caveat:</strong> <code>PATCH /parents/:id</code> accepts <em>any</em> caller with a JWT (no owner check, OQ-8). When RBAC lands, self-edit must still work for the parent's <strong>own</strong> profile — the UI must know the parent's <code>id</code> (via the <code>(forward-looking)</code> my-profile resolution).</li> <li><strong>Cannot update <code>userId</code></strong> — <code>UpdateParentDto</code> has no <code>userId</code> (<code>update-parent.dto.ts:4-34</code>).</li> <li><strong>Cannot update link-level flags here</strong> — relationship/primary/pickup-per-child live on the link and are <strong>immutable after create</strong> (no link PATCH endpoint) (OQ-4).</li> </ul> <h2 id="6-cross-cutting"><a class="header" href="#6-cross-cutting">6. Cross-cutting</a></h2> <div class="table-wrapper"><table><thead><tr><th>Entry</th><th>Behavior</th><th>Status</th></tr></thead><tbody> <tr><td>Deep link <code>studylyon://parents/:id</code></td><td>open parent detail (admin)</td><td>client <code>(forward-looking)</code></td></tr> <tr><td>Deep link <code>studylyon://students/:id</code></td><td>open child (parent: only if linked)</td><td>client <code>(forward-looking)</code></td></tr> <tr><td>Push "new guardian linked"</td><td>open parent detail</td><td><code>(planned)</code> push infra (<code>00-shared/12 B3</code>)</td></tr> <tr><td><code>ParentCreated</code> in-app notification</td><td>"guardian profile created" job <code>parent-created</code> (<code>event-queue-map.ts:37</code>)</td><td>implemented queue, UI <code>(planned)</code></td></tr> </tbody></table> </div> <p><strong>Abandonment & exit rules:</strong> back = previous screen (never shell home); timeout none (server stateless); permission denial → 403 screen <code>(planned)</code>; offline = cached list, writes blocked with banner.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-parents-module"><a class="header" href="#04--information-architecture-parents-module">04 — Information Architecture (Parents Module)</a></h1> <ul> <li><a href="parents/04_Information_Architecture.html#1-two-zones">1. Two zones</a></li> <li><a href="parents/04_Information_Architecture.html#2-route-table-module-owned-extends-00-shared05-4">2. Route table (module-owned; extends <code>00-shared/05 §4</code>)</a></li> <li><a href="parents/04_Information_Architecture.html#3-screen-hierarchy">3. Screen hierarchy</a></li> <li><a href="parents/04_Information_Architecture.html#4-modal-hierarchy-module">4. Modal hierarchy (module)</a></li> <li><a href="parents/04_Information_Architecture.html#5-screen-relationships">5. Screen relationships</a></li> <li><a href="parents/04_Information_Architecture.html#6-nested-nav">6. Nested nav</a></li> <li><a href="parents/04_Information_Architecture.html#7-quick-actions--fab--context-menus">7. Quick actions / FAB / context menus</a></li> <li><a href="parents/04_Information_Architecture.html#8-deep-links-proposed-routing-table">8. Deep links (proposed routing table)</a></li> <li><a href="parents/04_Information_Architecture.html#9-accessibility-of-ia">9. Accessibility of IA</a></li> <li><a href="parents/04_Information_Architecture.html#10-peripheral-which-nav-elements-are-parents-owned">10. Peripheral: which nav elements are Parents-owned</a></li> </ul> <blockquote> <p>Where Parents screens live in the shell (<a href="parents/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a>). Two zones: the <strong>admin guardians workspace</strong> (CRUD + linking) and the <strong>parent self-service zone</strong> (<code>(forward-looking)</code> — no "my profile/children" endpoints exist today, see <a href="parents/01_Product_Overview.html">01_Product_Overview.md</a> OQ-1). All routes derive from <code>parent.controller.ts</code> (<code>parent.controller.ts:22-60</code>) and the shared shell.</p> </blockquote> <hr /> <h2 id="1-two-zones"><a class="header" href="#1-two-zones">1. Two zones</a></h2> <pre><code>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) </code></pre> <ul> <li>Admin workspace routes are gated by intended <code>parent.read/create/update/delete/link</code> permissions <code>(planned)</code> (<code>04-Modules/Parents.md:64-70</code>) — <strong>not seeded today</strong> (<code>permissions.constants.ts</code>); server is JWT-only (<code>parent.controller.ts:23</code>).</li> <li>Parent self-service zone is reachable for the <code>parent</code> role only (<code>role.schema.ts:50-56</code>); requires the <code>(forward-looking)</code> my-profile endpoint.</li> </ul> <h2 id="2-route-table-module-owned-extends-00-shared05-4-4"><a class="header" href="#2-route-table-module-owned-extends-00-shared05-4-4">2. Route table (module-owned; extends <code>00-shared/05 §4</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Route</th><th>Screen</th><th>Auth</th><th>Source API</th></tr></thead><tbody> <tr><td><code>/parents</code></td><td>Parents list</td><td>JWT (+ <code>parent.read</code> planned)</td><td><code>GET /parents</code></td></tr> <tr><td><code>/parents/new</code></td><td>Create parent form</td><td>JWT (+ <code>parent.create</code> planned)</td><td><code>POST /parents</code></td></tr> <tr><td><code>/parents/:id</code></td><td>Parent detail + linked children</td><td>JWT (+ <code>parent.read</code> planned)</td><td><code>GET /parents/:id</code>, <code>GET /parents/:id/students</code></td></tr> <tr><td><code>/parents/:id/edit</code></td><td>Edit parent form</td><td>JWT (+ <code>parent.update</code> planned)</td><td><code>PATCH /parents/:id</code></td></tr> <tr><td><code>/parents/link</code> (sheet)</td><td>Link guardian ↔ student</td><td>JWT (+ <code>parent.link</code> planned)</td><td><code>POST /parents/link/:studentId</code></td></tr> <tr><td>— (dialog)</td><td>Unlink guardian</td><td>JWT (+ <code>parent.link</code> planned)</td><td><code>DELETE /parents/link/:linkId</code></td></tr> <tr><td><code>/my/children</code></td><td>My children + switcher <code>(forward-looking)</code></td><td>parent role</td><td><code>(planned)</code></td></tr> <tr><td><code>/my/profile</code></td><td>My guardian profile <code>(forward-looking)</code></td><td>parent role</td><td><code>GET/PATCH /parents/:id</code></td></tr> </tbody></table> </div> <h2 id="3-screen-hierarchy-4"><a class="header" href="#3-screen-hierarchy-4">3. Screen hierarchy</a></h2> <ul> <li>Parents list is the admin hub: → <code>/parents/new</code>, → <code>/parents/:id</code> (detail) → <code>/parents/:id/edit</code>, detail → link sheet (per child) / unlink dialog.</li> <li>Student detail (Students module) cross-links to guardians via <code>GET /parents/link/student/:studentId</code> (<code>parent.controller.ts:53-57</code>) — guardian list embedded in the student screen.</li> <li>Parent zone: <code>/my/children</code> is the hub; child switcher swaps context; <code>/my/profile</code> is a subpage.</li> </ul> <pre class="mermaid">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)/] </pre> <h2 id="4-modal-hierarchy-module-3"><a class="header" href="#4-modal-hierarchy-module-3">4. Modal hierarchy (module)</a></h2> <p>Following <code>00-shared/05 §5</code>:</p> <ol> <li><strong>Snackbar</strong> — "Guardian linked", "Link removed", "Profile updated".</li> <li><strong>Bottom sheet</strong> — link guardian (relationship + flags ≤ 6 controls), student picker.</li> <li><strong>Dialog</strong> — unlink confirm (destructive-ish), "already linked" warning, primary switch confirm.</li> <li><strong>Full-screen page</strong> — create/edit parent form (> 3 fields → page, <code>00-shared/05 §5</code>).</li> <li><strong>Loader</strong> — skeleton on lists; CTA spinner on mutations.</li> </ol> <h2 id="5-screen-relationships-6"><a class="header" href="#5-screen-relationships-6">5. Screen relationships</a></h2> <ul> <li>List → Detail → Edit; Edit returns → Detail (refresh via re-fetch — no cache for detail, <code>00-shared/06 §3.3</code>).</li> <li>Detail → linked children → per-child guardian menu (unlink, set primary via delete+recreate — OQ-4).</li> <li>Student detail ↔ parent detail bidirectional navigation.</li> <li>Unlink from either side refreshes the other (shared <code>ParentsCubit</code> cache invalidation).</li> </ul> <h2 id="6-nested-nav-3"><a class="header" href="#6-nested-nav-3">6. Nested nav</a></h2> <ul> <li>Tablet/desktop ≥ 840 dp: master-detail — <code>/parents</code> list left, detail right (<code>00-shared/05 §3</code>); phone pushes.</li> <li>Parent zone: <code>/my/children</code> single pane with switcher bar (no master-detail).</li> </ul> <h2 id="7-quick-actions--fab--context-menus-3"><a class="header" href="#7-quick-actions--fab--context-menus-3">7. Quick actions / FAB / context menus</a></h2> <ul> <li>Parents list FAB "Add guardian" (extended) → <code>/parents/new</code>.</li> <li>Parent row <code>AppMenu</code>: Edit, Open; linked-child row <code>AppMenu</code>: Unlink, Set primary.</li> <li>Student detail: "Add guardian" tonal button → link sheet.</li> <li>Keyboard (desktop): <code>N</code> on <code>/parents</code> = new; <code>Esc</code> closes sheets/dialogs (<code>00-shared/08 §2</code>).</li> </ul> <h2 id="8-deep-links-proposed-routing-table-2"><a class="header" href="#8-deep-links-proposed-routing-table-2">8. Deep links (proposed routing table)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Pattern</th><th>Target</th><th>Note</th></tr></thead><tbody> <tr><td><code>studylyon://parents/:id</code></td><td>Parent detail</td><td>admin</td></tr> <tr><td><code>studylyon://students/:id</code></td><td>Student detail</td><td>parent: only if linked <code>(forward-looking)</code></td></tr> <tr><td><code>studylyon://my/children</code></td><td>My children</td><td>parent role</td></tr> </tbody></table> </div> <h2 id="9-accessibility-of-ia-3"><a class="header" href="#9-accessibility-of-ia-3">9. Accessibility of IA</a></h2> <ul> <li>One <code>headlineMedium</code> per screen; forms ordered identity → role → flags; focus begins at first field (<code>09_Accessibility_Baseline.md §11</code>).</li> <li>Link sheet: single semantics group per guardian row.</li> </ul> <h2 id="10-peripheral-which-nav-elements-are-parents-owned"><a class="header" href="#10-peripheral-which-nav-elements-are-parents-owned">10. Peripheral: which nav elements are Parents-owned</a></h2> <ul> <li>Admin: the <code>/parents</code> workspace only. Parent: <code>/my/children</code> + <code>/my/profile</code> — both must register in the shared shell's role-gated destination map (<code>00-shared/05 §2</code>) and be <strong>hidden + unroutable</strong> for other roles (<code>00-shared/05 §1</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-parents-module"><a class="header" href="#05--screen-inventory-parents-module">05 — Screen Inventory (Parents Module)</a></h1> <ul> <li><a href="parents/05_Screen_Inventory.html#1-parents-list-parents">1. Parents List (<code>/parents</code>)</a></li> <li><a href="parents/05_Screen_Inventory.html#2-parent-detail-parentsid">2. Parent Detail (<code>/parents/:id</code>)</a></li> <li><a href="parents/05_Screen_Inventory.html#3-create--edit-parent-form-parentsnew-parentsidedit">3. Create / Edit Parent Form (<code>/parents/new</code>, <code>/parents/:id/edit</code>)</a></li> <li><a href="parents/05_Screen_Inventory.html#4-link-guardian-sheet-from-detail-or-student-screen">4. Link Guardian Sheet (from detail or student screen)</a></li> <li><a href="parents/05_Screen_Inventory.html#5-unlink-dialog">5. Unlink Dialog</a></li> <li><a href="parents/05_Screen_Inventory.html#6-my-children-mychildren-forward-looking">6. My Children (<code>/my/children</code>) <code>(forward-looking)</code></a></li> <li><a href="parents/05_Screen_Inventory.html#7-child-switcher-component-embedded-in-mychildren-and-child-context-screens">7. Child Switcher (component, embedded in <code>/my/children</code> and child context screens)</a></li> <li><a href="parents/05_Screen_Inventory.html#8-my-profile-myprofile-forward-looking">8. My Profile (<code>/my/profile</code>) <code>(forward-looking)</code></a></li> <li><a href="parents/05_Screen_Inventory.html#shared-components-used">Shared components used</a></li> <li><a href="parents/05_Screen_Inventory.html#analytics-events-proposed">Analytics events (proposed)</a></li> </ul> <blockquote> <p>Every screen of the Parents module: intent, route, composition, states, permissions, platform behavior, events. Authoritative components in <a href="parents/../00-shared/03_Component_Library.html">00-shared/03</a>; module-specific components in <a href="parents/07_Component_Library.html">07_Component_Library.md</a>.</p> </blockquote> <p><strong>Legend</strong> — States: idle / loading / success / empty / error(offline, 4xx, 5xx) / permission. Analytics events <code>{module}.{screen}.{action}</code> (proposed; SDK open — <a href="parents/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a>).</p> <hr /> <h2 id="1-parents-list-parents"><a class="header" href="#1-parents-list-parents">1. Parents List (<code>/parents</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Paginated directory of guardian profiles</td></tr> <tr><td>Source</td><td><code>GET /parents</code> → <code>data: ParentDocument[]</code>, <code>meta:{page,limit,totalItems,totalPages,hasNext,hasPrevious}</code> (<code>parent.service.ts:55-67</code>, <code>pagination-query.dto.ts:32-39</code>)</td></tr> <tr><td>Entry / Exit</td><td>shell → list; → detail, → <code>/parents/new</code></td></tr> <tr><td>Widgets</td><td><code>AppSearchBar</code> — <strong>server ignores <code>q</code></strong> (<code>parent.service.ts:58-61</code>) → treat as client-side filter or omit (OQ-7); <code>AppListTile</code> rows (identity from joined <code>User</code>), <code>AppSkeleton(list)</code>, <code>AppEmptyState</code> ("No guardians yet"), <code>AppErrorState</code>, <code>RefreshIndicator</code></td></tr> <tr><td>FAB</td><td>"Add guardian" (extended, <code>Icons.person_add</code>) → <code>/parents/new</code></td></tr> <tr><td>Pagination</td><td>infinite scroll via <code>meta.hasNext</code> (<a href="parents/../00-shared/06_State_Management.html">00-shared/06 §3.2</a>); no <code>sort</code> param honored (OQ-7)</td></tr> <tr><td>Row actions</td><td><code>AppMenu</code>: Open, Edit</td></tr> <tr><td>States</td><td>loading skeleton; empty; error 5xx generic + requestId; offline last-good cache + banner</td></tr> <tr><td>Analytics</td><td><code>parents.list.{view,search,open,create_tap}</code></td></tr> <tr><td>A11y</td><td>row semantics "Guardian <name>, linked children N"; list position announced</td></tr> <tr><td>Adaptive</td><td>phone single column; tablet 2-column; desktop master-detail ≥ 840 dp</td></tr> </tbody></table> </div> <h2 id="2-parent-detail-parentsid"><a class="header" href="#2-parent-detail-parentsid">2. Parent Detail (<code>/parents/:id</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Full guardian profile + all linked children with per-link metadata</td></tr> <tr><td>Source</td><td><code>GET /parents/:id</code> (404 "Parent not found." <code>parent.service.ts:51</code>) + <code>GET /parents/:id/students</code> → <strong>raw link docs</strong> (<code>parent.service.ts:69-72</code>)</td></tr> <tr><td>Entry / Exit</td><td>list → detail; → edit; → link sheet</td></tr> <tr><td>Composition</td><td>Header: <code>AppAvatar</code> (from <code>User.avatarFileId</code>/name), display name (join <code>users</code>), chips (occupation, company); sections: Emergency & pickup (profile flags), Linked children (per child: relationship chip, primary badge, pickup/financial/priority line, <code>AppMenu</code>: Unlink, Set primary)</td></tr> <tr><td>Join note</td><td>Link docs expose only <code>studentId</code>/<code>parentId</code> ObjectIds (<code>student-parent-link.schema.ts:18-25</code>) → client joins <code>GET /students/:id</code> per child (OQ-10)</td></tr> <tr><td>404 state</td><td><code>AppErrorState</code> "Guardian not found" + back</td></tr> <tr><td>Pull-to-refresh</td><td>yes</td></tr> <tr><td>Analytics</td><td><code>parents.detail.{view,edit_tap,link_tap,unlink_tap}</code></td></tr> </tbody></table> </div> <h2 id="3-create--edit-parent-form-parentsnew-parentsidedit"><a class="header" href="#3-create--edit-parent-form-parentsnew-parentsidedit">3. Create / Edit Parent Form (<code>/parents/new</code>, <code>/parents/:id/edit</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Create or update a guardian profile</td></tr> <tr><td>Source</td><td><code>POST /parents</code> (<code>create-parent.dto.ts</code>), <code>PATCH /parents/:id</code> (<code>update-parent.dto.ts</code>)</td></tr> <tr><td>Create-only field</td><td><code>userId</code> (required <code>@IsMongoId</code>, <code>create-parent.dto.ts:5-7</code>) — picker over <code>GET /users?q=</code> (<code>users.service.ts:90-115</code>); <strong>not editable after create</strong> (<code>update-parent.dto.ts</code> has no <code>userId</code>)</td></tr> <tr><td>Fields</td><td>occupation, company, annualIncome (number), relationshipNotes, emergencyContactPriority (number), pickupAuthorization (switch, default false <code>parent.schema.ts:28</code>)</td></tr> <tr><td>Duplicate</td><td>409 → inline banner + "open existing profile" (<code>parent.service.ts:32-34</code>)</td></tr> <tr><td>States</td><td>idle/loading/error/saving; 400 field details mapped</td></tr> <tr><td>Analytics</td><td><code>parents.form.{open,submit,success,duplicate,error}</code></td></tr> </tbody></table> </div> <h2 id="4-link-guardian-sheet-from-detail-or-student-screen"><a class="header" href="#4-link-guardian-sheet-from-detail-or-student-screen">4. Link Guardian Sheet (from detail or student screen)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Create one <code>student_parent_links</code> row</td></tr> <tr><td>Source</td><td><code>POST /parents/link/:studentId</code> (<code>link-parent.dto.ts</code>, <code>student-parent-link.service.ts:16-32</code>)</td></tr> <tr><td>Composition</td><td>student picker (search by admission number/name) or preselected; <code>AppDropdown</code> relationship (mother/father/guardian/grandparent/relative/foster_parent — enum <code>student-parent-link.schema.ts:7-14</code>); <code>AppSwitch</code> isPrimaryGuardian; <code>AppSwitch</code> pickupAllowed (<strong>default true</strong>, <code>student-parent-link.schema.ts:33-34</code>); <code>AppSwitch</code> financialResponsibility; number field emergencyPriority</td></tr> <tr><td>Warnings</td><td>"Already linked" if the pair exists in loaded links (client pre-check, OQ-2); "This student already has a primary guardian — replace?" (OQ-5)</td></tr> <tr><td>Errors</td><td>404 student (only when server validates — today it doesn't, OQ-3); 400 validation</td></tr> <tr><td>Analytics</td><td><code>parents.link.{open,submit,success,warn_duplicate}</code></td></tr> </tbody></table> </div> <h2 id="5-unlink-dialog"><a class="header" href="#5-unlink-dialog">5. Unlink Dialog</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Confirm removing a parent–student relationship</td></tr> <tr><td>Source</td><td><code>DELETE /parents/link/:linkId</code> → 404 "Link not found." (<code>student-parent-link.service.ts:38-41</code>)</td></tr> <tr><td>Copy</td><td>"Unlink <guardian> from <student>? The guardian profile and other links are kept." (<code>04-Modules/Parents.md:58</code>)</td></tr> <tr><td>Primary case</td><td>If link <code>isPrimaryGuardian</code> → warn "This child will have no primary guardian." (OQ-4)</td></tr> <tr><td>Behavior</td><td>destructive-style confirm (<code>error</code> CTA, <code>heavyImpact</code>); server-first (no optimistic)</td></tr> </tbody></table> </div> <h2 id="6-my-children-mychildren-forward-looking"><a class="header" href="#6-my-children-mychildren-forward-looking">6. My Children (<code>/my/children</code>) <code>(forward-looking)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Parent home: enumerate <strong>own linked children</strong> only (privacy boundary <code>USER_PERSONAS.md:48-53</code>)</td></tr> <tr><td>Source</td><td><strong>No endpoint today</strong> (OQ-1): needs <code>(planned)</code> <code>GET /parents/me</code> (or <code>userId</code> filter) + links; then <code>GET /students/:id</code> join per child</td></tr> <tr><td>Composition</td><td><code>ChildSwitcherBar</code> (avatars row), selected child summary card (name, class/grade via <code>student.schema.ts:33-39</code>), entry points to attendance/results/fees (other modules, <code>student.read</code>)</td></tr> <tr><td>Empty</td><td><code>AppEmptyState</code> "No linked children — contact the school office"</td></tr> <tr><td>Offline</td><td>cached last-good children list</td></tr> </tbody></table> </div> <h2 id="7-child-switcher-component-embedded-in-mychildren-and-child-context-screens"><a class="header" href="#7-child-switcher-component-embedded-in-mychildren-and-child-context-screens">7. Child Switcher (component, embedded in <code>/my/children</code> and child context screens)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Multi-child households switch context without leaving the screen</td></tr> <tr><td>State</td><td><code>ChildSwitcherCubit</code> (selectedChildId persisted in memory; <a href="parents/13_State_Management.html">13_State_Management.md §3</a>)</td></tr> <tr><td>Behavior</td><td>horizontal avatar chips, selected = <code>primaryContainer</code>; all child-context modules read selected child</td></tr> <tr><td>A11y</td><td>each chip: "Child: <name>, selected"; <code>Semantics(toggled:)</code></td></tr> </tbody></table> </div> <h2 id="8-my-profile-myprofile-forward-looking"><a class="header" href="#8-my-profile-myprofile-forward-looking">8. My Profile (<code>/my/profile</code>) <code>(forward-looking)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Parent edits own guardian flags (occupation, emergency priority, pickup)</td></tr> <tr><td>Source</td><td><code>GET /parents/:id</code> + <code>PATCH /parents/:id</code> (self-owned id resolved via <code>(planned)</code> my-profile endpoint)</td></tr> <tr><td>Read-only</td><td>relationship/primary/pickup-per-child (link-level, no link PATCH endpoint — OQ-4); identity fields (from <code>users</code>)</td></tr> </tbody></table> </div> <hr /> <h2 id="shared-components-used-16"><a class="header" href="#shared-components-used-16">Shared components used</a></h2> <p><code>AppListTile</code>, <code>AppCard</code>, <code>AppAvatar</code>, <code>AppChips</code>, <code>AppBadge</code>, <code>AppSwitch</code>, <code>AppDropdown</code>, <code>AppTextField</code>, <code>AppSearchBar</code>, <code>AppDialog</code>, <code>AppBottomSheet</code>, <code>AppMenu</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppErrorState</code>, <code>AppOfflineBanner</code>, <code>AppFAB</code>, <code>AppSnackbar</code>, <code>AppSectionHeader</code>, <code>AppInfoRow</code>. Module-specific: <code>ParentListTile</code>, <code>GuardianCard</code>, <code>LinkedChildCard</code>, <code>RelationshipChip</code>, <code>PrimaryGuardianBadge</code>, <code>ChildSwitcherBar</code> (<a href="parents/07_Component_Library.html">07_Component_Library.md</a>).</p> <h2 id="analytics-events-proposed-13"><a class="header" href="#analytics-events-proposed-13">Analytics events (proposed)</a></h2> <p><code>parents.list.{view,search,open,create_tap}</code>, <code>parents.detail.{view,edit_tap,link_tap,unlink_tap}</code>, <code>parents.form.{open,submit,success,duplicate,error}</code>, <code>parents.link.{open,submit,success,warn_duplicate}</code>, <code>parents.unlink.{confirm,success,404}</code>, <code>parents.my.children.{view,switch_child}</code> (all proposed).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-parents-module"><a class="header" href="#06--screen-specifications-parents-module">06 — Screen Specifications (Parents Module)</a></h1> <ul> <li><a href="parents/06_Screen_Specifications.html#1-parents-list-parents">1. Parents List <code>/parents</code></a></li> <li><a href="parents/06_Screen_Specifications.html#2-parent-detail-parentsid">2. Parent Detail <code>/parents/:id</code></a></li> <li><a href="parents/06_Screen_Specifications.html#3-create--edit-parent-form-parentsnew-parentsidedit">3. Create / Edit Parent Form <code>/parents/new</code>, <code>/parents/:id/edit</code></a></li> <li><a href="parents/06_Screen_Specifications.html#4-link-guardian-sheet-from-detail--student-screen">4. Link Guardian Sheet (from detail / student screen)</a></li> <li><a href="parents/06_Screen_Specifications.html#5-unlink-dialog">5. Unlink Dialog</a></li> <li><a href="parents/06_Screen_Specifications.html#6-my-children-mychildren-forward-looking">6. My Children <code>/my/children</code> <code>(forward-looking)</code></a></li> <li><a href="parents/06_Screen_Specifications.html#7-child-switcher-component">7. Child Switcher (component)</a></li> <li><a href="parents/06_Screen_Specifications.html#8-my-profile-myprofile-forward-looking">8. My Profile <code>/my/profile</code> <code>(forward-looking)</code></a></li> <li><a href="parents/06_Screen_Specifications.html#shared-micro-interaction--motion-notes-all-screens">Shared micro-interaction & motion notes (all screens)</a></li> </ul> <blockquote> <p>Production-grade specs for each Parents screen: chrome, states, components, micro-interactions, a11y, responsive, dark/light, keyboard, motion. Tokens: <code>Sp</code> = <a href="parents/../00-shared/02_Design_Tokens.html">00-shared/02 §3</a>, <code>Ty</code> = typography §2, <code>El</code> = elevation §5, <code>R</code> = radius §4, <code>Mo</code> = motion §6. Components reference <a href="parents/../00-shared/03_Component_Library.html">00-shared/03</a> + <a href="parents/07_Component_Library.html">07_Component_Library.md</a>.</p> </blockquote> <hr /> <h2 id="1-parents-list-parents-1"><a class="header" href="#1-parents-list-parents-1">1. Parents List <code>/parents</code></a></h2> <p><strong>Chrome:</strong> standard shell <code>AppBar</code> (<code>headlineMedium</code> "Guardians", subtitle = count from <code>meta.totalItems</code>); horizontal padding 16 phone / 24 tablet+; <code>AppBar</code> elevation <code>e-1</code>.</p> <p><strong>Layout (phone):</strong></p> <pre><code>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 </code></pre> <p><strong>States:</strong></p> <ul> <li>initial → <code>AppSkeleton(list)</code> (8 rows, shimmer <code>m-slow</code>; reduced-motion static tint).</li> <li>success+data → list; <code>RefreshIndicator</code> (bypasses cache).</li> <li>success+empty → <code>AppEmptyState</code> <code>Icons.groups</code>, "No guardians yet", action "Add first guardian".</li> <li>error → <code>AppErrorState(code)</code>; 5xx generic + requestId hint; Retry re-fetches page 1.</li> <li>offline → <code>AppOfflineBanner</code> top; stale-while-revalidate from last-good cache (<code>00-shared/06 §3.3</code>).</li> <li>permission <code>(planned)</code> → 403 screen; today any JWT can list (OQ-8).</li> </ul> <p><strong>Pagination:</strong> <code>ParentsCubit.loadMore()</code> on scroll end when <code>meta.hasNext</code> (<code>pagination-query.dto.ts:32-39</code>); bottom spinner fade-in <code>m-instant</code>; <code>page</code> increments 1-based; <code>limit</code> 20 default (<code>pagination-query.dto.ts:19</code>).</p> <p><strong>Motion:</strong> row entrance stagger <code>m-entrance</code> (40 ms); FAB hide on scroll-down, show on scroll-up (<code>00-shared/03 D</code>).</p> <p><strong>A11y:</strong> search results announced via live region; row semantics "Guardian <name>, <childrenCount> linked children"; focus ring on rows (desktop).</p> <p><strong>Tablet/desktop:</strong> ≥ 840 dp master-detail — list pane 320–360 dp, detail in right pane (see §2); hover row tint <code>m-fast</code>; <code>N</code> key = new guardian; <code>Esc</code> clears search.</p> <p><strong>Dark/light:</strong> token-only (<code>surface</code>, <code>surfaceContainerLow</code>, <code>onSurfaceVariant</code>); children-count badge <code>primaryContainer</code>.</p> <h2 id="2-parent-detail-parentsid-1"><a class="header" href="#2-parent-detail-parentsid-1">2. Parent Detail <code>/parents/:id</code></a></h2> <p><strong>Chrome:</strong> <code>AppBar</code> title = display name (from joined <code>User</code>), back to list; body scrolls.</p> <p><strong>Layout (phone):</strong></p> <pre><code>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" </code></pre> <p><strong>States:</strong></p> <ul> <li>loading → <code>AppSkeleton</code> header card + 2 child cards.</li> <li>404 → <code>AppErrorState</code> "Guardian not found" (<code>parent.service.ts:51</code>) + back button.</li> <li>error → <code>AppErrorState(code)</code> + Retry.</li> <li>children load (<code>GET /parents/:id/students</code>) is a <strong>second call</strong> (<code>parent.service.ts:69-72</code>): header renders first, children section shows inline <code>AppSkeleton</code> rows; failure in children section only → inline <code>AppErrorState(compact)</code>.</li> </ul> <p><strong>Linked children rendering (join, OQ-10):</strong> each card fetches <code>GET /students/:id</code> (client-side join). While joining: <code>AppSkeleton</code> inline. On child 404 (deleted) → card shows "student no longer active" state with unlink action still available.</p> <p><strong>Per-child menu (<code>AppMenu</code>):</strong></p> <ul> <li>"Set as primary" → shown when <code>isPrimaryGuardian</code> is false; action = <strong>delete old primary link + recreate this one</strong> (no link PATCH endpoint — OQ-4); confirm dialog explains the two-step.</li> <li>"Unlink" → <code>AppDialog</code> (§5) → <code>DELETE /parents/link/:linkId</code> → row fade-out <code>m-base</code> + snackbar "Guardian unlinked" (<code>student-parent-link.service.ts:38-41</code>).</li> </ul> <p><strong>Guardian-relationship presentation:</strong> <code>RelationshipChip</code> per child + <code>PrimaryGuardianBadge</code> "Primary" (<code>primaryContainer</code> tonal) when <code>isPrimaryGuardian</code> (<code>student-parent-link.schema.ts:27-29</code>).</p> <p><strong>Motion:</strong> hero <code>AppAvatar</code> list→detail <code>m-slow</code>; card entrances <code>m-entrance</code> stagger.</p> <p><strong>A11y:</strong> sections use <code>Semantics(headers:)</code>; menu items labeled; badge announced "Primary guardian".</p> <p><strong>Tablet/desktop:</strong> master-detail right pane; edit opens as overlay page in pane.</p> <h2 id="3-create--edit-parent-form-parentsnew-parentsidedit-1"><a class="header" href="#3-create--edit-parent-form-parentsnew-parentsidedit-1">3. Create / Edit Parent Form <code>/parents/new</code>, <code>/parents/:id/edit</code></a></h2> <p><strong>Chrome:</strong> full-screen page (<code>00-shared/05 §5</code> — > 3 fields); <code>AppBar</code> title "Add guardian" / "Edit guardian"; scroll + keyboard avoidance.</p> <p><strong>Create layout (phone):</strong></p> <pre><code>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 </code></pre> <p><strong>Edit layout:</strong> same minus user picker (<code>UpdateParentDto</code> has no <code>userId</code>, <code>update-parent.dto.ts:4-34</code>).</p> <p><strong>Validation (client, mirrors DTO):</strong></p> <ul> <li><code>userId</code> required + MongoId (<code>create-parent.dto.ts:6-7</code>) — client always passes a picked user's id.</li> <li>occupation/company/relationshipNotes strings (<code>IsString</code> optional).</li> <li>annualIncome / emergencyContactPriority numbers (client: <code>NumberFormat</code> parsing; server has no bounds — <code>annualIncome</code> unbounded, <code>emergencyContactPriority</code> unbounded — client caps 0–9999, propose server <code>Min/Max</code>).</li> <li>pickupAuthorization boolean.</li> <li>Server 400 <code>VALIDATION_ERROR</code> details map to fields (<code>http-exception.filter.ts:104-107</code>).</li> </ul> <p><strong>States:</strong></p> <ul> <li>saving → CTA spinner, fields disabled, no double-submit.</li> <li>409 <code>DUPLICATE_RESOURCE</code> (create) → inline <code>AppBanner(error)</code> "A guardian profile already exists for this user." + button "Open existing profile" → <code>/parents/:id</code> (<code>parent.service.ts:32-34</code>).</li> <li>404 (edit) → <code>AppErrorState</code> "Parent not found." (<code>parent.service.ts:77</code>).</li> <li>success → pop to detail; snackbar "Guardian saved"; audit event <code>ParentUpdated</code> (<code>event-queue-map.ts:38</code>) or <code>ParentCreated</code> → in-app (<code>event-queue-map.ts:37</code>).</li> </ul> <p><strong>A11y:</strong> labels visible + linked; errors announced + focus first invalid (<code>09 §10</code>); autofill hints <code>organization</code> for company (desktop).</p> <p><strong>Motion:</strong> route push <code>m-base</code>; error slide-in <code>m-fast</code>.</p> <p><strong>Tablet/desktop:</strong> centered card ≤ 560 dp; <code>Enter</code> submits; <code>Tab</code> order = visual.</p> <h2 id="4-link-guardian-sheet-from-detail--student-screen"><a class="header" href="#4-link-guardian-sheet-from-detail--student-screen">4. Link Guardian Sheet (from detail / student screen)</a></h2> <p><strong>Chrome:</strong> <code>AppBottomSheet</code> (half → full height on phone when keyboard); <code>DragHandle</code>; title "Link guardian to student"; scrollable body.</p> <p><strong>Layout:</strong></p> <pre><code>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 </code></pre> <p><strong>Warnings (client-side pre-checks over loaded links):</strong></p> <ul> <li>Pair already linked → <code>AppBanner(warning)</code> "Already linked — saving creates a duplicate (server allows it, OQ-2)" + disable CTA or "Link anyway".</li> <li>Student already has a primary and <code>isPrimaryGuardian</code> toggled on → <code>AppBanner(warning)</code> "This student already has a primary guardian. Two primaries will exist." (OQ-5).</li> </ul> <p><strong>States:</strong> submitting → CTA spinner; 400 → field errors; 404 student → banner (only if server ever enforces — today <code>findById</code> result is discarded, OQ-3); success → sheet pops with link doc → children section refreshes + snackbar "Guardian linked" (<code>lightImpact</code>).</p> <p><strong>A11y:</strong> sheet = dialog semantics; each switch <code>Semantics(toggled:)</code>; dropdown role combobox.</p> <p><strong>Motion:</strong> sheet slide-up <code>m-base</code>; warning banner expand <code>AnimatedSize</code>.</p> <h2 id="5-unlink-dialog-1"><a class="header" href="#5-unlink-dialog-1">5. Unlink Dialog</a></h2> <p><strong>Chrome:</strong> <code>AppDialog</code> centered card; title "Unlink guardian?"; body: "Remove <guardian> as a guardian of <student>? The guardian profile and other links are kept." (<code>04-Modules/Parents.md:58</code>).</p> <ul> <li>Primary warning line (error container, <code>error</code> icon) when <code>isPrimaryGuardian</code>: "This child will have no primary guardian after unlinking." (OQ-4).</li> <li>Actions: <code>TextButton</code> Cancel / <code>FilledButton</code> destructive "Unlink".</li> <li>Behavior: server-first (no optimistic — irreversible); <code>heavyImpact</code> on confirm; success → row removed <code>m-base</code> fade + snackbar; 404 → treat-as-removed (<code>student-parent-link.service.ts:40</code>).</li> </ul> <h2 id="6-my-children-mychildren-forward-looking-1"><a class="header" href="#6-my-children-mychildren-forward-looking-1">6. My Children <code>/my/children</code> <code>(forward-looking)</code></a></h2> <p><strong>Chrome:</strong> shell <code>AppBar</code> "My children"; body = <code>ChildSwitcherBar</code> + selected child summary.</p> <p><strong>Composition:</strong></p> <pre><code>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 </code></pre> <p><strong>States:</strong> loading skeleton; empty <code>AppEmptyState</code> "No linked children"; 404/error → <code>AppErrorState</code>; offline → cached list + banner. <strong>Blocker note:</strong> depends on <code>(planned)</code> my-profile/children endpoint (OQ-1) — the screen spec is frozen until then; do not build against a client-side workaround.</p> <h2 id="7-child-switcher-component"><a class="header" href="#7-child-switcher-component">7. Child Switcher (component)</a></h2> <p><strong>Spec:</strong> <code>ChildSwitcherBar</code> — horizontal <code>SingleChildScrollView</code> of <code>FilterChip</code>-style avatar chips (40 dp, initials/avatar, name below, <code>primaryContainer</code> selected state); <code>ChildSwitcherCubit.selectedChildId</code> drives all child-context modules; switch animates <code>AnimatedSwitcher</code> fade <code>m-fast</code>; a11y chips <code>Semantics(button, toggled)</code>; announced "Switched to <child name>".</p> <h2 id="8-my-profile-myprofile-forward-looking-1"><a class="header" href="#8-my-profile-myprofile-forward-looking-1">8. My Profile <code>/my/profile</code> <code>(forward-looking)</code></a></h2> <p>Same form chrome as §3 (edit mode) with read-only header (identity from <code>users</code>, relationship per child from links). <code>PATCH /parents/:id</code> on save (<code>parent.service.ts:74-87</code>). Blocks: no self-id resolution (OQ-1) — frozen spec.</p> <hr /> <h2 id="shared-micro-interaction--motion-notes-all-screens-2"><a class="header" href="#shared-micro-interaction--motion-notes-all-screens-2">Shared micro-interaction & motion notes (all screens)</a></h2> <ul> <li>Route transitions <code>m-base</code>; snackbar success < 3 s, error ≥ 4 s (<code>00-shared/03 A</code>).</li> <li>Cards <code>e-1</code>; sheets <code>e-5</code>; dialogs <code>e-4</code>; FAB <code>e-3</code>, <code>r-full</code>.</li> <li>Touch targets ≥ 48 dp; rows ≥ 56 dp (<code>02 §9</code>).</li> <li>Safe areas respected; landscape lists scroll.</li> <li>Reduced motion: fades only, no stagger/shake (<code>08 §4</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-parents-module"><a class="header" href="#07--component-library-parents-module">07 — Component Library (Parents Module)</a></h1> <ul> <li><a href="parents/07_Component_Library.html#1-parentlisttile-list-row">1. ParentListTile (list row)</a></li> <li><a href="parents/07_Component_Library.html#2-linkedchildcard-detail-screen-child-row">2. LinkedChildCard (detail-screen child row)</a></li> <li><a href="parents/07_Component_Library.html#3-relationshipchip">3. RelationshipChip</a></li> <li><a href="parents/07_Component_Library.html#4-primaryguardianbadge">4. PrimaryGuardianBadge</a></li> <li><a href="parents/07_Component_Library.html#5-emergencyflagsrow-detail-meta-line">5. EmergencyFlagsRow (detail meta line)</a></li> <li><a href="parents/07_Component_Library.html#6-userpickerfield-create-form">6. UserPickerField (create-form)</a></li> <li><a href="parents/07_Component_Library.html#7-childswitcherbar">7. ChildSwitcherBar</a></li> <li><a href="parents/07_Component_Library.html#8-guardiansummarycard-student-detail-embed">8. GuardianSummaryCard (student-detail embed)</a></li> <li><a href="parents/07_Component_Library.html#9-flutter-widget-map-module">9. Flutter widget map (module)</a></li> <li><a href="parents/07_Component_Library.html#10-golden-tests-base-00-shared03-g">10. Golden tests (base 00-shared/03 G)</a></li> </ul> <blockquote> <p>Module-specific components built from shared <code>App*</code> primitives (<a href="parents/../00-shared/03_Component_Library.html">00-shared/03</a>). 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 to <code>parent.schema.ts</code>, <code>student-parent-link.schema.ts</code>, and <code>user.schema.ts</code>.</p> </blockquote> <hr /> <h2 id="1-parentlisttile-list-row"><a class="header" href="#1-parentlisttile-list-row">1. ParentListTile (list row)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Spec</th></tr></thead><tbody> <tr><td>Purpose</td><td>One guardian in <code>/parents</code> list</td></tr> <tr><td>Props</td><td><code>parentId</code>, <code>displayName</code>, <code>photoUrl?</code>, <code>occupation?</code>, <code>company?</code>, <code>linkedChildrenCount</code>, <code>onTap</code>, <code>menu?</code></td></tr> <tr><td>Rendering</td><td><code>AppListTile</code> 56+: <code>AppAvatar</code> 40 leading; title = displayName <code>titleMedium</code> (from joined <code>User</code>); subtitle = "occupation · company" <code>bodyMedium onSurfaceVariant</code> (omit empties); trailing = <code>AppBadge</code> "N children" <code>primaryContainer</code> + <code>AppMenu</code> (Open, Edit)</td></tr> <tr><td>Data source</td><td><code>GET /parents</code> + <code>GET /parents/:id/students</code> count (client join, OQ-10); children count can be lazy-fetched on demand</td></tr> <tr><td>a11y</td><td><code>Semantics(button, label: "Guardian <name>, <N> linked children")</code></td></tr> <tr><td>Tokens</td><td><code>surface</code>, <code>e-0</code>; avatar initials <code>primaryContainer</code></td></tr> <tr><td>Flutter</td><td><code>ListTile</code> in <code>Card(elevation: 0)</code> with <code>MenuAnchor</code></td></tr> </tbody></table> </div> <h2 id="2-linkedchildcard-detail-screen-child-row"><a class="header" href="#2-linkedchildcard-detail-screen-child-row">2. LinkedChildCard (detail-screen child row)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Spec</th></tr></thead><tbody> <tr><td>Purpose</td><td>One parent–student link with relationship metadata</td></tr> <tr><td>Props</td><td><code>link</code> (link doc: <code>relationship, isPrimaryGuardian, financialResponsibility, pickupAllowed, emergencyPriority</code>, <code>student-parent-link.schema.ts:18-40</code>), <code>student</code> (joined or loading), <code>onTap</code> (→ student detail), <code>onUnlink</code>, <code>onSetPrimary</code>, <code>isPrimaryWarning</code></td></tr> <tr><td>Rendering</td><td><code>AppCard</code> e-1: leading <code>AppAvatar</code> (student), title student name + <code>mono</code> admissionNumber, subtitle line: <code>RelationshipChip</code> + <code>PrimaryGuardianBadge</code>; meta line <code>bodySmall</code>: "Pickup ✓/✗ · Financial ✓/✗ · Priority N" (icon+label, never color-only — <a href="parents/09_User_Behaviour.html">09 §9</a>); trailing <code>AppMenu</code>: "Set as primary" (when not primary), "Unlink"</td></tr> <tr><td>States</td><td><code>student == null</code> → inline <code>AppSkeleton</code>; student 404 → "Student no longer active" (<code>bodySmall</code> warning) with unlink still available</td></tr> <tr><td>a11y</td><td>card button + menu; badge text "Primary guardian"</td></tr> <tr><td>Flutter</td><td><code>Card</code> + <code>ListTile</code> + <code>Row</code> of <code>Icon</code>+<code>Text</code>; <code>MenuAnchor</code></td></tr> </tbody></table> </div> <h2 id="3-relationshipchip"><a class="header" href="#3-relationshipchip">3. RelationshipChip</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Spec</th></tr></thead><tbody> <tr><td>Purpose</td><td>Display the <code>relationship</code> enum value</td></tr> <tr><td>Props</td><td><code>relationship</code> (one of `mother</td></tr> <tr><td>Mapping</td><td>label = i18n key <code>parent.relationship.<value></code>; icon map: mother → <code>female</code>, father → <code>male</code>, guardian → <code>verified_user</code>, grandparent → <code>elderly</code>, relative → <code>family_restroom</code>, foster_parent → <code>home_work</code></td></tr> <tr><td>Tokens</td><td><code>AppChips</code> read-only: <code>surfaceVariant</code> fill, <code>labelMedium</code></td></tr> <tr><td>a11y</td><td>chip label + tooltip; <code>Semantics(label: "Relationship: <value>")</code></td></tr> <tr><td>Flutter</td><td><code>Chip</code> (disabled) with <code>Icon</code></td></tr> </tbody></table> </div> <h2 id="4-primaryguardianbadge"><a class="header" href="#4-primaryguardianbadge">4. PrimaryGuardianBadge</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Spec</th></tr></thead><tbody> <tr><td>Purpose</td><td>Mark <code>isPrimaryGuardian === true</code> per link</td></tr> <tr><td>Props</td><td><code>active</code> (bool), <code>compact?</code></td></tr> <tr><td>Rendering</td><td>active → <code>AppBadge</code> tonal <code>primaryContainer</code>, icon <code>star</code> + "Primary"; inactive → nothing (or outline "Secondary" only in edit contexts)</td></tr> <tr><td>a11y</td><td>announced "Primary guardian" when active</td></tr> <tr><td>Flutter</td><td><code>Badge</code>/custom <code>Container</code> with <code>Icon</code> + <code>Text</code></td></tr> </tbody></table> </div> <h2 id="5-emergencyflagsrow-detail-meta-line"><a class="header" href="#5-emergencyflagsrow-detail-meta-line">5. EmergencyFlagsRow (detail meta line)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Spec</th></tr></thead><tbody> <tr><td>Purpose</td><td>Compact per-link flags line: pickup, financial responsibility, priority</td></tr> <tr><td>Props</td><td><code>pickupAllowed</code>, <code>financialResponsibility</code>, <code>emergencyPriority</code></td></tr> <tr><td>Rendering</td><td><code>Row</code> of icon+label pairs <code>bodySmall</code>: <code>directions_walk</code> ✓/✗ "Pickup", <code>payments</code> "Billing" (only when true), <code>priority_high</code> "N" (when > 0)</td></tr> <tr><td>a11y</td><td>each pair <code>Semantics(label)</code>; never color-only (icon + text) — <a href="parents/09_User_Behaviour.html">09 §9</a></td></tr> <tr><td>Flutter</td><td><code>Wrap</code> of <code>Icon</code>+<code>Text</code></td></tr> </tbody></table> </div> <h2 id="6-userpickerfield-create-form"><a class="header" href="#6-userpickerfield-create-form">6. UserPickerField (create-form)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Spec</th></tr></thead><tbody> <tr><td>Purpose</td><td>Choose the <code>User</code> a new parent profile belongs to</td></tr> <tr><td>Props</td><td><code>selectedUser?</code>, <code>onPicked(UserSummary)</code>, <code>enabled</code></td></tr> <tr><td>Behavior</td><td>read-only <code>AppTextField</code> + <code>Icons.search</code> → opens <code>AppBottomSheet</code> with <code>AppSearchBar</code> over <code>GET /users?q=</code> (<code>users.service.ts:90-115</code>); results = displayName + email; picking pops sheet with the user; clear button available</td></tr> <tr><td>a11y</td><td>field label "User account"; results list live region</td></tr> <tr><td>Flutter</td><td><code>InkWell</code> field + <code>showModalBottomSheet</code></td></tr> </tbody></table> </div> <h2 id="7-childswitcherbar"><a class="header" href="#7-childswitcherbar">7. ChildSwitcherBar</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Spec</th></tr></thead><tbody> <tr><td>Purpose</td><td>Multi-child context switcher (parent zone, <code>(forward-looking)</code>)</td></tr> <tr><td>Props</td><td><code>children[{id, name, avatarUrl?}]</code>, <code>selectedId</code>, <code>onChanged(id)</code>, <code>loading?</code></td></tr> <tr><td>Rendering</td><td>horizontal scrollable row of avatar chips (40 dp, initials fallback); selected = <code>primaryContainer</code> fill + <code>primary</code> border; <code>AppSkeleton</code> when loading</td></tr> <tr><td>Behavior</td><td>tap → <code>ChildSwitcherCubit.switchChild(id)</code>; announce "Switched to <name>"; persist last child in memory (per session)</td></tr> <tr><td>a11y</td><td>chips <code>Semantics(button, toggled: selected)</code></td></tr> <tr><td>Flutter</td><td><code>SingleChildScrollView</code> + custom <code>FilterChip</code>-style widgets</td></tr> </tbody></table> </div> <h2 id="8-guardiansummarycard-student-detail-embed"><a class="header" href="#8-guardiansummarycard-student-detail-embed">8. GuardianSummaryCard (student-detail embed)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Spec</th></tr></thead><tbody> <tr><td>Purpose</td><td>Guardian list inside student detail (reverse direction)</td></tr> <tr><td>Props</td><td><code>links</code>, <code>parents</code> (joined), <code>onOpenParent(id)</code>, <code>onAdd</code></td></tr> <tr><td>Rendering</td><td><code>AppCard</code> section: per link — <code>RelationshipChip</code> + name + <code>PrimaryGuardianBadge</code>; <code>TextButton</code> "Add guardian" → link sheet; empty → "No guardians linked"</td></tr> <tr><td>Source</td><td><code>GET /parents/link/student/:studentId</code> (<code>parent.controller.ts:53-57</code>) + parent join</td></tr> <tr><td>Flutter</td><td><code>Card</code> + <code>ListTile</code> rows</td></tr> </tbody></table> </div> <h2 id="9-flutter-widget-map-module"><a class="header" href="#9-flutter-widget-map-module">9. Flutter widget map (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Component</th><th>Flutter widgets</th></tr></thead><tbody> <tr><td>ParentListTile</td><td><code>ListTile</code> + <code>CircleAvatar</code> + <code>Badge</code> + <code>MenuAnchor</code></td></tr> <tr><td>LinkedChildCard</td><td><code>Card</code> + <code>ListTile</code> + <code>Chip</code> + <code>MenuAnchor</code></td></tr> <tr><td>RelationshipChip</td><td><code>Chip</code> (disabled)</td></tr> <tr><td>PrimaryGuardianBadge</td><td>custom <code>Container</code> + <code>Icon</code></td></tr> <tr><td>EmergencyFlagsRow</td><td><code>Wrap</code> of <code>Icon</code> + <code>Text</code></td></tr> <tr><td>UserPickerField</td><td><code>InkWell</code> + <code>TextField(readOnly)</code> + bottom sheet</td></tr> <tr><td>ChildSwitcherBar</td><td><code>ListView</code> horizontal of chips</td></tr> <tr><td>GuardianSummaryCard</td><td><code>Card</code> + <code>ListTile</code></td></tr> </tbody></table> </div> <h2 id="10-golden-tests-base-00-shared03-g"><a class="header" href="#10-golden-tests-base-00-shared03-g">10. Golden tests (base <a href="parents/../00-shared/03_Component_Library.html">00-shared/03 G</a>)</a></h2> <p>Every module component: golden at phone/tablet/desktop + dark mode; state permutations for <code>LinkedChildCard</code> (primary/not, pickup on/off, student-missing) and <code>ChildSwitcherBar</code> (1 child, 4 children, loading).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-parents-module"><a class="header" href="#08--form-specifications-parents-module">08 — Form Specifications (Parents Module)</a></h1> <ul> <li><a href="parents/08_Form_Specifications.html#1-create-parent--post-parents-create-parentdtots">1. Create Parent — <code>POST /parents</code> (<code>create-parent.dto.ts</code>)</a></li> <li><a href="parents/08_Form_Specifications.html#2-update-parent--patch-parentsid-update-parentdtots">2. Update Parent — <code>PATCH /parents/:id</code> (<code>update-parent.dto.ts</code>)</a></li> <li><a href="parents/08_Form_Specifications.html#3-link-parent--post-parentslinkstudentid-link-parentdtots">3. Link Parent — <code>POST /parents/link/:studentId</code> (<code>link-parent.dto.ts</code>)</a></li> <li><a href="parents/08_Form_Specifications.html#4-form-level-rules-all">4. Form-level rules (all)</a></li> <li><a href="parents/08_Form_Specifications.html#client-side-error-priority-all-forms">Client-side error priority (all forms)</a></li> </ul> <blockquote> <p>Every form field-by-field. Validation mirrors class-validator decorators <strong>exactly</strong> from <code>src/modules/parents/dto/*.ts</code>; server failures come back as <code>VALIDATION_ERROR</code> (400) with per-field <code>details</code> (<code>http-exception.filter.ts:104-107</code>). Client validates inline first; server 400 shadows client.</p> </blockquote> <hr /> <h2 id="1-create-parent--post-parents-create-parentdtots"><a class="header" href="#1-create-parent--post-parents-create-parentdtots">1. Create Parent — <code>POST /parents</code> (<code>create-parent.dto.ts</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Type</th><th>Required</th><th>Server decorator</th><th>Notes / UX</th></tr></thead><tbody> <tr><td>1</td><td><code>userId</code></td><td>ObjectId picker</td><td>✓</td><td><code>@IsMongoId()</code> (<code>create-parent.dto.ts:6-7</code>)</td><td>hidden behind <code>UserPickerField</code> (<a href="parents/07_Component_Library.html">07 §6</a>); the only required field</td></tr> <tr><td>2</td><td><code>occupation</code></td><td>text</td><td>no</td><td><code>@IsOptional() @IsString()</code> (<code>:10-12</code>)</td><td>autofill <code>organization-title</code></td></tr> <tr><td>3</td><td><code>company</code></td><td>text</td><td>no</td><td><code>@IsOptional() @IsString()</code> (<code>:14-16</code>)</td><td>autofill <code>organization</code></td></tr> <tr><td>4</td><td><code>annualIncome</code></td><td>number (currency)</td><td>no</td><td><code>@IsOptional()</code> no type decorator (<code>:19-21</code>)</td><td>client <code>NumberFormat</code> parse; display as currency; <strong>no Min/Max server-side</strong> → client bounds 0–1e9 (propose server bounds)</td></tr> <tr><td>5</td><td><code>relationshipNotes</code></td><td>text (multiline)</td><td>no</td><td><code>@IsOptional() @IsString()</code> (<code>:23-26</code>)</td><td>maxLength 500 (client)</td></tr> <tr><td>6</td><td><code>emergencyContactPriority</code></td><td>number (0–99)</td><td>no</td><td><code>@IsOptional()</code> no type decorator (<code>:28-30</code>)</td><td>default 0 (<code>parent.schema.ts:24-25</code>); client bounds 0–99; hint "lower = called first"</td></tr> <tr><td>7</td><td><code>pickupAuthorization</code></td><td>switch</td><td>no</td><td><code>@IsOptional()</code> no type decorator (<code>:32-34</code>)</td><td><strong>default false</strong> (<code>parent.schema.ts:28</code>); "Authorized to pick up child"</td></tr> </tbody></table> </div> <p>Submit → loading → server:</p> <ul> <li>201/200 <code>data</code> = parent doc (id, userId, flags, <code>createdAt/updatedAt/version</code> from <code>BaseSchema</code>, <code>base.schema.ts:8-35</code>).</li> <li>409 <code>DUPLICATE_RESOURCE</code> → banner + "Open existing profile" (<code>parent.service.ts:32-34</code>).</li> <li>400 details → per-field (<code>http-exception.filter.ts:104-107</code>).</li> </ul> <blockquote> <p><strong>Note on DTO typeless numerics/booleans:</strong> <code>annualIncome</code>, <code>emergencyContactPriority</code>, <code>pickupAuthorization</code> in <code>CreateParentDto</code> have <strong>no <code>@IsNumber()</code> / <code>@IsBoolean()</code></strong> decorators (<code>create-parent.dto.ts:18-34</code>) — strings pass validation and would be stored. Client must send proper JSON types.</p> </blockquote> <h2 id="2-update-parent--patch-parentsid-update-parentdtots"><a class="header" href="#2-update-parent--patch-parentsid-update-parentdtots">2. Update Parent — <code>PATCH /parents/:id</code> (<code>update-parent.dto.ts</code>)</a></h2> <p>Same field set as §1 <strong>minus <code>userId</code></strong> (not updatable) <strong>plus <code>metadata</code></strong>:</p> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Type</th><th>Required</th><th>Server decorator</th></tr></thead><tbody> <tr><td>8</td><td><code>occupation</code></td><td>text</td><td>no</td><td><code>@IsOptional() @IsString()</code> (<code>update-parent.dto.ts:6-8</code>)</td></tr> <tr><td>9</td><td><code>company</code></td><td>text</td><td>no</td><td><code>@IsOptional() @IsString()</code> (<code>:10-12</code>)</td></tr> <tr><td>10</td><td><code>annualIncome</code></td><td>number</td><td>no</td><td><code>@IsOptional()</code> (<code>:14-16</code>)</td></tr> <tr><td>11</td><td><code>relationshipNotes</code></td><td>text</td><td>no</td><td><code>@IsOptional() @IsString()</code> (<code>:18-20</code>)</td></tr> <tr><td>12</td><td><code>emergencyContactPriority</code></td><td>number</td><td>no</td><td><code>@IsOptional()</code> (<code>:22-24</code>)</td></tr> <tr><td>13</td><td><code>pickupAuthorization</code></td><td>boolean</td><td>no</td><td><code>@IsOptional()</code> (<code>:26-28</code>)</td></tr> <tr><td>14</td><td><code>metadata</code></td><td>object</td><td>no</td><td><code>@IsOptional()</code> (<code>:32-34</code>)</td></tr> </tbody></table> </div> <p>PATCH semantics: partial — only sent keys are set (<code>$set</code>, <code>parent.service.ts:76</code>); <code>version</code> incremented by repository (<code>base.repository.ts:62-65</code>). Client sends only changed fields. 404 "Parent not found." (<code>parent.service.ts:77</code>).</p> <h2 id="3-link-parent--post-parentslinkstudentid-link-parentdtots"><a class="header" href="#3-link-parent--post-parentslinkstudentid-link-parentdtots">3. Link Parent — <code>POST /parents/link/:studentId</code> (<code>link-parent.dto.ts</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Type</th><th>Required</th><th>Server decorator</th><th>Notes / UX</th></tr></thead><tbody> <tr><td>15</td><td><code>parentId</code></td><td>ObjectId</td><td>✓</td><td><code>@IsMongoId()</code> (<code>link-parent.dto.ts:6-7</code>)</td><td>fixed by context (from parent detail)</td></tr> <tr><td>16</td><td><code>relationship</code></td><td>dropdown</td><td>✓</td><td><code>@IsString()</code> (<code>:19-20</code>) — <strong>free string, no enum</strong></td><td>must send exactly one of <code>mother|father|guardian|grandparent|relative|foster_parent</code> (<code>student-parent-link.schema.ts:7-14</code>); client dropdown guarantees; invalid value → schema rejection → 500 (OQ-6)</td></tr> <tr><td>17</td><td><code>isPrimaryGuardian</code></td><td>switch</td><td>no</td><td><code>@IsOptional()</code> (<code>:23-24</code>)</td><td>default false; warn when another primary exists (OQ-5)</td></tr> <tr><td>18</td><td><code>financialResponsibility</code></td><td>switch</td><td>no</td><td><code>@IsOptional()</code> (<code>:26-28</code>)</td><td>default false (<code>student-parent-link.schema.ts:31</code>)</td></tr> <tr><td>19</td><td><code>pickupAllowed</code></td><td>switch</td><td>no</td><td><code>@IsOptional()</code> (<code>:30-32</code>)</td><td><strong>default true</strong> (<code>student-parent-link.schema.ts:33-34</code>) — opposite of profile <code>pickupAuthorization</code></td></tr> <tr><td>20</td><td><code>emergencyPriority</code></td><td>number (0–99)</td><td>no</td><td><code>@IsOptional()</code> (<code>:34-36</code>)</td><td>default 0 (<code>student-parent-link.schema.ts:36-37</code>)</td></tr> </tbody></table> </div> <p><code>studentId</code> comes from the <strong>route param</strong>, never the body (<code>student-parent-link.service.ts:16-32</code>).</p> <p><strong>Client-side pre-checks before submit:</strong></p> <ul> <li>pair <code>(parentId, studentId)</code> already in loaded links → warn duplicate (OQ-2).</li> <li><code>isPrimaryGuardian</code> true while another link for this student is primary → warn (OQ-5).</li> <li>student picker required when opened from parent detail; parent picker required when opened from student detail.</li> </ul> <p>Errors: 400 validation; 404 student (only if server later enforces — OQ-3); success → link doc in <code>data</code>.</p> <h2 id="4-form-level-rules-all"><a class="header" href="#4-form-level-rules-all">4. Form-level rules (all)</a></h2> <ul> <li>Double-submit disabled while pending (<a href="parents/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §6</a>).</li> <li><strong>No optimistic writes</strong> for any parents mutation (links are consequential; server truth), except none — all server-first (<a href="parents/../00-shared/06_State_Management.html">00-shared/06 §3.5</a>).</li> <li>Undo: none for unlink (irreversible server-side); snackbar confirm only.</li> <li>Keyboard: <code>.next</code> sequence, last <code>.done</code>; <code>Enter</code> submits (desktop).</li> <li>Autofill hints on identity-ish fields (occupation/company).</li> <li>Error copy: envelope <code>message</code> for business 4xx (409/404); codes for the rest (<a href="parents/07_Component_Library.html">07_API_Conventions.md §11</a>).</li> </ul> <h2 id="client-side-error-priority-all-forms-1"><a class="header" href="#client-side-error-priority-all-forms-1">Client-side error priority (all forms)</a></h2> <ol> <li>400 <code>VALIDATION_ERROR</code> → field.</li> <li>404 <code>RESOURCE_NOT_FOUND</code> → banner + context action.</li> <li>409 <code>DUPLICATE_RESOURCE</code> → inline + link to existing.</li> <li>422 <code>BUSINESS_RULE_VIOLATION</code> → banner (not produced by this module today).</li> <li>429 <code>RATE_LIMITED</code> → countdown.</li> <li>5xx → <code>AppErrorState</code> + requestId.</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-parents-module"><a class="header" href="#09--user-behaviour-parents-module">09 — User Behaviour (Parents Module)</a></h1> <ul> <li><a href="parents/09_User_Behaviour.html#1-expected-behaviour-server-contract-driven">1. Expected behaviour (server-contract-driven)</a></li> <li><a href="parents/09_User_Behaviour.html#2-common-mistakes--countermeasures">2. Common mistakes & countermeasures</a></li> <li><a href="parents/09_User_Behaviour.html#3-power--new-users">3. Power & new users</a></li> <li><a href="parents/09_User_Behaviour.html#4-mobile--a11y-users">4. Mobile & a11y users</a></li> <li><a href="parents/09_User_Behaviour.html#5-interrupted-sessions--retries">5. Interrupted sessions & retries</a></li> <li><a href="parents/09_User_Behaviour.html#6-abandonment">6. Abandonment</a></li> <li><a href="parents/09_User_Behaviour.html#7-frustration-points--ux-treatments-summary">7. Frustration points → UX treatments (summary)</a></li> <li><a href="parents/09_User_Behaviour.html#8-cross-device--conflict">8. Cross-device & conflict</a></li> <li><a href="parents/09_User_Behaviour.html#9-adoption-path-holistic">9. Adoption path (holistic)</a></li> </ul> <blockquote> <p>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.</p> </blockquote> <hr /> <h2 id="1-expected-behaviour-server-contract-driven-6"><a class="header" href="#1-expected-behaviour-server-contract-driven-6">1. Expected behaviour (server-contract-driven)</a></h2> <ul> <li><strong>A guardian profile belongs to one user, forever.</strong> <code>tenantId+userId</code> unique index (<code>parent.schema.ts:36</code>); a second profile for the same user → 409. UI must pre-check via the user picker ("this user already has a guardian profile").</li> <li><strong>The same person can be linked to many children</strong> and <strong>each child can have many guardians</strong> (M2M, <code>student-parent-link.schema.ts:18-25</code>) — no "one parent one child" assumptions anywhere in the UI.</li> <li><strong>The profile is identity-light</strong>: name/email/phone come from <code>users</code> (<code>parent.schema.ts:8-32</code>, <code>COLLECTIONS.md §2.2</code>). UI never offers to edit name/email in the parent form.</li> <li><strong><code>pickupAllowed</code> (link) defaults true; <code>pickupAuthorization</code> (profile) defaults false</strong> (<code>student-parent-link.schema.ts:33-34</code>, <code>parent.schema.ts:27-28</code>) — UI must present them as separate concerns ("authorized at office level" vs "allowed per child") and not assume they match.</li> <li><strong>Unlink is safe by design</strong>: deleting a link never deletes the parent (<code>04-Modules/Parents.md:58</code>); the parent row persists with zero links.</li> <li><strong>Delete is soft</strong>: <code>DELETE /parents/:id</code> sets <code>isDeleted</code> (<code>base.repository.ts:68-74</code>); lists exclude it (<code>base.repository.ts:26-28</code>). No hard delete in the API.</li> <li><strong>Audit trail exists</strong>: <code>ParentUpdated</code>/<code>ParentDeleted</code> → <code>audit-write</code> jobs (<code>event-queue-map.ts:38-39</code>) — every profile change is traceable.</li> </ul> <h2 id="2-common-mistakes--countermeasures-6"><a class="header" href="#2-common-mistakes--countermeasures-6">2. Common mistakes & countermeasures</a></h2> <div class="table-wrapper"><table><thead><tr><th>Mistake</th><th>Related</th><th>UX countermeasure</th></tr></thead><tbody> <tr><td>Creating a duplicate profile for the same user</td><td>create</td><td>user picker warns when the chosen user already has a profile (409 otherwise, <code>parent.service.ts:32-34</code>)</td></tr> <tr><td>Linking the same guardian to the same child twice</td><td>link</td><td>client pre-check over loaded links + warning (server allows duplicates, OQ-2)</td></tr> <tr><td>Setting two primary guardians for one child</td><td>link</td><td>warning when toggling primary while another exists (OQ-5)</td></tr> <tr><td>Unlinking the primary guardian by accident</td><td>unlink</td><td>dialog warning "child will have no primary guardian" + confirm (OQ-4)</td></tr> <tr><td>Typing an invalid relationship value</td><td>link</td><td>dropdown only, enum values (<code>student-parent-link.schema.ts:7-14</code>); free text would 500 (OQ-6)</td></tr> <tr><td>Searching the parents list expecting server search</td><td>list</td><td><code>q</code> is ignored server-side (<code>parent.service.ts:58-61</code>) → client-side filter only, or hide search (OQ-7)</td></tr> <tr><td>Expecting <code>userId</code> to be editable</td><td>edit</td><td>no <code>userId</code> in <code>UpdateParentDto</code> (<code>update-parent.dto.ts:4-34</code>) — field hidden, doc note "transfer ownership = create new + link"</td></tr> <tr><td>Expecting per-child flags editable after link</td><td>link</td><td><strong>no link PATCH endpoint</strong> — per-child flags are set at create only; change = unlink + relink (OQ-4). UI must offer "recreate link" flow with copy preserved</td></tr> <tr><td>Deleting a parent expecting links to vanish</td><td>delete</td><td>soft delete leaves links (<code>parent.service.ts:89-100</code>) — UI copy: "Links are kept; children will show the guardian as inactive" (or propose cascade)</td></tr> </tbody></table> </div> <h2 id="3-power--new-users-6"><a class="header" href="#3-power--new-users-6">3. Power & new users</a></h2> <ul> <li><strong>Power user (admission staff):</strong> bulk season — keyboard <code>N</code> new guardian, fast user picker, tab-complete student admission numbers; link sheet remembers last relationship per user (session preference, client-side).</li> <li><strong>New user (admin):</strong> first-run — empty list → <code>AppEmptyState</code> action "Add guardian"; tooltip "create the user first (Users → Add), then attach the guardian profile".</li> </ul> <h2 id="4-mobile--a11y-users-6"><a class="header" href="#4-mobile--a11y-users-6">4. Mobile & a11y users</a></h2> <ul> <li><strong>One-thumb phone:</strong> single primary CTA per screen; 48 dp targets; switches ≥ 48 row.</li> <li><strong>TalkBack/VoiceOver:</strong> link sheet fully walkable; chips announce relationship; switcher announces selected child (<code>09_Accessibility_Baseline.md §7</code>).</li> <li><strong>Dynamic type 200%:</strong> child cards reflow; EmergencyFlagsRow wraps; no fixed widths.</li> <li><strong>Reduced motion:</strong> no stagger on lists; error = color+icon+text.</li> </ul> <h2 id="5-interrupted-sessions--retries-3"><a class="header" href="#5-interrupted-sessions--retries-3">5. Interrupted sessions & retries</a></h2> <ul> <li><strong>App killed mid-link:</strong> sheet state lost; parent/student context retained by route — re-open sheet with preselected context.</li> <li><strong>Mid-submit network loss:</strong> CTA stays pending; on reconnect user must retap (no silent auto-retry — write is not idempotent, OQ-2 duplicates possible).</li> <li><strong>5xx after submit:</strong> generic error + requestId; form state preserved; no double row.</li> <li><strong>Offline:</strong> lists from last-good cache + banner; all writes blocked.</li> </ul> <h2 id="6-abandonment-3"><a class="header" href="#6-abandonment-3">6. Abandonment</a></h2> <ul> <li><strong>Create form abandoned mid-way:</strong> nothing saved; user picker selection lost (server has no drafts).</li> <li><strong>Link sheet abandoned:</strong> nothing saved — no partial links (link create is atomic).</li> <li><strong>Unlink dialog abandoned:</strong> nothing happens (soft-delete only on confirm).</li> </ul> <h2 id="7-frustration-points--ux-treatments-summary-2"><a class="header" href="#7-frustration-points--ux-treatments-summary-2">7. Frustration points → UX treatments (summary)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Frustration</th><th>Treatment</th></tr></thead><tbody> <tr><td>"Which child does this guardian belong to?"</td><td>detail groups children with relationship chips + primary badge at a glance</td></tr> <tr><td>"Why can't I change the relationship after linking?"</td><td>recreate-link flow in menu with prefilled values; explanatory snackbar</td></tr> <tr><td>"The guardian shows twice for the same child"</td><td>duplicate link (server allows) → surface a "Merge/remove duplicate" action that unlinks extras</td></tr> <tr><td>"No primary guardian!"</td><td>primary warning on unlink + empty-state hint on student guardian list</td></tr> <tr><td>"This child's data looks wrong"</td><td>cross-tenant reads impossible (<code>base.repository.ts:20-30</code>) — any wrongness is a bug, not UI</td></tr> </tbody></table> </div> <h2 id="8-cross-device--conflict-2"><a class="header" href="#8-cross-device--conflict-2">8. Cross-device & conflict</a></h2> <ul> <li>Two admins edit the same parent concurrently → last-write-wins with <code>version</code> bump (<code>base.repository.ts:62-65</code>); no conflict signal to client — UI treats PATCH as absolute (server returned doc is truth).</li> <li>Unlink on one device while another shows the child → next refresh hides it; 404 on delete → treat-as-removed (<code>student-parent-link.service.ts:40</code>).</li> </ul> <h2 id="9-adoption-path-holistic-3"><a class="header" href="#9-adoption-path-holistic-3">9. Adoption path (holistic)</a></h2> <ul> <li>P0: admin CRUD + link/unlink + primary badge (all server-supported today).</li> <li>P1 <code>(forward-looking)</code>: parent self-service (my children, child switcher, my profile) — blocked on my-profile endpoint (OQ-1).</li> <li>P2 <code>(planned)</code>: RBAC <code>parent.*</code> permissions (<code>04-Modules/Parents.md:64-70</code>), duplicate link prevention, unique primary enforcement, link PATCH endpoint, cascade policies.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-parents-module"><a class="header" href="#10--interaction-specification-parents-module">10 — Interaction Specification (Parents Module)</a></h1> <ul> <li><a href="parents/10_Interaction_Specification.html#1-phone--touch">1. Phone / touch</a></li> <li><a href="parents/10_Interaction_Specification.html#2-keyboard--desktop--mouse">2. Keyboard / desktop / mouse</a></li> <li><a href="parents/10_Interaction_Specification.html#3-haptics-mobile">3. Haptics (mobile)</a></li> <li><a href="parents/10_Interaction_Specification.html#4-motion-language-module-extension-of-08-4">4. Motion language (module extension of 08 §4)</a></li> <li><a href="parents/10_Interaction_Specification.html#5-micro-interactions-catalogue">5. Micro-interactions catalogue</a></li> <li><a href="parents/10_Interaction_Specification.html#6-frustration-controls-shared-08-6--module">6. Frustration controls (shared 08 §6 + module)</a></li> <li><a href="parents/10_Interaction_Specification.html#7-special-picker--deep-link-interactions">7. Special: picker & deep-link interactions</a></li> <li><a href="parents/10_Interaction_Specification.html#8-validation-of-interaction-to-server-semantics">8. Validation of interaction to server semantics</a></li> </ul> <blockquote> <p>Module-specific interactions layered on shared <a href="parents/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>. Covers every input modality on phone/tablet/desktop.</p> </blockquote> <hr /> <h2 id="1-phone--touch-6"><a class="header" href="#1-phone--touch-6">1. Phone / touch</a></h2> <div class="table-wrapper"><table><thead><tr><th>Gesture</th><th>Behaviour</th><th>Where</th></tr></thead><tbody> <tr><td>Tap</td><td>open parent/child, submit forms, toggle switches, open menus</td><td>all</td></tr> <tr><td>Tap on CTA</td><td>submitting-aware disabled re-tap (<a href="parents/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §6</a>)</td><td>forms, sheets</td></tr> <tr><td>Long-press</td><td><code>AppMenu</code> on list rows / child cards</td><td>parents list, linked children</td></tr> <tr><td>Double-tap</td><td>none</td><td>—</td></tr> <tr><td>Swipe left/right</td><td><strong>no destructive swipe</strong> on links (irreversible server-side) — keep confirm</td><td>—</td></tr> <tr><td>Pull down</td><td>refresh lists (bypasses cache)</td><td>parents list, detail, my children</td></tr> <tr><td>Pinch/drag</td><td>none</td><td>—</td></tr> <tr><td>Hardware back</td><td>pop sheet/dialog first; then previous screen; on root → shell</td><td>all</td></tr> <tr><td>Edge swipe</td><td>drawer (shell)</td><td>—</td></tr> <tr><td>Haptics</td><td>success <code>lightImpact</code>; link/unlink confirm <code>heavyImpact</code>; menu open <code>selectionClick</code>; error <code>mediumImpact</code> (<a href="parents/08_Form_Specifications.html">08 §3</a>)</td><td>mutations</td></tr> </tbody></table> </div> <h2 id="2-keyboard--desktop--mouse-6"><a class="header" href="#2-keyboard--desktop--mouse-6">2. Keyboard / desktop / mouse</a></h2> <div class="table-wrapper"><table><thead><tr><th>Input</th><th>Behaviour</th></tr></thead><tbody> <tr><td>Tab</td><td>form order = visual order; focus ring <code>primary</code> (2 dp)</td></tr> <tr><td>Enter</td><td>submits active form (create/edit/link); confirms focused dialog button</td></tr> <tr><td>Esc</td><td>closes sheet/dialog/menu; clears search; focus returns to trigger</td></tr> <tr><td>N</td><td>new guardian (parents list)</td></tr> <tr><td>Ctrl/Cmd+Enter</td><td>submit from text areas</td></tr> <tr><td>Arrows</td><td>navigate user/student picker results</td></tr> <tr><td>Right-click</td><td>context menu (same as long-press)</td></tr> <tr><td>Hover</td><td>row tint <code>m-fast</code>, tooltip after 500 ms; card elevation hint</td></tr> <tr><td>Focus</td><td>logical <code>FocusTraversalGroup</code>; modal sheets trap focus</td></tr> </tbody></table> </div> <h2 id="3-haptics-mobile-8"><a class="header" href="#3-haptics-mobile-8">3. Haptics (mobile)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Haptic</th></tr></thead><tbody> <tr><td>Guardian linked / profile saved</td><td><code>lightImpact</code></td></tr> <tr><td>Unlink confirm</td><td><code>heavyImpact</code></td></tr> <tr><td>Open row menu</td><td><code>selectionClick</code></td></tr> <tr><td>Validation error / 4xx</td><td><code>mediumImpact</code></td></tr> <tr><td>Rule</td><td>off when system disabled; none on web/desktop (<a href="parents/08_Form_Specifications.html">08 §3</a>)</td></tr> </tbody></table> </div> <h2 id="4-motion-language-module-extension-of-08-4-5"><a class="header" href="#4-motion-language-module-extension-of-08-4-5">4. Motion language (module extension of <a href="parents/08_Form_Specifications.html">08 §4</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Transition</th><th>Duration/curve</th><th>Used by</th></tr></thead><tbody> <tr><td>Route push list→detail</td><td><code>m-base</code> slide</td><td>parents list, my children</td></tr> <tr><td>Hero avatar list→detail</td><td><code>m-slow</code> flight</td><td>parent/child cards</td></tr> <tr><td>Link sheet</td><td><code>m-base</code> bottom slide</td><td>link guardian</td></tr> <tr><td>Unlink row removal</td><td><code>m-base</code> fade-out</td><td>detail children list</td></tr> <tr><td>Warning banner expand</td><td><code>AnimatedSize</code> <code>m-base</code></td><td>duplicate/primary warnings</td></tr> <tr><td>Child switcher change</td><td><code>AnimatedSwitcher</code> fade <code>m-fast</code></td><td>my children</td></tr> <tr><td>Skeleton→content</td><td>cross-fade <code>m-slow</code></td><td>all loads</td></tr> </tbody></table> </div> <p>Reduced-motion: fades + instant only; no stagger, no hero (<a href="parents/08_Form_Specifications.html">08 §4</a>).</p> <h2 id="5-micro-interactions-catalogue-4"><a class="header" href="#5-micro-interactions-catalogue-4">5. Micro-interactions catalogue</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Detail</th></tr></thead><tbody> <tr><td>Submit pressed</td><td>CTA loading swap <code>m-base</code>, double-tap locked</td></tr> <tr><td>Switch toggle (pickup/primary)</td><td><code>m-fast</code> thumb + icon morph; warning banner slides in if conflict</td></tr> <tr><td>Duplicate link attempt</td><td>chip row shake (reduced-motion: none) + <code>AppBanner(warning)</code></td></tr> <tr><td>Unlink</td><td>dialog scale+fade <code>m-base</code>; on confirm <code>heavyImpact</code> + row fade + snackbar</td></tr> <tr><td>Children count badge</td><td>scale pop <code>m-fast</code> on refresh change</td></tr> <tr><td>Pull-to-refresh</td><td><code>primary</code> spinner; <code>lightImpact</code> on completion</td></tr> </tbody></table> </div> <h2 id="6-frustration-controls-shared-08-6--module-5"><a class="header" href="#6-frustration-controls-shared-08-6--module-5">6. Frustration controls (shared <a href="parents/08_Form_Specifications.html">08 §6</a> + module)</a></h2> <ul> <li>All presses respond < 100 ms with <code>m-fast</code> ripple.</li> <li>Every network action shows pending state (CTA spinner / skeleton).</li> <li>Submit disabled while pending — no double-submit (double link rows are a real risk, OQ-2).</li> <li>Rate-limit → countdown, no auto-retry.</li> <li>Errors persist until corrected/dismissed; never flash.</li> </ul> <h2 id="7-special-picker--deep-link-interactions"><a class="header" href="#7-special-picker--deep-link-interactions">7. Special: picker & deep-link interactions</a></h2> <ul> <li>User picker sheet: debounced <code>q</code> search (300 ms, <code>users.service.ts:90-115</code>), Enter selects first result, Esc closes.</li> <li>Student picker in link sheet: search by admission number or name; selection replaces field with chip; change reopens picker.</li> <li>Deep links: <code>studylyon://parents/:id</code> → detail (admin); <code>studylyon://students/:id</code> → child if linked <code>(forward-looking)</code>; <code>studylyon://my/children</code> → switcher.</li> <li>Clipboard: none in this module (no codes/keys).</li> </ul> <h2 id="8-validation-of-interaction-to-server-semantics-6"><a class="header" href="#8-validation-of-interaction-to-server-semantics-6">8. Validation of interaction to server semantics</a></h2> <ul> <li>"Switch primary" never <em>patches</em> a link — no link PATCH endpoint (<code>student-parent-link.service.ts:16-41</code>); the interaction performs delete+recreate and its copy must not imply an update.</li> <li>"Unlink" maps exactly to <code>DELETE /parents/link/:linkId</code> soft delete (<code>student-parent-link.service.ts:38-41</code>); 404 → treat-as-removed.</li> <li>Primary toggles warn but are <strong>not blocked</strong> client-side — the server permits multi-primary (OQ-5); UI should never silently enforce what the server won't.</li> <li>Search field on the parents list is client-side filtering only (server ignores <code>q</code>, <code>parent.service.ts:58-61</code>) — interaction spec forbids pretending it queries the server.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-parents-module"><a class="header" href="#11--design-system-mapping-parents-module">11 — Design System Mapping (Parents Module)</a></h1> <ul> <li><a href="parents/11_Design_System_Mapping.html#1-theme-recipes-module">1. Theme recipes (module)</a></li> <li><a href="parents/11_Design_System_Mapping.html#2-widget--token">2. Widget → token</a></li> <li><a href="parents/11_Design_System_Mapping.html#3-typography-mapping">3. Typography mapping</a></li> <li><a href="parents/11_Design_System_Mapping.html#4-iconography">4. Iconography</a></li> <li><a href="parents/11_Design_System_Mapping.html#5-motion-tokens-values-00-shared02-6">5. Motion tokens (values 00-shared/02 §6)</a></li> <li><a href="parents/11_Design_System_Mapping.html#6-contrast--dark-parity">6. Contrast & dark parity</a></li> <li><a href="parents/11_Design_System_Mapping.html#7-breakpoint-behaviour">7. Breakpoint behaviour</a></li> <li><a href="parents/11_Design_System_Mapping.html#8-theming-rules-enforced">8. Theming rules (enforced)</a></li> </ul> <blockquote> <p>Parents module → Material 3 / Flutter / token mapping, extending <a href="parents/../00-shared/04_Design_System_Mapping.html">00-shared/04</a>. Every color, type, spacing, elevation, radius, icon, motion reference traces to <a href="parents/../00-shared/02_Design_Tokens.html">00-shared/02_Design_Tokens.md</a>.</p> </blockquote> <hr /> <h2 id="1-theme-recipes-module-6"><a class="header" href="#1-theme-recipes-module-6">1. Theme recipes (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screens</th><th>Theme</th><th>Exception</th></tr></thead><tbody> <tr><td>Admin workspace (list/detail/forms)</td><td>standard shell</td><td>primary accent for FAB + primary-guardian badge</td></tr> <tr><td>Link sheet / unlink dialog</td><td>modal (<code>surfaceContainerHigh</code>)</td><td>warning states <code>warning</code>/<code>error</code> containers</td></tr> <tr><td>Parent self-service (my children/profile) <code>(forward-looking)</code></td><td>standard shell</td><td>switcher selected state <code>primaryContainer</code></td></tr> <tr><td>Flags & badges</td><td>icon+label always (<a href="parents/09_User_Behaviour.html">09 §9</a>)</td><td>never color-only</td></tr> </tbody></table> </div> <h2 id="2-widget--token-6"><a class="header" href="#2-widget--token-6">2. Widget → token</a></h2> <div class="table-wrapper"><table><thead><tr><th>Component</th><th>M3 / Flutter</th><th>Tokens</th><th>Elev</th><th>Radius</th></tr></thead><tbody> <tr><td>Parents list rows</td><td><code>ListTile</code></td><td><code>surface</code>, <code>onSurface</code>/<code>onSurfaceVariant</code></td><td><code>e-0</code></td><td><code>r-md</code></td></tr> <tr><td>Parent header card</td><td><code>AppCard</code></td><td><code>surfaceContainerLow</code></td><td><code>e-1</code></td><td><code>r-lg</code></td></tr> <tr><td>LinkedChildCard</td><td><code>AppCard</code></td><td><code>surfaceContainerLow</code></td><td><code>e-1</code></td><td><code>r-lg</code></td></tr> <tr><td>Primary badge</td><td>custom <code>Badge</code></td><td><code>primaryContainer</code>/<code>onPrimaryContainer</code></td><td>—</td><td><code>r-sm</code></td></tr> <tr><td>Relationship chip</td><td><code>Chip</code> (disabled)</td><td><code>surfaceVariant</code>/<code>onSurfaceVariant</code></td><td>—</td><td><code>r-sm</code></td></tr> <tr><td>FAB "Add guardian"</td><td><code>FloatingActionButton.extended</code></td><td><code>primaryContainer</code>, icon <code>primary</code></td><td><code>e-3</code></td><td><code>r-full</code></td></tr> <tr><td>Form fields</td><td><code>TextFormField</code></td><td>fill <code>surfaceVariant</code>, border <code>outline</code>, focus <code>primary</code></td><td>—</td><td><code>r-md</code></td></tr> <tr><td>Switches (pickup/primary)</td><td><code>SwitchListTile</code></td><td><code>primary</code> selected</td><td>—</td><td>—</td></tr> <tr><td>Link sheet</td><td><code>AppBottomSheet</code></td><td><code>surfaceContainerHigh</code></td><td><code>e-5</code></td><td><code>r-xl</code> top</td></tr> <tr><td>Unlink dialog</td><td><code>AppDialog</code></td><td><code>surfaceContainerHigh</code>; destructive CTA <code>error</code></td><td><code>e-4</code></td><td><code>r-lg</code></td></tr> <tr><td>Warning banners</td><td><code>AppBanner</code></td><td><code>tertiaryContainer</code> (duplicate/primary)</td><td><code>e-1</code></td><td>—</td></tr> <tr><td>Snackbar</td><td><code>SnackBar</code></td><td><code>inverseSurface</code></td><td><code>e-5</code></td><td><code>r-md</code></td></tr> <tr><td>Offline banner</td><td><code>AppOfflineBanner</code></td><td><code>tertiaryContainer</code></td><td><code>e-1</code></td><td>—</td></tr> <tr><td>Child switcher chips</td><td><code>FilterChip</code>-style</td><td>selected <code>primaryContainer</code>, else <code>surfaceVariant</code></td><td>—</td><td><code>r-full</code></td></tr> </tbody></table> </div> <h2 id="3-typography-mapping-8"><a class="header" href="#3-typography-mapping-8">3. Typography mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Token</th><th>Screen element</th></tr></thead><tbody> <tr><td><code>headlineMedium</code></td><td>screen titles (Guardians, My children)</td></tr> <tr><td><code>titleLarge</code></td><td>header card names, dialog titles</td></tr> <tr><td><code>titleMedium</code></td><td>list titles, section headers, child names</td></tr> <tr><td><code>bodyMedium</code></td><td>labels, meta lines</td></tr> <tr><td><code>bodySmall</code></td><td>flags line, timestamps, hint "end of list"</td></tr> <tr><td><code>labelLarge</code></td><td>buttons, tabs</td></tr> <tr><td><code>labelMedium</code></td><td>badges, chips</td></tr> <tr><td><code>mono</code></td><td>admission numbers, IDs</td></tr> </tbody></table> </div> <p>Numerals (priority, income, counts) use <code>FontFeature.tabularFigures()</code> (<a href="parents/02_User_Personas.html">02 §2</a>).</p> <h2 id="4-iconography-10"><a class="header" href="#4-iconography-10">4. Iconography</a></h2> <p>Material Symbols outlined; module map: <code>groups</code> (empty list), <code>person_add</code> (FAB), <code>family_restroom</code>, <code>elderly</code>, <code>female</code>, <code>male</code>, <code>verified_user</code>, <code>home_work</code> (relationship icons), <code>star</code> (primary badge), <code>directions_walk</code> (pickup), <code>payments</code> (financial), <code>priority_high</code> (emergency), <code>link</code>/<code>link_off</code> (link/unlink), <code>swap_horiz</code> (switch primary), <code>search</code> (pickers), <code>edit</code> (edit).</p> <h2 id="5-motion-tokens-values-00-shared02-6-6"><a class="header" href="#5-motion-tokens-values-00-shared02-6-6">5. Motion tokens (values <a href="parents/../00-shared/02_Design_Tokens.html">00-shared/02 §6</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Purpose</th><th>Token</th><th>Applied to</th></tr></thead><tbody> <tr><td>Route transitions</td><td><code>m-base</code></td><td>list↔detail, forms</td></tr> <tr><td>Hero avatar</td><td><code>m-slow</code></td><td>list→detail</td></tr> <tr><td>Sheet/dialog</td><td><code>m-base</code></td><td>link sheet, unlink dialog</td></tr> <tr><td>Row removal (unlink)</td><td><code>m-base</code> fade</td><td>children list</td></tr> <tr><td>Warning expand</td><td><code>m-base</code> <code>AnimatedSize</code></td><td>duplicate/primary banners</td></tr> <tr><td>Switcher fade</td><td><code>m-fast</code></td><td>child switch</td></tr> <tr><td>Badge pop</td><td><code>m-fast</code></td><td>children-count change</td></tr> <tr><td>Skeleton→content</td><td><code>m-slow</code> cross-fade</td><td>all loads</td></tr> </tbody></table> </div> <p>Rules: reduced-motion substitutes fades/instants (<a href="parents/08_Form_Specifications.html">08 §4</a>).</p> <h2 id="6-contrast--dark-parity-6"><a class="header" href="#6-contrast--dark-parity-6">6. Contrast & dark parity</a></h2> <ul> <li>All text ≥ 4.5:1; interactive chrome ≥ 3:1; focus ring <code>primary</code> 3:1 vs adjacent.</li> <li>Dark: chromatic roles TBD by designer (<a href="parents/../00-shared/02_Design_Tokens.html">00-shared/02 §1.4</a>).</li> <li>Flags (pickup/financial/priority) always icon + label — colorblind-safe (<a href="parents/09_User_Behaviour.html">09 §9</a>).</li> <li>Warning banners readable in both modes (<code>tertiaryContainer</code>/<code>onTertiaryContainer</code>).</li> </ul> <h2 id="7-breakpoint-behaviour-6"><a class="header" href="#7-breakpoint-behaviour-6">7. Breakpoint behaviour</a></h2> <div class="table-wrapper"><table><thead><tr><th>Class</th><th>Admin workspace</th><th>Parent self-service</th></tr></thead><tbody> <tr><td>phone <600</td><td>single column; sheets full-height on keyboard</td><td>switcher + single child</td></tr> <tr><td>600–839</td><td>list + detail panes (2-column)</td><td>switcher + detail</td></tr> <tr><td>≥1200</td><td>master-detail 320–360 dp list; hover + keyboard shortcuts</td><td>centered child dashboard ≤ 720 dp</td></tr> </tbody></table> </div> <h2 id="8-theming-rules-enforced-2"><a class="header" href="#8-theming-rules-enforced-2">8. Theming rules (enforced)</a></h2> <ol> <li>No literal colors — <code>context.colorScheme.*</code> only (<a href="parents/../00-shared/04_Design_System_Mapping.html">00-shared/04 §7</a>).</li> <li>No literal spacing/durations.</li> <li>Tenant branding override affects <code>primary</code> only (badge/CTA adapt automatically).</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-parents-module"><a class="header" href="#12--api-mapping-parents-module">12 — API Mapping (Parents Module)</a></h1> <ul> <li><a href="parents/12_API_Mapping.html#0-module-wide-request-envelope--client-policy">0. Module-wide request envelope & client policy</a></li> <li><a href="parents/12_API_Mapping.html#screen-parents-list">Screen: Parents list</a></li> <li><a href="parents/12_API_Mapping.html#screen-parent-detail">Screen: Parent detail</a></li> <li><a href="parents/12_API_Mapping.html#screen-create-parent">Screen: Create parent</a></li> <li><a href="parents/12_API_Mapping.html#screen-edit-parent">Screen: Edit parent</a></li> <li><a href="parents/12_API_Mapping.html#screen-delete-parent-soft">Screen: Delete parent (soft)</a></li> <li><a href="parents/12_API_Mapping.html#screen-link-guardian-sheet">Screen: Link guardian (sheet)</a></li> <li><a href="parents/12_API_Mapping.html#screen-guardians-of-a-student-student-detail-embed">Screen: Guardians of a student (student-detail embed)</a></li> <li><a href="parents/12_API_Mapping.html#screen-unlink-dialog">Screen: Unlink (dialog)</a></li> <li><a href="parents/12_API_Mapping.html#loading--streaming--realtime">Loading / streaming / realtime</a></li> <li><a href="parents/12_API_Mapping.html#client-side-error-mapping-table-module">Client-side error mapping table (module)</a></li> <li><a href="parents/12_API_Mapping.html#pagination">Pagination</a></li> <li><a href="parents/12_API_Mapping.html#optimistic--undo">Optimistic / undo</a></li> </ul> <blockquote> <p>Exact wire contract for every screen → endpoint. Base <code>/api/v1</code>; envelope per <a href="parents/../00-shared/07_API_Conventions.html">00-shared/07</a>. All endpoints from <code>src/modules/parents/controllers/parent.controller.ts</code>; business rules from <code>parent.service.ts</code> and <code>student-parent-link.service.ts</code>. Global guards: <code>RateLimitGuard</code> → <code>JwtAuthGuard</code> → <code>RbacGuard</code> (<code>app.module.ts:129-131</code>); <strong>no endpoint carries <code>@Permissions</code> metadata</strong> — parent endpoints are effectively JWT-only (OQ-8).</p> </blockquote> <hr /> <h2 id="0-module-wide-request-envelope--client-policy-10"><a class="header" href="#0-module-wide-request-envelope--client-policy-10">0. Module-wide request envelope & client policy</a></h2> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Contract</th></tr></thead><tbody> <tr><td>Base</td><td><code>https://api.<domain>/api/v1</code></td></tr> <tr><td>Headers</td><td><code>Authorization: Bearer <accessToken></code>; <code>x-request-id</code> client-generated; <code>Content-Type: application/json</code></td></tr> <tr><td>success</td><td><code>{success:true, message:"OK", data, meta?, timestamp, requestId}</code> (<code>response-envelope.interceptor.ts:47-60</code>)</td></tr> <tr><td>error</td><td><code>{success:false, message, error:{code, details?}, timestamp, requestId}</code> (<code>http-exception.filter.ts:73-81</code>)</td></tr> <tr><td>Codes</td><td>400 <code>VALIDATION_ERROR</code> / 401 <code>UNAUTHENTICATED</code> / 403 <code>PERMISSION_DENIED</code> / 404 <code>RESOURCE_NOT_FOUND</code> / 409 <code>DUPLICATE_RESOURCE</code> / 422 <code>BUSINESS_RULE_VIOLATION</code> / 429 <code>RATE_LIMITED</code> / 5xx <code>INTERNAL_SERVER_ERROR</code> (<code>http-exception.filter.ts:27-35</code>)</td></tr> <tr><td>Tenancy</td><td><code>tenantId</code> from JWT only (<code>jwt-auth.guard.ts:44-55</code>); injected by repository (<code>base.repository.ts:33-35</code>) — <strong>never in body</strong></td></tr> <tr><td>Rate tier</td><td><code>api</code> 100/min (global <code>RateLimitGuard</code>, prod-only)</td></tr> <tr><td>Caching</td><td>none server-side for parents; client last-good cache (list)</td></tr> <tr><td>Offline</td><td>reads cached; writes blocked</td></tr> <tr><td>Retry</td><td>backoff on 5xx/network; no auto-retry on 429</td></tr> </tbody></table> </div> <hr /> <h2 id="screen-parents-list"><a class="header" href="#screen-parents-list">Screen: Parents list</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>GET /parents</code></td></tr> <tr><td>Query</td><td><code>page</code> (1-based), <code>limit</code> (1–100, default 20), <code>sort</code>, <code>q</code> — <strong>only page/limit honored</strong> (<code>parent.service.ts:58-61</code>; <code>pagination-query.dto.ts:5-30</code>)</td></tr> <tr><td>Success</td><td>200 <code>data: ParentDocument[]</code>, <code>meta:{page,limit,totalItems,totalPages,hasNext,hasPrevious}</code></td></tr> <tr><td>Parent doc shape</td><td><code>{_id, tenantId, userId, occupation?, company?, annualIncome?, relationshipNotes?, emergencyContactPriority, pickupAuthorization, metadata?, createdAt, updatedAt, createdBy?, updatedBy?, version}</code> (<code>parent.schema.ts:8-32</code>, <code>base.schema.ts:8-35</code>)</td></tr> <tr><td>Client flow</td><td>page 1 → infinite scroll while <code>meta.hasNext</code>; <code>RefreshIndicator</code> reset</td></tr> <tr><td>Errors</td><td>400 bad page/limit; 401; 429; 5xx</td></tr> </tbody></table> </div> <h2 id="screen-parent-detail"><a class="header" href="#screen-parent-detail">Screen: Parent detail</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>GET /parents/:id</code> → 200 <code>data: ParentDocument</code>; 404 <code>RESOURCE_NOT_FOUND</code> "Parent not found." (<code>parent.service.ts:49-53</code>)</td></tr> <tr><td><strong>Endpoint</strong></td><td><code>GET /parents/:id/students</code> → 200 <code>data: StudentParentLinkDocument[]</code> (<strong>raw link docs</strong>, <code>parent.service.ts:69-72</code>)</td></tr> <tr><td>Link doc shape</td><td><code>{_id, tenantId, studentId, parentId, relationship, isPrimaryGuardian, financialResponsibility, pickupAllowed, emergencyPriority, metadata?, createdAt, updatedAt, version}</code> (<code>student-parent-link.schema.ts:16-41</code>)</td></tr> <tr><td>Join</td><td>client fetches <code>GET /students/:id</code> per <code>studentId</code> (OQ-10)</td></tr> <tr><td>Errors</td><td>404 (parent); 400 invalid id (CastError → "Invalid resource identifier.", <code>http-exception.filter.ts:48,92</code>)</td></tr> </tbody></table> </div> <h2 id="screen-create-parent"><a class="header" href="#screen-create-parent">Screen: Create parent</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>POST /parents</code> body <code>CreateParentDto</code> (<code>create-parent.dto.ts</code>)</td></tr> <tr><td>Required</td><td><code>userId</code> (<code>@IsMongoId</code>)</td></tr> <tr><td>Optional</td><td><code>occupation</code>, <code>company</code>, <code>annualIncome</code>, <code>relationshipNotes</code>, <code>emergencyContactPriority</code>, <code>pickupAuthorization</code></td></tr> <tr><td>Success</td><td>200/201 <code>data: ParentDocument</code> (Nest default 201 for <code>@Post</code>; envelope interceptor doesn't alter status — verify in e2e)</td></tr> <tr><td>Side effect</td><td><code>ParentCreated</code> event → <code>in-app</code> queue job <code>parent-created</code> (<code>parent.service.ts:38-45</code>, <code>event-queue-map.ts:37</code>)</td></tr> <tr><td>Errors</td><td>400 validation; 409 <code>DUPLICATE_RESOURCE</code> "Parent profile already exists for this user." (<code>parent.service.ts:32-34</code>); 429; 5xx</td></tr> </tbody></table> </div> <h2 id="screen-edit-parent"><a class="header" href="#screen-edit-parent">Screen: Edit parent</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>PATCH /parents/:id</code> body <code>UpdateParentDto</code> (<code>update-parent.dto.ts</code>) — partial <code>$set</code>; <code>userId</code> <strong>not accepted</strong></td></tr> <tr><td>Success</td><td>200 <code>data: ParentDocument</code> (updated; <code>version</code> incremented, <code>base.repository.ts:62-65</code>)</td></tr> <tr><td>Side effect</td><td><code>ParentUpdated</code> → <code>audit-write</code> <code>log-parent-updated</code> (<code>parent.service.ts:78-86</code>, <code>event-queue-map.ts:38</code>)</td></tr> <tr><td>Errors</td><td>400; 404 "Parent not found." (<code>parent.service.ts:77</code>); 429; 5xx</td></tr> </tbody></table> </div> <h2 id="screen-delete-parent-soft"><a class="header" href="#screen-delete-parent-soft">Screen: Delete parent (soft)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>DELETE /parents/:id</code></td></tr> <tr><td>Success</td><td>200 <code>data: undefined</code> (void handler, <code>parent.service.ts:89-100</code>)</td></tr> <tr><td>Side effect</td><td><code>ParentDeleted</code> → <code>audit-write</code> <code>log-parent-deleted</code> (<code>event-queue-map.ts:39</code>); <strong>links are NOT touched</strong> (OQ-3)</td></tr> <tr><td>Errors</td><td>404 "Parent not found." (<code>parent.service.ts:91</code>)</td></tr> </tbody></table> </div> <h2 id="screen-link-guardian-sheet"><a class="header" href="#screen-link-guardian-sheet">Screen: Link guardian (sheet)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>POST /parents/link/:studentId</code> body <code>LinkParentDto</code> (<code>link-parent.dto.ts</code>) — <code>studentId</code> <strong>in the URL</strong>, not body</td></tr> <tr><td>Required</td><td><code>parentId</code> (<code>@IsMongoId</code>), <code>relationship</code> (string — <strong>enum not validated in DTO</strong>, OQ-6)</td></tr> <tr><td>Optional</td><td><code>isPrimaryGuardian</code>, <code>financialResponsibility</code>, <code>pickupAllowed</code> (default true), <code>emergencyPriority</code></td></tr> <tr><td>Success</td><td>200/201 <code>data: StudentParentLinkDocument</code></td></tr> <tr><td>Validation reality</td><td><code>studentRepo.findById(studentId)</code> result <strong>discarded</strong> — missing student does not 404 (OQ-3); duplicates allowed (OQ-2)</td></tr> <tr><td>Errors</td><td>400 (bad ids/fields); 429; 5xx (invalid relationship → Mongoose ValidationError → 500, OQ-6)</td></tr> </tbody></table> </div> <h2 id="screen-guardians-of-a-student-student-detail-embed"><a class="header" href="#screen-guardians-of-a-student-student-detail-embed">Screen: Guardians of a student (student-detail embed)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>GET /parents/link/student/:studentId</code> → 200 <code>data: StudentParentLinkDocument[]</code> (<code>student-parent-link.service.ts:34-36</code>)</td></tr> <tr><td>Join</td><td>client fetches <code>GET /parents/:id</code> per <code>parentId</code></td></tr> </tbody></table> </div> <h2 id="screen-unlink-dialog"><a class="header" href="#screen-unlink-dialog">Screen: Unlink (dialog)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>DELETE /parents/link/:linkId</code></td></tr> <tr><td>Success</td><td>200 <code>data: undefined</code> (void, <code>student-parent-link.service.ts:38-41</code>) — soft delete</td></tr> <tr><td>Errors</td><td>404 <code>RESOURCE_NOT_FOUND</code> "Link not found." (<code>student-parent-link.service.ts:40</code>)</td></tr> </tbody></table> </div> <h2 id="loading--streaming--realtime-6"><a class="header" href="#loading--streaming--realtime-6">Loading / streaming / realtime</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Loading</th><th>Streaming</th><th>Realtime</th></tr></thead><tbody> <tr><td>parents list</td><td><code>AppSkeleton(list)</code></td><td>—</td><td>—</td></tr> <tr><td>parent detail</td><td>header skeleton + children skeletons</td><td>children load after header</td><td>—</td></tr> <tr><td>create/edit/link</td><td>CTA spinner</td><td>—</td><td>—</td></tr> <tr><td>my children <code>(forward-looking)</code></td><td>skeleton</td><td>—</td><td><code>(planned)</code> WS <code>parent.linked</code></td></tr> </tbody></table> </div> <h2 id="client-side-error-mapping-table-module-4"><a class="header" href="#client-side-error-mapping-table-module-4">Client-side error mapping table (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>code</th><th>UI</th></tr></thead><tbody> <tr><td>create</td><td>409</td><td>banner + "Open existing profile"</td></tr> <tr><td>any form</td><td>400</td><td>per-field details</td></tr> <tr><td>detail/edit</td><td>404</td><td><code>AppErrorState</code> + back</td></tr> <tr><td>unlink</td><td>404</td><td>treat-as-removed</td></tr> <tr><td>link</td><td>500 (invalid relationship)</td><td>generic + requestId (client prevents via dropdown)</td></tr> <tr><td>any</td><td>401</td><td>silent refresh → session expiry</td></tr> <tr><td>any</td><td>429</td><td>countdown</td></tr> <tr><td>any</td><td>5xx</td><td>generic + requestId + retry</td></tr> </tbody></table> </div> <h2 id="pagination-2"><a class="header" href="#pagination-2">Pagination</a></h2> <p><code>GET /parents</code> is the only paginated endpoint (meta as above). Link lists (<code>GET /parents/:id/students</code>, <code>GET /parents/link/student/:studentId</code>) are <strong>non-paginated full arrays</strong> (<code>student-parent-link.repository.ts:20-26</code>) — client renders all; per-student join is bounded by household size.</p> <h2 id="optimistic--undo-6"><a class="header" href="#optimistic--undo-6">Optimistic / undo</a></h2> <ul> <li><strong>No optimistic writes</strong> on any parents mutation — link/unlink are consequential; server-confirm everywhere (<a href="parents/../00-shared/06_State_Management.html">00-shared/06 §3.5</a>).</li> <li>Undo: none (no server endpoint reverses an unlink; soft-deleted links are not restorable via API).</li> <li>Read lists: <code>RefreshIndicator</code> always bypasses client cache.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-parents-module"><a class="header" href="#13--state-management-parents-module">13 — State Management (Parents Module)</a></h1> <ul> <li><a href="parents/13_State_Management.html#1-module-level-parentsstore-shared-link-cache-admin-workspace">1. Module-level: ParentsStore (shared link cache, admin workspace)</a></li> <li><a href="parents/13_State_Management.html#2-per-screen-cubits">2. Per-screen Cubits</a></li> <li><a href="parents/13_State_Management.html#3-state-objects-concise">3. State objects (concise)</a></li> <li><a href="parents/13_State_Management.html#4-events--actions-map-ui--cubit--api">4. Events & actions map (UI → Cubit → API)</a></li> <li><a href="parents/13_State_Management.html#5-caching--refresh">5. Caching & refresh</a></li> <li><a href="parents/13_State_Management.html#6-realtime">6. Realtime</a></li> <li><a href="parents/13_State_Management.html#7-error-states-per-action">7. Error states per action</a></li> <li><a href="parents/13_State_Management.html#8-testing-hooks-00-shared06-6">8. Testing hooks (<code>00-shared/06 §6</code>)</a></li> <li><a href="parents/13_State_Management.html#9-cross-cutting-interplay">9. Cross-cutting interplay</a></li> </ul> <blockquote> <p>Per-screen Cubits (Flutter/bloc; proposal, <a href="parents/../00-shared/06_State_Management.html">00-shared/06</a>) + the module-wide <strong>guardian-link cache</strong> that admin screens share. Backed by <code>ParentsRepository</code> (dio) calling the endpoints in <a href="parents/12_API_Mapping.html">12_API_Mapping.md</a>.</p> </blockquote> <hr /> <h2 id="1-module-level-parentsstore-shared-link-cache-admin-workspace"><a class="header" href="#1-module-level-parentsstore-shared-link-cache-admin-workspace">1. Module-level: ParentsStore (shared link cache, admin workspace)</a></h2> <ul> <li>One <code>ParentsStore</code> (Cubit-injected singleton within the module scope) holds: <code>List<ParentRef> listPage</code>, <code>meta</code>, and a per-parent <code>Map<parentId, List<Link>> links</code>.</li> <li>Invariant: any mutation (link create/delete) invalidates the affected parent's links cache so every screen re-fetches the children section on next open.</li> <li><strong>No persistence</strong> for links (server truth; small data); list uses last-good cache per <a href="parents/../00-shared/06_State_Management.html">00-shared/06 §3.3</a> (TTL 5 min; <code>RefreshIndicator</code> bypasses).</li> </ul> <h2 id="2-per-screen-cubits-3"><a class="header" href="#2-per-screen-cubits-3">2. Per-screen Cubits</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Cubit</th><th>Events → State</th></tr></thead><tbody> <tr><td>Parents list</td><td><code>ParentsCubit</code></td><td><code>Load</code>, <code>Refresh</code>, <code>LoadMore</code>, <code>Retry</code> → <code>{initial, loading, loaded(items, meta), empty, error(code), loadingMore}</code></td></tr> <tr><td>Parent detail</td><td><code>ParentDetailCubit</code></td><td><code>Load(id)</code>, <code>Refresh</code>, <code>Retry</code> → <code>{initial, loading, loaded(parent, links, students), notFound, error(code)}</code></td></tr> <tr><td>Create/Edit form</td><td><code>ParentFormCubit</code></td><td><code>Init(existing?)</code>, <code>SetField</code>, <code>Submit(form)</code> → <code>{idle, dirty, saving, saved(parentId), duplicate(existingId), validation(errors), error(code)}</code></td></tr> <tr><td>Link sheet</td><td><code>LinkCubit</code></td><td><code>Init(context)</code> (parentId+studentId known), <code>PickStudent</code>, <code>SetRelationship</code>, <code>TogglePrimary/Pickup/Financial</code>, <code>Submit</code> → <code>{idle, searching, ready, saving, linked(link), warnDuplicate, warnSecondPrimary, error}</code></td></tr> <tr><td>Unlink</td><td><code>UnlinkCubit</code></td><td><code>Confirm(linkId)</code> → <code>{idle, confirming, done, notFound, error}</code></td></tr> <tr><td>My children <code>(forward-looking)</code></td><td><code>ParentChildrenCubit</code></td><td><code>Load()</code> → <code>{initial, loading, loaded(children), empty, error}</code></td></tr> <tr><td>Child switcher</td><td><code>ChildSwitcherCubit</code></td><td><code>SwitchChild(id)</code> → <code>{children, selectedChildId}</code></td></tr> <tr><td>My profile <code>(forward-looking)</code></td><td><code>MyProfileCubit</code></td><td><code>Load()</code>, <code>Submit(form)</code> → <code>{initial, loading, loaded(profile), saving, saved, error}</code></td></tr> </tbody></table> </div> <h2 id="3-state-objects-concise-3"><a class="header" href="#3-state-objects-concise-3">3. State objects (concise)</a></h2> <pre><code class="language-dart">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; } </code></pre> <h2 id="4-events--actions-map-ui--cubit--api-3"><a class="header" href="#4-events--actions-map-ui--cubit--api-3">4. Events & actions map (UI → Cubit → API)</a></h2> <div class="table-wrapper"><table><thead><tr><th>UI event</th><th>Cubit method</th><th>Repository call</th></tr></thead><tbody> <tr><td>list open / pull</td><td><code>load()</code> / <code>refresh()</code></td><td><code>repo.parents(page, limit)</code></td></tr> <tr><td>scroll end</td><td><code>loadMore()</code></td><td><code>repo.parents(page+1)</code></td></tr> <tr><td>row open</td><td><code>detail.load(id)</code></td><td><code>repo.parent(id)</code> + <code>repo.parentLinks(id)</code> + per-child <code>repo.student(id)</code></td></tr> <tr><td>create submit</td><td><code>form.submit()</code></td><td><code>repo.createParent(dto)</code></td></tr> <tr><td>edit submit</td><td><code>form.submit()</code></td><td><code>repo.updateParent(id, dto)</code></td></tr> <tr><td>delete</td><td><code>detail.remove(id)</code> (menu)</td><td><code>repo.deleteParent(id)</code></td></tr> <tr><td>link submit</td><td><code>link.submit()</code></td><td><code>repo.linkParent(studentId, linkDto)</code></td></tr> <tr><td>student guardians embed</td><td><code>studentsGuardians.load(studentId)</code></td><td><code>repo.studentLinks(studentId)</code> + per-parent <code>repo.parent(id)</code></td></tr> <tr><td>unlink confirm</td><td><code>unlink.confirm(linkId)</code></td><td><code>repo.unlink(linkId)</code></td></tr> <tr><td>set primary</td><td><code>link.switchPrimary(link)</code></td><td><code>repo.unlink(oldPrimary)</code> + <code>repo.linkParent(studentId, newDto)</code> (OQ-4)</td></tr> <tr><td>child switch</td><td><code>switcher.switch(id)</code></td><td>— (local state)</td></tr> </tbody></table> </div> <h2 id="5-caching--refresh-2"><a class="header" href="#5-caching--refresh-2">5. Caching & refresh</a></h2> <ul> <li>Parents list: last-good cache <code>sl:cache:parents:{tenant}:{page}</code>; <code>RefreshIndicator</code> bypasses; infinite-scroll appends.</li> <li>Detail: <strong>no cache</strong> — always fetch on open; children section re-fetches after any link mutation (invariant §1).</li> <li>My children <code>(forward-looking)</code>: last-good cache 5 min + banner; child switcher selection persisted in memory only (per session).</li> </ul> <h2 id="6-realtime-2"><a class="header" href="#6-realtime-2">6. Realtime</a></h2> <ul> <li><strong>No WS surface today.</strong> <code>ParentCreated</code> → <code>in-app</code> notification job (<code>event-queue-map.ts:37</code>); when the notification center + WS ship <code>(planned)</code>, a <code>parent.linked</code> push invalidates the detail cache and nudges refresh (<code>00-shared/06 §3.4</code>).</li> </ul> <h2 id="7-error-states-per-action-4"><a class="header" href="#7-error-states-per-action-4">7. Error states per action</a></h2> <div class="table-wrapper"><table><thead><tr><th>Action</th><th>Error</th><th>State →</th></tr></thead><tbody> <tr><td>create</td><td>409</td><td><code>duplicate(existingId)</code> → banner + open existing</td></tr> <tr><td>any</td><td>404</td><td><code>notFound</code> → <code>AppErrorState</code> (detail) or treat-as-removed (unlink)</td></tr> <tr><td>link</td><td>duplicate pre-check</td><td><code>warnDuplicate</code> → warning + block submit (OQ-2)</td></tr> <tr><td>link</td><td>second primary</td><td><code>warnSecondPrimary</code> → warning, submit allowed (OQ-5)</td></tr> <tr><td>any</td><td>429</td><td>rateLimited → countdown</td></tr> <tr><td>any</td><td>401</td><td>session expiry flow (global)</td></tr> <tr><td>any</td><td>5xx</td><td><code>error(code)</code> → <code>AppErrorState</code> + requestId</td></tr> </tbody></table> </div> <h2 id="8-testing-hooks-00-shared06-6-3"><a class="header" href="#8-testing-hooks-00-shared06-6-3">8. Testing hooks (<code>00-shared/06 §6</code>)</a></h2> <ul> <li>Pure-Dart cubits; unit-test: pagination mixin (append/refresh/meta), primary-switch two-step sequencing, duplicate-link warning logic, 404 mapping.</li> <li>Widget tests: list 3 states; detail children skeleton→loaded/empty; form duplicate/validation; link sheet warnings; switcher selection.</li> </ul> <h2 id="9-cross-cutting-interplay-3"><a class="header" href="#9-cross-cutting-interplay-3">9. Cross-cutting interplay</a></h2> <ul> <li><code>ConnectivityCubit</code> gates all writes offline; reads serve cache + banner.</li> <li><code>AuthCubit</code> session expiry → re-login; module state discarded (no cross-login persistence).</li> <li>Role changes (RBAC <code>(planned)</code>) rebuild route visibility; <code>ParentsCubit</code> survives only under <code>parent.read</code>.</li> <li><code>TenantContext</code> (from <code>AuthCubit</code>) implicit in every repository call — never stored client-side per record.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-parents-module"><a class="header" href="#14--qa-checklist-parents-module">14 — QA Checklist (Parents Module)</a></h1> <ul> <li><a href="parents/14_QA_Checklist.html#1-functional">1. Functional</a></li> <li><a href="parents/14_QA_Checklist.html#2-visual--layout">2. Visual / layout</a></li> <li><a href="parents/14_QA_Checklist.html#3-accessibility-00-shared09">3. Accessibility (00-shared/09)</a></li> <li><a href="parents/14_QA_Checklist.html#4-performance">4. Performance</a></li> <li><a href="parents/14_QA_Checklist.html#5-offline--network">5. Offline & network</a></li> <li><a href="parents/14_QA_Checklist.html#6-tablet--desktop">6. Tablet / desktop</a></li> <li><a href="parents/14_QA_Checklist.html#7-localization">7. Localization</a></li> <li><a href="parents/14_QA_Checklist.html#8-permissions">8. Permissions</a></li> <li><a href="parents/14_QA_Checklist.html#9-dark-mode">9. Dark mode</a></li> <li><a href="parents/14_QA_Checklist.html#10-animations">10. Animations</a></li> <li><a href="parents/14_QA_Checklist.html#11-security">11. Security</a></li> <li><a href="parents/14_QA_Checklist.html#12-server-backed-edge-mirrors">12. Server-backed edge mirrors</a></li> <li><a href="parents/14_QA_Checklist.html#qa-scripts">QA scripts</a></li> </ul> <blockquote> <p>Module QA specific to framework + shared <a href="parents/../00-shared/10_QA_Baseline.html">00-shared/10</a>. Every check derives from a specific backend behavior so failure analysis is direct.</p> </blockquote> <hr /> <h2 id="1-functional-4"><a class="header" href="#1-functional-4">1. Functional</a></h2> <p><strong>Profile CRUD</strong></p> <ul> <li><input disabled="" type="checkbox"/> <code>POST /parents</code> with valid <code>userId</code> → 200/201 <code>data</code> parent doc (<code>parent.service.ts:29-47</code>).</li> <li><input disabled="" type="checkbox"/> Same <code>userId</code> again → 409 <code>DUPLICATE_RESOURCE</code> "Parent profile already exists for this user." (<code>parent.service.ts:32-34</code>).</li> <li><input disabled="" type="checkbox"/> <code>GET /parents</code> paginated: <code>meta</code> = page/limit/totalItems/totalPages/hasNext/hasPrevious; <code>limit</code> > 100 → 400 (<code>pagination-query.dto.ts:17-19</code>).</li> <li><input disabled="" type="checkbox"/> <code>GET /parents?q=&sort=</code> — confirm <strong>ignored</strong> server-side (expected today, <code>parent.service.ts:58-61</code>) — client filter must not pretend to search (OQ-7).</li> <li><input disabled="" type="checkbox"/> <code>GET /parents/:id</code> → 404 "Parent not found." for unknown id (<code>parent.service.ts:51</code>).</li> <li><input disabled="" type="checkbox"/> <code>PATCH /parents/:id</code> partial update; <code>userId</code> in body → <strong>silently ignored</strong> (no field in <code>UpdateParentDto</code>).</li> <li><input disabled="" type="checkbox"/> <code>DELETE /parents/:id</code> soft-delete: subsequent list/detail exclude it; <code>isDeleted</code> set (<code>base.repository.ts:68-74</code>).</li> <li><input disabled="" type="checkbox"/> Invalid ObjectId path param → 400 <code>VALIDATION_ERROR</code> "Invalid resource identifier." (<code>http-exception.filter.ts:48,92</code>).</li> </ul> <p><strong>Link validation</strong></p> <ul> <li><input disabled="" type="checkbox"/> <code>POST /parents/link/:studentId</code> creates link with <code>relationship</code>, flags, <code>emergencyPriority</code> (<code>student-parent-link.service.ts:16-32</code>).</li> <li><input disabled="" type="checkbox"/> <strong>Known gap:</strong> link to a non-existent student <strong>succeeds</strong> (findById result discarded, <code>student-parent-link.service.ts:27</code>) — QA must record behavior; decide with product (OQ-3).</li> <li><input disabled="" type="checkbox"/> <strong>Known gap:</strong> duplicate <code>(studentId, parentId)</code> link succeeds (non-unique index, <code>student-parent-link.schema.ts:46</code>) — QA must decide client-block or server-fix (OQ-2).</li> <li><input disabled="" type="checkbox"/> <strong>Known gap:</strong> invalid <code>relationship</code> string → 500 (schema enum, no DTO enum, <code>link-parent.dto.ts:19-20</code>) — client must only send enum values (OQ-6).</li> <li><input disabled="" type="checkbox"/> <code>GET /parents/link/student/:studentId</code> returns all links for student; empty array when none (<code>student-parent-link.repository.ts:20-22</code>).</li> <li><input disabled="" type="checkbox"/> <code>DELETE /parents/link/:linkId</code> → 200; repeat → 404 "Link not found." (<code>student-parent-link.service.ts:40</code>).</li> </ul> <p><strong>Primary-guardian switches</strong></p> <ul> <li><input disabled="" type="checkbox"/> Primary flag stored per link (<code>isPrimaryGuardian</code>, <code>student-parent-link.schema.ts:27-29</code>).</li> <li><input disabled="" type="checkbox"/> <strong>Known gap:</strong> two links for one student can both be primary (no exclusivity, OQ-5) — QA verifies client warning + documents server behavior.</li> <li><input disabled="" type="checkbox"/> 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 <code>LinkCubit</code>).</li> <li><input disabled="" type="checkbox"/> Unlink of primary guardian leaves student with zero primaries; warning shown (OQ-4).</li> </ul> <p><strong>Unlink with active links</strong></p> <ul> <li><input disabled="" type="checkbox"/> Unlink does <strong>not</strong> delete the parent profile (<code>parent.service.ts:89-100</code> vs <code>student-parent-link.service.ts:38-41</code>).</li> <li><input disabled="" type="checkbox"/> Parent with zero links still lists/opens fine (findLinks returns []).</li> <li><input disabled="" type="checkbox"/> Delete parent with active links: links remain, orphaned — children section must handle student-missing/parent-missing join gracefully (OQ-3).</li> </ul> <p><strong>Privacy: parent sees only own children</strong></p> <ul> <li><input disabled="" type="checkbox"/> Cross-tenant: tenant A token listing parents → only tenant A docs (<code>base.repository.ts:20-30</code>).</li> <li><input disabled="" type="checkbox"/> Parent role: <strong>no my-children endpoint exists</strong> — UI must not fake it (OQ-1); when <code>(planned)</code> lands: verify parent sees exactly linked children, other children 404/absent.</li> <li><input disabled="" type="checkbox"/> Platform admin bypasses tenant scope (<code>base.repository.ts:21-23</code>) — verify intended.</li> <li><input disabled="" type="checkbox"/> No parent.* permission exists (<code>permissions.constants.ts</code>) — any JWT can create/delete parents today; QA flags this as known risk until RBAC (OQ-8).</li> </ul> <p><strong>Multi-child households</strong></p> <ul> <li><input disabled="" type="checkbox"/> One parent linked to 3 students → 3 link rows; detail groups per child; <code>GET /parents/:id/students</code> returns all (<code>parent.service.ts:69-72</code>).</li> <li><input disabled="" type="checkbox"/> Two parents (mother+father) linked to same student → 2 rows with different relationships; per-link flags independent.</li> <li><input disabled="" type="checkbox"/> Student join: child cards render from <code>GET /students/:id</code> per link (OQ-10); archived/gone student handled.</li> </ul> <h2 id="2-visual--layout-4"><a class="header" href="#2-visual--layout-4">2. Visual / layout</a></h2> <ul> <li><input disabled="" type="checkbox"/> List/detail/forms dark-light parity; no hardcoded colors (<a href="parents/../00-shared/10_QA_Baseline.html">00-shared/10 §6</a>).</li> <li><input disabled="" type="checkbox"/> Long names + 3 children badges wrap at 200% text scale; EmergencyFlagsRow wraps.</li> <li><input disabled="" type="checkbox"/> Master-detail ≥ 840 dp; phone single pane; sheets full-height on keyboard.</li> </ul> <h2 id="3-accessibility-00-shared09-4"><a class="header" href="#3-accessibility-00-shared09-4">3. Accessibility (<a href="parents/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>)</a></h2> <ul> <li><input disabled="" type="checkbox"/> TalkBack: list → detail → link sheet → unlink full journey without sight.</li> <li><input disabled="" type="checkbox"/> VoiceOver separately.</li> <li><input disabled="" type="checkbox"/> Keyboard-only desktop: Tab order, Enter submit, Esc closes sheet/dialog, <code>N</code> new.</li> <li><input disabled="" type="checkbox"/> Relationship chips + primary badge announced; switcher <code>toggled</code> semantics.</li> <li><input disabled="" type="checkbox"/> Flags never color-only (icon+label).</li> <li><input disabled="" type="checkbox"/> Contrast 4.5:1 text / 3:1 chrome; reduced-motion off (no shake/stagger).</li> </ul> <h2 id="4-performance-4"><a class="header" href="#4-performance-4">4. Performance</a></h2> <ul> <li><input disabled="" type="checkbox"/> List skeleton ≤ 200 ms; infinite scroll appends without duplicate parallel page requests (<a href="parents/../00-shared/10_QA_Baseline.html">00-shared/10 §1</a>).</li> <li><input disabled="" type="checkbox"/> Detail children join: N+1 bounded; no repeated fetches on rebuild (cache per parent).</li> <li><input disabled="" type="checkbox"/> No duplicate parallel requests for same student while joining (dedupe map in <code>ParentDetailCubit</code>).</li> </ul> <h2 id="5-offline--network"><a class="header" href="#5-offline--network">5. Offline & network</a></h2> <ul> <li><input disabled="" type="checkbox"/> Offline list → last-good cache + banner; writes blocked.</li> <li><input disabled="" type="checkbox"/> Link submit offline → blocked with guidance (no silent failure).</li> <li><input disabled="" type="checkbox"/> Network loss mid-submit → no duplicate rows on retry <strong>known risk</strong> (no idempotency, OQ-2) — QA: verify one retry creates one row only if first failed.</li> </ul> <h2 id="6-tablet--desktop-4"><a class="header" href="#6-tablet--desktop-4">6. Tablet / desktop</a></h2> <ul> <li><input disabled="" type="checkbox"/> Master-detail list+detail; hover states; right-click menus; <code>Esc</code>; breadcrumbs depth > 2 (<a href="parents/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §3</a>).</li> </ul> <h2 id="7-localization-4"><a class="header" href="#7-localization-4">7. Localization</a></h2> <ul> <li><input disabled="" type="checkbox"/> All strings i18n keys (<code>parents.*</code>); relationship labels localized; RTL-safe chips row.</li> <li><input disabled="" type="checkbox"/> Currency/income format via <code>Intl</code>; dates locale-aware.</li> </ul> <h2 id="8-permissions-4"><a class="header" href="#8-permissions-4">8. Permissions</a></h2> <ul> <li><input disabled="" type="checkbox"/> Today: JWT-only (documented risk, OQ-8). When RBAC lands: <code>parent.read/create/update/delete/link</code> gate each surface (<code>04-Modules/Parents.md:64-70</code>); self-service for parent role only (<code>role.schema.ts:50-56</code>).</li> <li><input disabled="" type="checkbox"/> Cross-tenant link creation impossible (tenantId injected, <code>base.repository.ts:33-35</code>).</li> </ul> <h2 id="9-dark-mode-4"><a class="header" href="#9-dark-mode-4">9. Dark mode</a></h2> <ul> <li><input disabled="" type="checkbox"/> Full visual pass; primary badge <code>primaryContainer</code> dark variant (TBD by designer, <a href="parents/../00-shared/02_Design_Tokens.html">00-shared/02 §1.4</a>); banners readable.</li> </ul> <h2 id="10-animations-4"><a class="header" href="#10-animations-4">10. Animations</a></h2> <ul> <li><input disabled="" type="checkbox"/> Tokens only; reduced-motion; no flashes on unlink row removal.</li> </ul> <h2 id="11-security-3"><a class="header" href="#11-security-3">11. Security</a></h2> <ul> <li><input disabled="" type="checkbox"/> No PII logged client-side; tokens secure storage (<a href="parents/../00-shared/10_QA_Baseline.html">00-shared/10 §3</a>).</li> <li><input disabled="" type="checkbox"/> 404 vs 403 semantics: unknown parent → 404 (no existence leak across tenants — repository scoping returns empty → 404, <code>parent.service.ts:49-53</code>).</li> <li><input disabled="" type="checkbox"/> 5xx internals never surfaced (filter, <code>http-exception.filter.ts:60-65</code>).</li> </ul> <h2 id="12-server-backed-edge-mirrors-4"><a class="header" href="#12-server-backed-edge-mirrors-4">12. Server-backed edge mirrors</a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>emergencyPriority</code>/<code>annualIncome</code> accept numbers only — client coerces; note DTO has no type decorators (<code>create-parent.dto.ts:18-34</code>).</li> <li><input disabled="" type="checkbox"/> <code>pickupAllowed</code> link default true vs <code>pickupAuthorization</code> profile default false — QA cross-checks display on both surfaces.</li> <li><input disabled="" type="checkbox"/> Event side effects: <code>ParentCreated</code> → in-app job; <code>ParentUpdated</code>/<code>ParentDeleted</code> → audit-write jobs (<code>event-queue-map.ts:37-39</code>); verify in-app notification appears <code>(planned)</code> UI.</li> </ul> <hr /> <h2 id="qa-scripts-7"><a class="header" href="#qa-scripts-7">QA scripts</a></h2> <ul> <li>Golden per module component (light/dark × phone/tablet/desktop × states) (<a href="parents/07_Component_Library.html">07 §10</a>).</li> <li>E2E (P0): create user → create parent → link to student → set primary → unlink → delete parent (device-cloud).</li> <li>Regression: duplicate-link, double-primary, orphan-link join cases recorded as known gaps with product decisions (OQ-2..OQ-6).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-parents-module"><a class="header" href="#15--flutter-implementation-guide-parents-module">15 — Flutter Implementation Guide (Parents Module)</a></h1> <ul> <li><a href="parents/15_Flutter_Implementation_Guide.html#1-folder-structure">1. Folder structure</a></li> <li><a href="parents/15_Flutter_Implementation_Guide.html#2-dependencies">2. Dependencies</a></li> <li><a href="parents/15_Flutter_Implementation_Guide.html#3-cubits">3. Cubits</a></li> <li><a href="parents/15_Flutter_Implementation_Guide.html#4-parentsrepository-single">4. ParentsRepository (single)</a></li> <li><a href="parents/15_Flutter_Implementation_Guide.html#5-navigation-go_router">5. Navigation (go_router)</a></li> <li><a href="parents/15_Flutter_Implementation_Guide.html#6-theme">6. Theme</a></li> <li><a href="parents/15_Flutter_Implementation_Guide.html#7-extensions">7. Extensions</a></li> <li><a href="parents/15_Flutter_Implementation_Guide.html#8-localization-keys">8. Localization keys</a></li> <li><a href="parents/15_Flutter_Implementation_Guide.html#9-storage--security">9. Storage & security</a></li> <li><a href="parents/15_Flutter_Implementation_Guide.html#10-testing">10. Testing</a></li> <li><a href="parents/15_Flutter_Implementation_Guide.html#11-performance">11. Performance</a></li> <li><a href="parents/15_Flutter_Implementation_Guide.html#12-proposals-flagged-to-the-team">12. Proposals flagged to the team</a></li> </ul> <blockquote> <p>How to build the Parents feature in the Flutter client on top of <a href="parents/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a>. Forward-looking spec; no client repo exists yet.</p> </blockquote> <hr /> <h2 id="1-folder-structure-8"><a class="header" href="#1-folder-structure-8">1. Folder structure</a></h2> <pre><code>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 </code></pre> <h2 id="2-dependencies-7"><a class="header" href="#2-dependencies-7">2. Dependencies</a></h2> <p>Base set from <code>00-shared/11 §1</code> (<code>flutter_bloc</code>, <code>dio</code>, <code>go_router</code>, <code>get_it</code>, <code>intl</code>, <code>cached_network_image</code>, <code>connectivity_plus</code>, <code>secure_storage</code>). No module-specific package needed (dropdowns/switches are material).</p> <h2 id="3-cubits-6"><a class="header" href="#3-cubits-6">3. Cubits</a></h2> <ul> <li><code>ParentsCubit</code> uses <code>PaginatedListMixin<ParentRef></code> (<code>00-shared/06 §3.2</code>); <code>loadMore()</code> guarded by <code>hasNext</code>; <code>q</code> never sent (server ignores — OQ-7).</li> <li><code>ParentDetailCubit</code> orchestrates the join: <code>parent(id)</code> + <code>parentLinks(id)</code> + deduped <code>student(id)</code> per link (<code>00-shared/11 §4</code> DTO→model mapping).</li> <li><code>LinkCubit</code> owns the two pre-check warnings (duplicate pair, second primary) over the loaded links set; submit disabled on <code>warnDuplicate</code>.</li> <li><code>ChildSwitcherCubit</code>: <code>selectedChildId</code> only; read by other modules via injected instance (in-memory, session-scoped).</li> </ul> <h2 id="4-parentsrepository-single"><a class="header" href="#4-parentsrepository-single">4. ParentsRepository (single)</a></h2> <pre><code class="language-dart">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 } </code></pre> <ul> <li><strong>Identity join:</strong> parent display name/avatar come from <code>users</code> — <code>ParentsRepository</code> needs a <code>user(id)</code>/users-search helper (<code>users.service.ts:90-115</code>), or <code>ParentRef</code> carries <code>userId</code> and the UI joins lazily.</li> </ul> <h2 id="5-navigation-go_router-1"><a class="header" href="#5-navigation-go_router-1">5. Navigation (go_router)</a></h2> <pre><code>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) </code></pre> <ul> <li>Route guards mirror <code>00-shared/05 §9</code>; until RBAC lands, guard on <strong>role</strong> client-side (<code>parent.read</code> absent today — OQ-8) and hide admin workspace for <code>parent</code> role.</li> <li>Deep links <code>studylyon://parents/:id</code>, <code>studylyon://students/:id</code> (linked-only check).</li> </ul> <h2 id="6-theme-3"><a class="header" href="#6-theme-3">6. Theme</a></h2> <p><code>AppTheme.light()/dark()</code> unchanged (<code>00-shared/04 §1</code>); module adds no tokens; primary badge uses <code>colorScheme.primaryContainer</code>.</p> <h2 id="7-extensions-3"><a class="header" href="#7-extensions-3">7. Extensions</a></h2> <ul> <li><code>RelationshipType.label(context)</code> → i18n + icon map.</li> <li><code>Link.isPrimary</code> shorthand; <code>StudentRef.gradeLabel(context)</code>.</li> <li><code>int.tabular()</code> for priority/income numerals (<code>FontFeature.tabularFigures</code>, <code>02 §2</code>).</li> </ul> <h2 id="8-localization-keys-4"><a class="header" href="#8-localization-keys-4">8. Localization keys</a></h2> <p><code>parents.list.*</code>, <code>parents.detail.*</code>, <code>parents.form.*</code>, <code>parents.link.*</code>, <code>parents.unlink.*</code>, <code>parents.relationship.*</code>, <code>parents.my.*</code> (en <code>.arb</code> first; relationship labels for mother/father/guardian/grandparent/relative/foster_parent localized per org demand, <code>00-shared/11 §9</code>).</p> <h2 id="9-storage--security"><a class="header" href="#9-storage--security">9. Storage & security</a></h2> <ul> <li>No sensitive data in this module; list last-good cache in <code>shared_preferences</code>/Hive key <code>{tenant}:parents:{page}</code> (<code>00-shared/11 §11</code>); never cache link data for the <code>parent</code> role (privacy boundary).</li> </ul> <h2 id="10-testing-6"><a class="header" href="#10-testing-6">10. Testing</a></h2> <ul> <li><strong>Unit:</strong> cubits with mocked repo — pagination append/refresh; duplicate-link pre-check; primary-switch two-step (unlink→recreate) failure rollback; 404 mapping.</li> <li><strong>Widget:</strong> list 3 states; detail children skeleton/empty/error; form 409 banner; link sheet warnings; switcher selection.</li> <li><strong>Golden:</strong> components + pages light/dark × 3 sizes (<code>00-shared/10 §9</code>).</li> <li><strong>Integration:</strong> create user → create parent → link → unlink → delete (mock server); offline list banner.</li> <li><strong>E2E (P0):</strong> admin links guardian to student; verify row + primary badge; unlink confirm; delete parent; parent-login my-children (once endpoint lands).</li> </ul> <h2 id="11-performance-5"><a class="header" href="#11-performance-5">11. Performance</a></h2> <ul> <li><code>ListView.builder</code> for parents list + children section; const constructors; avatar images <code>cached_network_image</code> with resize; dedupe map for student joins; no rebuild of full detail on child-card state change (<code>RepaintBoundary</code>).</li> </ul> <h2 id="12-proposals-flagged-to-the-team-5"><a class="header" href="#12-proposals-flagged-to-the-team-5">12. Proposals flagged to the team</a></h2> <ol> <li><strong>Backend:</strong> add <code>GET /parents/me</code> (or <code>userId</code> filter) + link-populated children endpoint — unblocks the entire parent self-service surface (OQ-1).</li> <li><strong>Backend:</strong> unique index on <code>(tenantId, studentId, parentId)</code> + 409 on duplicate link; enforce <code>findById</code> result in <code>linkStudentParent</code> (OQ-2/OQ-3).</li> <li><strong>Backend:</strong> link PATCH endpoint (or switch-primary endpoint) to replace the delete+recreate workaround (OQ-4); consider single-primary rule (OQ-5).</li> <li><strong>Backend:</strong> <code>@IsEnum(RelationshipType)</code> in <code>LinkParentDto</code> (OQ-6).</li> <li><strong>Backend/RBAC:</strong> seed <code>parent.*</code> permissions (<code>04-Modules/Parents.md:64-70</code>) and decorate endpoints (OQ-8).</li> <li>Analytics events (<code>parents.*</code>) wait <code>AnalyticsService</code> (<code>00-shared/10 §8</code>).</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-payments"><a class="header" href="#01--product-overview-payments">01 — Product Overview (Payments)</a></h1> <ul> <li><a href="payments/01_Product_Overview.html#1-purpose">1. Purpose</a></li> <li><a href="payments/01_Product_Overview.html#2-business-goals">2. Business goals</a></li> <li><a href="payments/01_Product_Overview.html#3-user-goals">3. User goals</a></li> <li><a href="payments/01_Product_Overview.html#4-stakeholders">4. Stakeholders</a></li> <li><a href="payments/01_Product_Overview.html#5-why-this-exists">5. Why this exists</a></li> <li><a href="payments/01_Product_Overview.html#6-dependencies">6. Dependencies</a></li> <li><a href="payments/01_Product_Overview.html#7-success-metrics">7. Success metrics</a></li> <li><a href="payments/01_Product_Overview.html#8-edge-cases">8. Edge cases</a></li> <li><a href="payments/01_Product_Overview.html#9-assumptions">9. Assumptions</a></li> <li><a href="payments/01_Product_Overview.html#10-glossary">10. Glossary</a></li> </ul> <blockquote> <p>StudyLyon payments: processing, refunds, reconciliation, receipts, invoice-credit sync.</p> </blockquote> <hr /> <h2 id="1-purpose-17"><a class="header" href="#1-purpose-17">1. Purpose</a></h2> <p>Capture every payment received from parents/guardians (and any payer) against fee invoices, issue receipts, track refunds, and keep invoice dues (<code>paidAmount</code>, status) in sync. Per <strong>PRD §8</strong>, <em>payment-gateway settlement is out of scope (Phase 1)</em> → payments are recorded either from a gateway response or manually, and reconciled by the accountant.</p> <h2 id="2-business-goals-16"><a class="header" href="#2-business-goals-16">2. Business goals</a></h2> <ul> <li>Record a payment with full audit context (payer, gateway, amount, invoice).</li> <li>Generate a human-readable receipt for every completed payment.</li> <li>Track full/partial refunds against a payment.</li> <li>Keep invoice <code>paidAmount</code> / <code>status</code> (PAID / PARTIAL) accurate.</li> <li>Support reconciliation of asynchronous gateway outcomes.</li> </ul> <h2 id="3-user-goals-14"><a class="header" href="#3-user-goals-14">3. User goals</a></h2> <ul> <li><strong>Accountant:</strong> record a cash/cheque/transfer payment fast; see all payments and receipts; refund when required.</li> <li><strong>Receptionist:</strong> take a counter payment against an invoice and hand over a receipt.</li> <li><strong>Org Admin:</strong> monitor collection and reconcile gateway responses.</li> <li><strong>Parent (forward-looking):</strong> see payment status and download receipts (self-service endpoint does not exist yet).</li> </ul> <h2 id="4-stakeholders-14"><a class="header" href="#4-stakeholders-14">4. Stakeholders</a></h2> <p>Accountants, receptionists, org admins, platform billing (via <code>payments.reconcile</code>), parents (read), external gateways (stripe, paypal, flutterwave, paystack, razorpay, cash, bank_transfer, cheque per <code>PaymentGateway</code>).</p> <h2 id="5-why-this-exists-15"><a class="header" href="#5-why-this-exists-15">5. Why this exists</a></h2> <p>Manual ledgers and split tools make fee collection untraceable. This module centralizes payments, receipts, refunds and reconciliation, emitting <code>PaymentProcessed</code> / <code>PaymentRefunded</code> domain events for realtime + audit.</p> <h2 id="6-dependencies-9"><a class="header" href="#6-dependencies-9">6. Dependencies</a></h2> <ul> <li><code>Invoices</code> (fees module) — link and partial/paid status</li> <li><code>EventBus</code> (events module) → <code>PaymentProcessed</code> / <code>PaymentRefunded</code> events to WS tenant room; <strong>note:</strong> these events are NOT routed to any BullMQ queue in <code>event-queue-map.ts</code></li> <li><code>Storage</code>/<code>files</code> (not used today; receipts are data rows, not PDFs)</li> <li>RBAC perms exist (<code>payments.read/process/refund/reconcile</code>, <code>receipts.read</code>) but are <strong>not yet enforced</strong> server-side (controller is <code>JwtAuthGuard</code> only)</li> </ul> <h2 id="7-success-metrics-11"><a class="header" href="#7-success-metrics-11">7. Success metrics</a></h2> <ul> <li>Payment → receipt latency < 1 s (synchronous, same request)</li> <li>Invoice status converges to PAID/PARTIAL on every payment/refund</li> <li>Zero orphan receipts (receipt always linked to a payment)</li> <li>Refund over-payment impossible (blocked server-side)</li> </ul> <h2 id="8-edge-cases-7"><a class="header" href="#8-edge-cases-7">8. Edge cases</a></h2> <ul> <li>Refund attempt on non-<code>COMPLETED</code> payment → 409</li> <li>Refund amount exceeding payment → 409</li> <li>Partial refund then further refund → <code>PARTIALLY_REFUNDED</code> then <code>REFUNDED</code></li> <li>Revenue link: payment with no <code>invoiceId</code> (off-invoice) → no invoice link</li> <li>Gateway async failure → <code>PATCH reconcile</code> sets FAILED/PENDING (client must re-fetch)</li> <li>Duplicate <code>transactionReference</code> → unique index violation (500 today; retry-safety gap)</li> <li>Receipt number generated from an <strong>in-process counter</strong> — not concurrency-safe across multiple API replicas</li> <li>Concurrent payments for the same invoice: no lock; <code>sumByInvoice</code> is aggregation at link time (order-dependent final status)</li> <li>Dual payment models (see <a href="payments/12_API_Mapping.html">12_Assumptions</a>)</li> </ul> <h2 id="9-assumptions-1"><a class="header" href="#9-assumptions-1">9. Assumptions</a></h2> <ul> <li>Money stored as <strong>floating-point <code>Number</code></strong> (<code>amount</code>, <code>fee</code>, <code>refundedAmount</code>) — financial apps should prefer minor units; flagged as an improvement, not corrected here.</li> <li>Mobile client is a <strong>forward-looking spec</strong> (PRD excludes native apps from Phase 1).</li> <li>Receipts are database rows; PDF/printable receipt via reports module <code>(planned)</code>.</li> <li>Parent self-service payment flows are <code>(forward-looking)</code> — no <code>/payments/me</code> endpoint.</li> <li>Reconciliation is manual (<code>PATCH :transactionRef/reconcile</code> with <code>{status}</code> body).</li> </ul> <h2 id="10-glossary-1"><a class="header" href="#10-glossary-1">10. Glossary</a></h2> <div class="table-wrapper"><table><thead><tr><th>Term</th><th>Meaning</th></tr></thead><tbody> <tr><td>transactionReference</td><td>Unique <code>TXN-{timestamp}-{uuid8}</code></td></tr> <tr><td>gateway</td><td><code>PaymentGateway</code> enum incl. offline methods (cash/cheque/bank_transfer)</td></tr> <tr><td>receiptNumber</td><td><code>RCP-{timestamp}-{counter}</code></td></tr> <tr><td>reconcile</td><td>Set payment status from gateway outcome string ("success"→COMPLETED, "failed"→FAILED, else PENDING)</td></tr> <tr><td>InvoiceStatus</td><td>PAID / PARTIAL / (per fees, incl. PENDING)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-payments"><a class="header" href="#02--user-personas-payments">02 — User Personas (Payments)</a></h1> <ul> <li><a href="payments/02_User_Personas.html#accountant-primary">Accountant (primary)</a></li> <li><a href="payments/02_User_Personas.html#receptionist--bursar">Receptionist / Bursar</a></li> <li><a href="payments/02_User_Personas.html#org-admin">Org Admin</a></li> <li><a href="payments/02_User_Personas.html#parent-forward-looking">Parent (forward-looking)</a></li> <li><a href="payments/02_User_Personas.html#system-actors">System actors</a></li> </ul> <blockquote> <p>Roles that touch payments, with exact permissions from <code>permissions.constants.ts</code>.</p> </blockquote> <hr /> <h2 id="accountant-primary"><a class="header" href="#accountant-primary">Accountant (primary)</a></h2> <ul> <li><strong>Goals:</strong> record payments quickly, issue receipts, refund accurately, reconcile gateway outcomes, keep invoices PAID/PARTIAL correct.</li> <li><strong>Pain points:</strong> manual ledger duplication; verifying gateway outcomes; refund mistakes; money displayed as floats.</li> <li><strong>Frequency:</strong> daily (collection peaks on due dates).</li> <li><strong>Permissions (defined, not yet enforced):</strong> <code>payments.read</code>, <code>payments.process</code>, <code>payments.refund</code>, <code>payments.reconcile</code>, <code>receipts.read</code>, <code>fees.collect</code>.</li> <li><strong>Needs:</strong> quick payment form (amount prefilled from invoice), receipt preview, refund guard rails (reason required by process), status history.</li> <li><strong>Restrictions:</strong> no academic write access.</li> <li><strong>Mental model:</strong> "payment completes an invoice; refund reverses it; reconcile fixes a pending state."</li> <li><strong>Expected behaviour:</strong> every completed payment instantly produces a receipt; invoice due amount updates immediately.</li> </ul> <h2 id="receptionist--bursar"><a class="header" href="#receptionist--bursar">Receptionist / Bursar</a></h2> <ul> <li><strong>Goals:</strong> take counter payments (cash/cheque/bank transfer) and hand the payer a receipt.</li> <li><strong>Pain points:</strong> slow forms when parents queue; wrong-payer mistakes.</li> <li><strong>Frequency:</strong> several per day.</li> <li><strong>Permissions:</strong> as delegated by org (client-gated; server enforcement <code>(planned)</code>).</li> <li><strong>Needs:</strong> payer search/autofill, prefilled amount from invoice, print/share receipt.</li> <li><strong>Restrictions:</strong> should not refund without approval (client-gated).</li> <li><strong>Mental model:</strong> "cash register."</li> <li><strong>Expected behaviour:</strong> under 60 s per payment.</li> </ul> <h2 id="org-admin"><a class="header" href="#org-admin">Org Admin</a></h2> <ul> <li><strong>Goals:</strong> monitor collections, drill into a payment, resolve disputes, reconcile.</li> <li><strong>Pain points:</strong> duplicated payment records across modules (see dual-schema issue).</li> <li><strong>Frequency:</strong> weekly review.</li> <li><strong>Permissions:</strong> same payments.* set.</li> <li><strong>Expected behaviour:</strong> payment list filterable; every payment traceable to invoice + receipt.</li> </ul> <h2 id="parent-forward-looking"><a class="header" href="#parent-forward-looking">Parent (forward-looking)</a></h2> <ul> <li><strong>Goals:</strong> see payment status, get receipts, pay online.</li> <li><strong>Pain points:</strong> no self-service today.</li> <li><strong>Restrictions:</strong> only own linked children's invoices (privacy boundary <code>(planned)</code>).</li> <li><strong>Expected behaviour:</strong> push notification on <code>PaymentProcessed</code> for their invoice.</li> </ul> <h2 id="system-actors"><a class="header" href="#system-actors">System actors</a></h2> <ul> <li><strong>Gateway (async):</strong> calls back / accountant reconciles via PATCH.</li> <li><strong>Event bus:</strong> <code>PaymentProcessed</code> / <code>PaymentRefunded</code> → WS broadcast to tenant room; audit logging via global pipeline.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journey-payments"><a class="header" href="#03--user-journey-payments">03 — User Journey (Payments)</a></h1> <ul> <li><a href="payments/03_User_Journey.html#1-record-a-counter-payment-accountant--receptionist">1. Record a counter payment (Accountant / Receptionist)</a></li> <li><a href="payments/03_User_Journey.html#2-refund-a-payment-accountant">2. Refund a payment (Accountant)</a></li> <li><a href="payments/03_User_Journey.html#3-reconcile-a-gateway-outcome">3. Reconcile a gateway outcome</a></li> <li><a href="payments/03_User_Journey.html#4-search-an-audit-trail-org-admin">4. Search an audit trail (Org Admin)</a></li> <li><a href="payments/03_User_Journey.html#common-journey-attributes">Common journey attributes</a></li> </ul> <blockquote> <p>End-to-end payment journeys. All flows assume Bearer JWT; every step uses the shared envelope contract (<a href="payments/../00-shared/07_API_Conventions.html">00-shared/07_API_Conventions.md</a>).</p> </blockquote> <hr /> <h2 id="1-record-a-counter-payment-accountant--receptionist"><a class="header" href="#1-record-a-counter-payment-accountant--receptionist">1. Record a counter payment (Accountant / Receptionist)</a></h2> <pre class="mermaid">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) </pre> <ul> <li><strong>Entry:</strong> Fees → invoice detail → "Record payment"; or Payments FAB → New payment.</li> <li><strong>Intent:</strong> credit an invoice instantly.</li> <li><strong>Decision points:</strong> gateway (cash/cheque/bank_transfer), prefill amount from invoice, attach payer identity.</li> <li><strong>System response:</strong> synchronous; skeleton → success view. Receipt number shown.</li> <li><strong>Failures:</strong> 400 validation (amount Min 0), 409 duplicate reference, network → retry prompt. <strong>Never optimistic</strong> — payment must be confirmed by the server.</li> <li><strong>Exit:</strong> "Done" → back to invoice (refreshed) or payments list.</li> <li><strong>Abandonment:</strong> form keeps entered values on back; not submitted until confirm.</li> <li><strong>Offline:</strong> blocked — must be online; guidance toast.</li> <li><strong>Multi-device:</strong> invoice status diverges if another clerk is mid-payment; pull-to-refresh.</li> </ul> <h2 id="2-refund-a-payment-accountant"><a class="header" href="#2-refund-a-payment-accountant">2. Refund a payment (Accountant)</a></h2> <pre class="mermaid">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 </pre> <ul> <li>**Decision:</strong> full vs partial (amount optional = full).</li> <li><strong>Failures:</strong> <ul> <li>409 "Only completed payments can be refunded." (status banner)</li> <li>409 "Refund amount exceeds payment amount." (inline amount error)</li> <li>404 payment missing.</li> </ul> </li> <li><strong>Recovery:</strong> retry with corrected amount.</li> </ul> <h2 id="3-reconcile-a-gateway-outcome"><a class="header" href="#3-reconcile-a-gateway-outcome">3. Reconcile a gateway outcome</a></h2> <pre class="mermaid">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] </pre> <ul> <li><strong>Entry:</strong> payments list row with "pending/processing" badge → Reconcile; or gateway callback page.</li> <li><strong>Exit:</strong> back to list with updated status.</li> <li><strong>Permission denial:</strong> 403 → reconcile action hidden (client-gated per <code>payments.reconcile</code>).</li> </ul> <h2 id="4-search-an-audit-trail-org-admin"><a class="header" href="#4-search-an-audit-trail-org-admin">4. Search an audit trail (Org Admin)</a></h2> <ul> <li>Payments list (paged <code>GET /payments?page&limit</code>, sorted <code>createdAt desc</code>) → filter by invoice / status (client-side; no server filters) → detail → view receipt (<code>GET /payments/receipts/:id</code>).</li> <li><strong>Deep link (forward-looking):</strong> <code>studylyon://payments/:id</code>.</li> </ul> <h2 id="common-journey-attributes"><a class="header" href="#common-journey-attributes">Common journey attributes</a></h2> <div class="table-wrapper"><table><thead><tr><th>Concern</th><th>Behaviour</th></tr></thead><tbody> <tr><td>Loading</td><td><code>AppSkeleton</code> lists; button spinner on submit</td></tr> <tr><td>Session expiry</td><td>silent refresh; fail → re-login, payment preserved as draft</td></tr> <tr><td>Timeout</td><td>15 s; 429 → countdown + disable retry</td></tr> <tr><td>Conflict resolution</td><td>refresh list; server state is source of truth</td></tr> <tr><td>Network loss</td><td>offline banner; no optimistic writes</td></tr> <tr><td>Push entry</td><td><code>(forward-looking)</code> <code>PaymentProcessed</code> notification → deep link</td></tr> <tr><td>Email entry</td><td><code>(planned)</code> receipt email via <code>PaymentCompleted</code>→emails routing (dead in map today)</td></tr> <tr><td>Abandonment</td><td>forms discarded with confirm prompt</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-payments"><a class="header" href="#04--information-architecture-payments">04 — Information Architecture (Payments)</a></h1> <ul> <li><a href="payments/04_Information_Architecture.html#1-placement-in-app-shell-00-shared05">1. Placement in app shell (00-shared/05)</a></li> <li><a href="payments/04_Information_Architecture.html#2-screen-map">2. Screen map</a></li> <li><a href="payments/04_Information_Architecture.html#3-navigation-rules">3. Navigation rules</a></li> <li><a href="payments/04_Information_Architecture.html#4-quick-actions--context-menus">4. Quick actions & context menus</a></li> <li><a href="payments/04_Information_Architecture.html#5-deep-links-forward-looking">5. Deep links (forward-looking)</a></li> <li><a href="payments/04_Information_Architecture.html#6-empty--edge-surfaces">6. Empty / edge surfaces</a></li> </ul> <blockquote> <p>Screen hierarchy and navigation for the payments module.</p> </blockquote> <hr /> <h2 id="1-placement-in-app-shell-00-shared05"><a class="header" href="#1-placement-in-app-shell-00-shared05">1. Placement in app shell (<a href="payments/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a>)</a></h2> <ul> <li><strong>Phone:</strong> Fees tab contains "Payments" sub-destination; also reachable from Invoice detail action menu.</li> <li><strong>Tablet/desktop:</strong> Fees → Payments as master-detail branch; receipt preview in right pane.</li> </ul> <h2 id="2-screen-map"><a class="header" href="#2-screen-map">2. Screen map</a></h2> <pre><code>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 </code></pre> <h2 id="3-navigation-rules"><a class="header" href="#3-navigation-rules">3. Navigation rules</a></h2> <ul> <li>Lists push detail (phone) / right pane (tablet+).</li> <li>"Record payment" FAB on Payments list; contextual action on invoice detail.</li> <li>Modal hierarchy: snackbar → bottom sheet (refund/reconcile) → dialog (confirm) → full-screen (new payment form).</li> <li>Destructive/irreversible (refund) uses <code>AppDialog</code> confirm with reason field first.</li> <li>Back: list → fees; sheet dismiss = cancel.</li> <li>Global search: payments searchable via global search <code>(planned)</code>; local filter client-side.</li> </ul> <h2 id="4-quick-actions--context-menus"><a class="header" href="#4-quick-actions--context-menus">4. Quick actions & context menus</a></h2> <ul> <li>Row overflow menu: View, Receipt, Refund (if COMPLETED), Reconcile (if PENDING/PROCESSING).</li> <li>FAB: New payment (hidden while scrolling down).</li> </ul> <h2 id="5-deep-links-forward-looking"><a class="header" href="#5-deep-links-forward-looking">5. Deep links (forward-looking)</a></h2> <ul> <li><code>studylyon://payments/:id</code> → payment detail</li> <li><code>studylyon://payments/receipts/:id</code> → receipt</li> <li><code>studylyon://invoice/:invoiceId</code> → invoice detail (payments tab)</li> </ul> <h2 id="6-empty--edge-surfaces"><a class="header" href="#6-empty--edge-surfaces">6. Empty / edge surfaces</a></h2> <ul> <li>Payments list empty: icon + "No payments yet — record the first payment".</li> <li>Receipts empty similarly.</li> <li>List end-of-page indicator (paged, no infinite scroll).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-payments"><a class="header" href="#05--screen-inventory-payments">05 — Screen Inventory (Payments)</a></h1> <ul> <li><a href="payments/05_Screen_Inventory.html#s1--payments-list">S1 — Payments list</a></li> <li><a href="payments/05_Screen_Inventory.html#s2--payment-detail">S2 — Payment detail</a></li> <li><a href="payments/05_Screen_Inventory.html#s3--new-payment-form-screen">S3 — New payment (form screen)</a></li> <li><a href="payments/05_Screen_Inventory.html#s4--refund-sheet-bottom-sheet">S4 — Refund sheet (bottom sheet)</a></li> <li><a href="payments/05_Screen_Inventory.html#s5--reconcile-sheet">S5 — Reconcile sheet</a></li> <li><a href="payments/05_Screen_Inventory.html#s6--receipt-view">S6 — Receipt view</a></li> <li><a href="payments/05_Screen_Inventory.html#s7--receipts-list">S7 — Receipts list</a></li> <li><a href="payments/05_Screen_Inventory.html#s8--payment-context-on-invoice-detail-fees-module">S8 — Payment context on invoice detail (fees module)</a></li> </ul> <blockquote> <p>Every screen in the payments module. States, permissions, a11y, analytics per screen.</p> </blockquote> <p><strong>Screen codes:</strong> 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).</p> <hr /> <h2 id="s1--payments-list"><a class="header" href="#s1--payments-list">S1 — Payments list</a></h2> <ul> <li><strong>Purpose:</strong> browse all tenant payments, newest first; drill into details; record new.</li> <li><strong>Route:</strong> <code>/payments</code></li> <li><strong>Entry:</strong> Fees tab → Payments; deep link; after successful payment.</li> <li><strong>Exit:</strong> payment detail, new payment, receipts list.</li> <li><strong>Widgets:</strong> <code>AppSearchBar</code> (client-side filter), status <code>FilterChip</code>s (client-side), <code>AppCard</code>/<code>AppListTile</code> rows (transactionReference, amount, payerName, gateway icon, status <code>AppBadge</code>, date), FAB "Record payment", pagination footer.</li> <li><strong>Buttons:</strong> FAB (primary CTA), row menu (overflow).</li> <li><strong>States:</strong> loading skeleton (8 rows) → empty → error (retry) → data; offline banner.</li> <li><strong>Pagination:</strong> <code>page</code>/<code>limit</code> (default 20) via server <code>{data,total}</code>; "Load more" button (no auto-scroll).</li> <li><strong>Permissions:</strong> visible with <code>payments.read</code> (client-gated; server <code>(planned)</code>).</li> <li><strong>Keyboard:</strong> Enter submits filter; <code>/</code> focuses search (desktop).</li> <li><strong>Responsive:</strong> single column phone; two-column grid tablet; table + filters desktop.</li> <li><strong>A11y:</strong> rows announce "Payment TXN-… amount … status"; status never color-only (icon + label).</li> <li><strong>Micro-interactions:</strong> row tap ripple; status chip pop on update.</li> <li><strong>Analytics:</strong> <code>payments.list.view</code>, <code>payments.list.filter</code>, <code>payments.list.open_detail</code>.</li> </ul> <h2 id="s2--payment-detail"><a class="header" href="#s2--payment-detail">S2 — Payment detail</a></h2> <ul> <li><strong>Purpose:</strong> full payment record + linked invoice + receipt access + actions.</li> <li><strong>Route:</strong> <code>/payments/:id</code></li> <li><strong>Entry:</strong> list tap, deep link, notification.</li> <li><strong>Exit:</strong> receipt, refund/reconcile sheets, invoice detail.</li> <li><strong>Sections:</strong> 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.</li> <li><strong>Actions:</strong> Receipt (primary tonal), Refund (destructive, only COMPLETED), Reconcile (only PENDING/PROCESSING), Copy transaction reference.</li> <li><strong>States:</strong> skeleton → data / 404 error (<code>RESOURCE_NOT_FOUND</code> empty state with copy "Payment not found").</li> <li><strong>Permissions:</strong> view <code>payments.read</code>; actions gated <code>payments.refund</code> / <code>payments.reconcile</code> (hide when absent).</li> <li><strong>A11y:</strong> money amounts <code>tabularFigures</code>; actions ≥ 48 dp.</li> <li><strong>Analytics:</strong> <code>payments.detail.view</code>, <code>payments.detail.refund_start</code>, <code>payments.detail.reconcile</code>.</li> </ul> <h2 id="s3--new-payment-form-screen"><a class="header" href="#s3--new-payment-form-screen">S3 — New payment (form screen)</a></h2> <ul> <li><strong>Purpose:</strong> record a payment; always server-confirmed (never optimistic).</li> <li><strong>Route:</strong> <code>/payments/new</code> (or prefilled via invoice context)</li> <li><strong>Widgets:</strong> 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.</li> <li><strong>States:</strong> pristine → validation errors → submitting (button spinner, fields locked) → success (payment + receipt card) → failure (error banner + retry).</li> <li><strong>Keyboard:</strong> amount numeric-decimal; payer email email type.</li> <li><strong>Permission:</strong> <code>payments.process</code>.</li> <li><strong>Analytics:</strong> <code>payments.new.submit</code>, <code>payments.new.success</code>, <code>payments.new.failed</code>.</li> </ul> <h2 id="s4--refund-sheet-bottom-sheet"><a class="header" href="#s4--refund-sheet-bottom-sheet">S4 — Refund sheet (bottom sheet)</a></h2> <ul> <li><strong>Purpose:</strong> full/partial refund with reason.</li> <li><strong>Route:</strong> modal from S2.</li> <li><strong>Widgets:</strong> refundable amount display, amount field (default = full), reason (required), submit, destructive styling.</li> <li><strong>Errors:</strong> 409 messages inline/banner; refund amount > payable blocked client-side (min 0, max = amount − refundedAmount).</li> <li><strong>Analytics:</strong> <code>payments.refund.submit</code>, <code>payments.refund.success</code>, <code>payments.refund.failed</code>.</li> </ul> <h2 id="s5--reconcile-sheet"><a class="header" href="#s5--reconcile-sheet">S5 — Reconcile sheet</a></h2> <ul> <li><strong>Purpose:</strong> set payment status from gateway outcome.</li> <li><strong>Widgets:</strong> status selector (success → completed / failed → failed / other → pending), optional gateway response text, submit.</li> <li><strong>Permission:</strong> <code>payments.reconcile</code>.</li> <li><strong>Analytics:</strong> <code>payments.reconcile.submit</code>.</li> </ul> <h2 id="s6--receipt-view"><a class="header" href="#s6--receipt-view">S6 — Receipt view</a></h2> <ul> <li><strong>Purpose:</strong> display receipt (data row) for printing/sharing.</li> <li><strong>Route:</strong> <code>/payments/receipts/:receiptId</code></li> <li><strong>Sections:</strong> header (receipt number, issuedAt), payment reference, amount/fee/currency, payer, description, invoice ref.</li> <li><strong>Actions:</strong> Share/Print <code>(planned)</code> via reports; Copy receipt number.</li> <li><strong>State:</strong> 404 empty state if missing.</li> <li><strong>Analytics:</strong> <code>payments.receipt.view</code>, <code>payments.receipt.share</code>.</li> </ul> <h2 id="s7--receipts-list"><a class="header" href="#s7--receipts-list">S7 — Receipts list</a></h2> <ul> <li><strong>Purpose:</strong> browse all receipts (paged, newest first).</li> <li><strong>Route:</strong> <code>/payments/receipts</code></li> <li><strong>Mirrors S1</strong> with receipt rows; tap → S6.</li> <li><strong>Analytics:</strong> <code>payments.receipts.view</code>.</li> </ul> <h2 id="s8--payment-context-on-invoice-detail-fees-module"><a class="header" href="#s8--payment-context-on-invoice-detail-fees-module">S8 — Payment context on invoice detail (fees module)</a></h2> <ul> <li>Invoice detail shows payments tab/row list (<code>GET /payments/invoice/:invoiceId</code>), paidAmount progress, "Record payment" entry point. Details owned by fees docs.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-payments"><a class="header" href="#06--screen-specifications-payments">06 — Screen Specifications (Payments)</a></h1> <ul> <li><a href="payments/06_Screen_Specifications.html#s1--payments-list">S1 — Payments list</a> <ul> <li><a href="payments/06_Screen_Specifications.html#layout">Layout</a></li> <li><a href="payments/06_Screen_Specifications.html#row-composition-applisttile-72-dp">Row composition (<code>AppListTile</code> 72 dp)</a></li> <li><a href="payments/06_Screen_Specifications.html#status-badge-mapping">Status badge mapping</a></li> <li><a href="payments/06_Screen_Specifications.html#states">States</a></li> <li><a href="payments/06_Screen_Specifications.html#a11y">A11y</a></li> <li><a href="payments/06_Screen_Specifications.html#animations">Animations</a></li> </ul> </li> <li><a href="payments/06_Screen_Specifications.html#s2--payment-detail">S2 — Payment detail</a> <ul> <li><a href="payments/06_Screen_Specifications.html#layout-1">Layout</a></li> <li><a href="payments/06_Screen_Specifications.html#states-1">States</a></li> <li><a href="payments/06_Screen_Specifications.html#a11y-1">A11y</a></li> </ul> </li> <li><a href="payments/06_Screen_Specifications.html#s3--new-payment-form-full-screen">S3 — New payment form (full-screen)</a> <ul> <li><a href="payments/06_Screen_Specifications.html#layout-2">Layout</a></li> <li><a href="payments/06_Screen_Specifications.html#components--tokens">Components & tokens</a></li> <li><a href="payments/06_Screen_Specifications.html#states-2">States</a></li> <li><a href="payments/06_Screen_Specifications.html#darklight-token-based-only">Dark/light: token-based only.</a></li> <li><a href="payments/06_Screen_Specifications.html#a11y-2">A11y</a></li> </ul> </li> <li><a href="payments/06_Screen_Specifications.html#s4--refund-sheet">S4 — Refund sheet</a></li> <li><a href="payments/06_Screen_Specifications.html#s5--reconcile-sheet">S5 — Reconcile sheet</a></li> <li><a href="payments/06_Screen_Specifications.html#s6--receipt-view">S6 — Receipt view</a></li> <li><a href="payments/06_Screen_Specifications.html#s7--receipts-list">S7 — Receipts list</a></li> <li><a href="payments/06_Screen_Specifications.html#global-interaction-details">Global interaction details</a></li> </ul> <blockquote> <p>The largest document: precise specs for each payments screen. All tokens referenced from <a href="payments/../00-shared/02_Design_Tokens.html">00-shared/02_Design_Tokens.md</a>; components from <a href="payments/../00-shared/03_Component_Library.html">00-shared/03</a>.</p> </blockquote> <hr /> <h2 id="s1--payments-list-1"><a class="header" href="#s1--payments-list-1">S1 — Payments list</a></h2> <h3 id="layout-12"><a class="header" href="#layout-12">Layout</a></h3> <ul> <li><code>AppBar</code>: title "Payments", actions: receipts icon, search icon.</li> <li>Body: search field (sticky), filter chips row (All / Completed / Pending / Processing / Failed / Refunded / Partially refunded — client-side), list, load-more footer.</li> <li>FAB: <code>AppFAB.extended</code> "Record payment" — hides on scroll down.</li> <li>Padding: 16 phone / 24 tablet+. Section gap 24. Card gap 12.</li> </ul> <h3 id="row-composition-applisttile-72-dp"><a class="header" href="#row-composition-applisttile-72-dp">Row composition (<code>AppListTile</code> 72 dp)</a></h3> <ul> <li>Leading: gateway icon (offline = cash icon) in <code>primaryContainer</code> circle.</li> <li>Title: <code>transactionReference</code> (<code>mono</code>, <code>titleSmall</code>).</li> <li>Subtitle: <code>payerName</code> + date (<code>bodySmall</code>, <code>onSurfaceVariant</code>).</li> <li>Trailing: amount (<code>titleMedium</code>, tabular figures) + status <code>AppBadge</code>.</li> <li>On tap: ripple + push S2.</li> </ul> <h3 id="status-badge-mapping"><a class="header" href="#status-badge-mapping">Status badge mapping</a></h3> <div class="table-wrapper"><table><thead><tr><th>Status</th><th>Badge color (role)</th><th>Icon</th></tr></thead><tbody> <tr><td>completed</td><td>success</td><td>check_circle</td></tr> <tr><td>pending / processing</td><td>info (<code>primaryContainer</code>)</td><td>schedule</td></tr> <tr><td>failed</td><td>error</td><td>cancel</td></tr> <tr><td>refunded / partially_refunded</td><td>tertiary</td><td>currency_rupee/replay</td></tr> </tbody></table> </div> <h3 id="states-7"><a class="header" href="#states-7">States</a></h3> <ul> <li><strong>Loading:</strong> 8 <code>AppSkeleton</code> rows; shimmer <code>m-slow</code>.</li> <li><strong>Empty:</strong> <code>AppEmptyState</code> (icon <code>payments</code>, "No payments yet", CTA "Record payment").</li> <li><strong>Error:</strong> <code>AppErrorState</code> code-aware (403 → permission empty-state; 5xx → generic + requestId + Retry).</li> <li><strong>Offline:</strong> <code>AppOfflineBanner</code>; cached last-good list (client cache 5 min).</li> <li><strong>Pagination:</strong> footer "Load more" → spinner → next page; "End of list" <code>bodySmall</code> when <code>total</code> reached. Server returns <code>{data,total}</code> — client computes hasNext locally.</li> </ul> <h3 id="a11y-7"><a class="header" href="#a11y-7">A11y</a></h3> <ul> <li>Row: single semantics node "Payment TXN-…, amount, status".</li> <li>Chips: <code>Semantics(selected:)</code>; results count live region.</li> <li>Contrast per <a href="payments/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>; touch targets ≥ 48.</li> </ul> <h3 id="animations"><a class="header" href="#animations">Animations</a></h3> <ul> <li>New row entrance: fade+rise stagger 40 ms.</li> <li>Status chip change: <code>AnimatedSwitcher</code> 150 ms.</li> </ul> <hr /> <h2 id="s2--payment-detail-1"><a class="header" href="#s2--payment-detail-1">S2 — Payment detail</a></h2> <h3 id="layout-13"><a class="header" href="#layout-13">Layout</a></h3> <ul> <li>Hero header card: amount <code>headlineMedium</code> tabular; status badge; transaction reference <code>mono</code> <code>bodySmall</code> with copy icon.</li> <li>Info cards (each <code>AppCard</code> e-1, padding 16, gap 12): <ul> <li>Payer: name, email, payerId.</li> <li>Invoice: link (route to invoice), status, paidAmount/totalAmount progress bar (linear, <code>m-base</code>).</li> <li>Gateway: gateway, gatewayTransactionId, gatewayResponse.</li> <li>Money: amount, fee, refundedAmount, currency.</li> <li>Timeline: createdAt, updatedAt (from BaseSchema).</li> </ul> </li> <li>Actions row (primary/secondary): <ul> <li>Receipt (<code>FilledButton.tonal</code>) → S6.</li> <li>Refund (<code>OutlinedButton</code> destructive) → S4 (visible iff <code>payments.refund</code> + status completed).</li> <li>Reconcile (<code>OutlinedButton</code>) → S5 (visible iff PENDING/PROCESSING + <code>payments.reconcile</code>).</li> </ul> </li> </ul> <h3 id="states-8"><a class="header" href="#states-8">States</a></h3> <ul> <li>Loading: skeleton cards.</li> <li>404: <code>AppErrorState</code> "Payment not found" (no retry; back offered).</li> <li>Conflict after action: banner with server message.</li> </ul> <h3 id="a11y-8"><a class="header" href="#a11y-8">A11y</a></h3> <ul> <li>Copy button: tooltip + semantics "Copy transaction reference"; announce "Copied".</li> <li>Amounts: <code>Semantics</code> numeric value.</li> </ul> <hr /> <h2 id="s3--new-payment-form-full-screen"><a class="header" href="#s3--new-payment-form-full-screen">S3 — New payment form (full-screen)</a></h2> <h3 id="layout-14"><a class="header" href="#layout-14">Layout</a></h3> <ul> <li><code>AppBar</code> title "Record payment", close/back.</li> <li><code>Form</code> column (gap 16): amount*, currency, gateway*, gatewayTransactionId, invoice (searchable <code>AppDropdown</code>), payerName, payerEmail, payerId, description, submit <code>FilledButton</code> fullWidth (48 dp).</li> <li>Keyboard: <code>resizeToAvoidBottomInset</code>; scroll into view on focus; amount → next, submit on last field (action "Done").</li> <li>Safe areas: bottom padding = nav/safe area + 16.</li> </ul> <h3 id="components--tokens"><a class="header" href="#components--tokens">Components & tokens</a></h3> <ul> <li>Amount: <code>AppTextField</code> keyboard <code>TextInputType.numberWithOptions(decimal:true)</code>, formatter decimal 2 places, error "Enter a valid amount (min 0)".</li> <li>Gateway: <code>AppDropdown</code> from <code>PaymentGateway</code> enum — icons per method.</li> <li>Invoice: optional; selecting prefills amount (editable after) + payerName from invoice payer when present.</li> <li>Submit: <code>loading</code> swaps label → spinner; fields disabled while pending; double-tap blocked.</li> </ul> <h3 id="states-9"><a class="header" href="#states-9">States</a></h3> <ul> <li>Validation: server 400 <code>details</code> mapped to fields; generic banner for others.</li> <li>Success: navigate-inline to success view: payment + receipt cards + actions (View receipt, Done → S1 refreshed).</li> <li>Failure: banner <code>AppBanner</code> error + Retry; form values preserved.</li> </ul> <h3 id="darklight-token-based-only"><a class="header" href="#darklight-token-based-only">Dark/light: token-based only.</a></h3> <h3 id="a11y-9"><a class="header" href="#a11y-9">A11y</a></h3> <ul> <li>Labels visible; autofill hints (name, email).</li> <li>Error announcement via live region; focus first invalid field.</li> </ul> <hr /> <h2 id="s4--refund-sheet"><a class="header" href="#s4--refund-sheet">S4 — Refund sheet</a></h2> <ul> <li><code>AppBottomSheet</code> with drag handle; title "Refund payment".</li> <li>Refundable amount info row (<code>bodyMedium</code>).</li> <li>Amount field (default full refundable, min 0.01, max refundable).</li> <li>Reason <code>AppTextField</code> (required, 3–200 chars).</li> <li>Submit <code>FilledButton</code> destructive (error colors) loading state; Cancel <code>TextButton</code>.</li> <li>Server 409 messages → inline error under amount / banner.</li> <li>Confirm semantics: <code>Semantics(button, label "Confirm refund of amount")</code>.</li> <li>Sheet dismiss: back gesture/ESC = cancel; no discard warning (non-destructive form).</li> </ul> <h2 id="s5--reconcile-sheet-1"><a class="header" href="#s5--reconcile-sheet-1">S5 — Reconcile sheet</a></h2> <ul> <li>Title "Reconcile payment".</li> <li>Outcome <code>AppRadio</code> group: Success / Failed / Pending.</li> <li>Gateway response text field (optional).</li> <li>Submit → PATCH; result status reflected on S2; errors → banner + retry.</li> </ul> <h2 id="s6--receipt-view-1"><a class="header" href="#s6--receipt-view-1">S6 — Receipt view</a></h2> <ul> <li>Receipt card (centered, max width 480, e-1, padding 24): <ul> <li>Header: "Receipt" + <code>receiptNumber</code> mono; school/org name (tenant branding) <code>(planned)</code>.</li> <li>Body: amount large, fee, currency, payment method; payer name/email; description; payment reference; invoice id.</li> <li>Footer: issuedAt date (locale), paymentId.</li> </ul> </li> <li>Actions: Share <code>(forward-looking)</code>; Copy receipt number.</li> <li>Print: <code>(planned)</code> reports module PDF.</li> <li>A11y: receipt is a <code>Semantics</code> summary; amounts spoken fully.</li> </ul> <h2 id="s7--receipts-list-1"><a class="header" href="#s7--receipts-list-1">S7 — Receipts list</a></h2> <ul> <li>Mirrors S1; rows: receiptNumber, amount, date, payerName; tap → S6.</li> <li>Pagination same <code>{data,total}</code> handling.</li> </ul> <hr /> <h2 id="global-interaction-details"><a class="header" href="#global-interaction-details">Global interaction details</a></h2> <ul> <li>Haptics: success <code>lightImpact</code>; error <code>mediumImpact</code>; refund confirm <code>heavyImpact</code>.</li> <li>Hover (desktop): row tint <code>surfaceContainerHigh</code>; destructive hover <code>errorContainer</code>.</li> <li>Focus order: search → chips → list → FAB; visible <code>primary</code> ring.</li> <li>Hero: invoice → payment amount cross-fade (no shared element needed).</li> <li>Reduced motion: all <code>m-instant</code> fades.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-payments"><a class="header" href="#07--component-library-payments">07 — Component Library (Payments)</a></h1> <ul> <li><a href="payments/07_Component_Library.html#p1--paymentrow">P1 — PaymentRow</a></li> <li><a href="payments/07_Component_Library.html#p2--amounttext">P2 — AmountText</a></li> <li><a href="payments/07_Component_Library.html#p3--statusbadgepayments">P3 — StatusBadgePayments</a></li> <li><a href="payments/07_Component_Library.html#p4--receiptviewcard">P4 — ReceiptViewCard</a></li> <li><a href="payments/07_Component_Library.html#p5--paymentformfields">P5 — PaymentFormFields</a></li> <li><a href="payments/07_Component_Library.html#p6--actionsrow">P6 — ActionsRow</a></li> <li><a href="payments/07_Component_Library.html#p7--moneyprogress">P7 — MoneyProgress</a></li> </ul> <blockquote> <p>Reusable components for the payments module. Shared primitives (<code>AppCard</code>, <code>AppTextField</code>, <code>AppDropdown</code>, <code>AppBadge</code>, <code>AppSnackbar</code>, <code>AppEmptyState</code>, <code>AppErrorState</code>, <code>AppSkeleton</code>, <code>AppFAB</code>) come from <a href="payments/../00-shared/03_Component_Library.html">00-shared/03</a> — not re-specified here.</p> </blockquote> <hr /> <h2 id="p1--paymentrow"><a class="header" href="#p1--paymentrow">P1 — PaymentRow</a></h2> <ul> <li><strong>Purpose:</strong> list row for a payment.</li> <li><strong>Props:</strong> <code>payment</code>, <code>onTap</code>, <code>showInvoiceLink?</code>.</li> <li><strong>Variants:</strong> default; dense (accountant list).</li> <li><strong>States:</strong> regular, selected, disabled (never — read-only).</li> <li><strong>Contents:</strong> gateway icon, <code>transactionReference</code>, payerName, date, amount, status badge.</li> <li><strong>A11y:</strong> single semantics node; status icon+label, never color-only.</li> <li><strong>Tokens:</strong> <code>e-0</code>, height ≥ 72, <code>mono</code> reference.</li> <li><strong>Flutter:</strong> <code>ListTile</code> custom; <code>CachedNetworkImage</code> not needed (no images).</li> </ul> <h2 id="p2--amounttext"><a class="header" href="#p2--amounttext">P2 — AmountText</a></h2> <ul> <li><strong>Purpose:</strong> money display with currency, tabular figures.</li> <li><strong>Props:</strong> <code>amount</code>, <code>currency</code>, <code>size</code> (titleMedium/headlineMedium/bodyMedium), <code>signed?</code>.</li> <li><strong>States:</strong> default; strikethrough for refunded portion; dim for failed/pending.</li> <li><strong>Validation:</strong> formats 2 decimals; locale via <code>Intl</code> <code>NumberFormat.currency</code>.</li> <li><strong>Flutter:</strong> <code>Text</code> + <code>FontFeature.tabularFigures()</code>; util extension.</li> </ul> <h2 id="p3--statusbadgepayments"><a class="header" href="#p3--statusbadgepayments">P3 — StatusBadgePayments</a></h2> <ul> <li><strong>Purpose:</strong> payment status visual (wraps <code>AppBadge</code>).</li> <li><strong>Props:</strong> <code>status</code> (<code>PaymentStatus</code>), <code>showLabel</code>.</li> <li><strong>States:</strong> 7 statuses → color+icon per <a href="payments/06_Screen_Specifications.html">06 §S1</a> table.</li> <li><strong>A11y:</strong> label always spoken.</li> <li><strong>Flutter:</strong> <code>Badge</code>/custom chip.</li> </ul> <h2 id="p4--receiptviewcard"><a class="header" href="#p4--receiptviewcard">P4 — ReceiptViewCard</a></h2> <ul> <li><strong>Purpose:</strong> printable receipt layout.</li> <li><strong>Props:</strong> <code>receipt</code>, <code>actions?</code>.</li> <li><strong>States:</strong> complete; missing invoice (still renders, invoice ref blank).</li> <li><strong>Tokens:</strong> <code>surfaceContainerLow</code>, <code>e-1</code>, <code>r-md</code>, padding 24, max width 480.</li> <li><strong>Flutter:</strong> <code>Card</code> + <code>Column</code>.</li> </ul> <h2 id="p5--paymentformfields"><a class="header" href="#p5--paymentformfields">P5 — PaymentFormFields</a></h2> <ul> <li><strong>Purpose:</strong> the record-payment form body (extracted, reusable on S3 and as a sheet on tablet).</li> <li><strong>Props:</strong> <code>initialAmount</code>, <code>invoices</code> (for picker), <code>busy</code>, <code>onSubmit(payload)</code>.</li> <li><strong>Variants:</strong> full (all fields) / compact (invoice context prefills).</li> <li><strong>Validation:</strong> server-mapped errors (<code>details</code>) + client (amount min, email format, gateway required).</li> <li><strong>Flutter:</strong> <code>Form</code> + <code>AppTextField</code>s.</li> </ul> <h2 id="p6--actionsrow"><a class="header" href="#p6--actionsrow">P6 — ActionsRow</a></h2> <ul> <li><strong>Purpose:</strong> contextual payment actions (View receipt / Refund / Reconcile).</li> <li><strong>Props:</strong> <code>status</code>, <code>permissions</code>, <code>onRefund</code>, <code>onReconcile</code>, <code>onReceipt</code>.</li> <li><strong>States:</strong> action hidden when permission absent or status ineligible; destructive button styled <code>error</code>.</li> <li><strong>Flutter:</strong> <code>Row</code>/<code>Wrap</code> of <code>FilledButton.tonal</code>, <code>OutlinedButton</code>, <code>TextButton</code>.</li> </ul> <h2 id="p7--moneyprogress"><a class="header" href="#p7--moneyprogress">P7 — MoneyProgress</a></h2> <ul> <li><strong>Purpose:</strong> paidAmount vs totalAmount progress (invoice link).</li> <li><strong>Props:</strong> <code>paid</code>, <code>total</code>, <code>label</code>.</li> <li><strong>States:</strong> 0%, partial, 100% (PAID — success color).</li> <li><strong>A11y:</strong> live summary "paid X of Y".</li> <li><strong>Flutter:</strong> <code>LinearProgressIndicator</code> + <code>Text</code>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-payments"><a class="header" href="#08--form-specifications-payments">08 — Form Specifications (Payments)</a></h1> <ul> <li><a href="payments/08_Form_Specifications.html#f1--record-payment-post-payments">F1 — Record payment (<code>POST /payments</code>)</a></li> <li><a href="payments/08_Form_Specifications.html#f2--refund-post-paymentsrefund">F2 — Refund (<code>POST /payments/refund</code>)</a></li> <li><a href="payments/08_Form_Specifications.html#f3--reconcile-patch-paymentstransactionrefreconcile">F3 — Reconcile (<code>PATCH /payments/:transactionRef/reconcile</code>)</a></li> <li><a href="payments/08_Form_Specifications.html#f4--local-filters-s1">F4 — Local filters (S1)</a></li> </ul> <blockquote> <p>Every form field, derived from <code>process-payment.dto.ts</code>, <code>refund-payment.dto.ts</code>, and the reconcile endpoint body.</p> </blockquote> <hr /> <h2 id="f1--record-payment-post-payments"><a class="header" href="#f1--record-payment-post-payments">F1 — Record payment (<code>POST /payments</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type / control</th><th>Label</th><th>Hint / placeholder</th><th>Keyboard</th><th>Validation (server)</th><th>Error message</th></tr></thead><tbody> <tr><td>amount</td><td><code>AppTextField</code> decimal</td><td>Amount</td><td>e.g. 250.00</td><td>numeric-decimal</td><td><code>@IsNumber @Min(0)</code></td><td>"Enter a valid amount (min 0)"</td></tr> <tr><td>currency</td><td><code>AppTextField</code> (read-only default)</td><td>Currency</td><td>USD</td><td>text</td><td><code>@IsString @IsOptional</code></td><td>—</td></tr> <tr><td>gateway</td><td><code>AppDropdown</code></td><td>Gateway</td><td>required</td><td>—</td><td><code>@IsEnum(PaymentGateway)</code></td><td>"Select payment method"</td></tr> <tr><td>gatewayTransactionId</td><td><code>AppTextField</code></td><td>Gateway reference</td><td>optional, gateway methods</td><td>text</td><td><code>@IsString @IsOptional</code></td><td>—</td></tr> <tr><td>invoiceId</td><td><code>AppDropdown</code> searchable</td><td>Invoice</td><td>optional (search by invoice no.)</td><td>—</td><td><code>@IsString @IsOptional</code></td><td>—</td></tr> <tr><td>invoiceType</td><td><code>AppTextField</code></td><td>Invoice type</td><td>optional</td><td>text</td><td><code>@IsString @IsOptional</code></td><td>—</td></tr> <tr><td>payerId</td><td><code>AppTextField</code></td><td>Payer ID</td><td>optional</td><td>text</td><td><code>@IsString @IsOptional</code></td><td>—</td></tr> <tr><td>payerEmail</td><td><code>AppTextField</code></td><td>Payer email</td><td>email</td><td>email</td><td><code>@IsString @IsOptional</code></td><td>"Enter a valid email" (client)</td></tr> <tr><td>payerName</td><td><code>AppTextField</code></td><td>Payer name</td><td>optional</td><td>text</td><td><code>@IsString @IsOptional</code></td><td>—</td></tr> <tr><td>description</td><td><code>AppTextField</code> multiline</td><td>Description</td><td>optional</td><td>text</td><td><code>@IsString @IsOptional</code></td><td>—</td></tr> </tbody></table> </div> <ul> <li><strong>Defaults:</strong> currency <code>USD</code>; amounts empty; gateway none.</li> <li><strong>Dependencies/conditional:</strong> selecting an invoice prefills amount + payerName (editable). Offline methods (cash/cheque/bank_transfer) recommend payerName; gateway methods show gatewayTransactionId.</li> <li><strong>Autocomplete:</strong> payer name/email from recent payers (client cache) <code>(proposed)</code>.</li> <li><strong>Submission:</strong> client validates → <code>POST /payments</code>; button loading; <strong>no optimistic update</strong> (server is source of truth).</li> <li><strong>Success:</strong> inline payment + receipt cards; offer "View receipt", "Done".</li> <li><strong>Failure:</strong> 400 field errors mapped; 409 conflict banner + refresh; 5xx generic + retry; values preserved.</li> <li><strong>Undo:</strong> not applicable (confirmatory commitment).</li> </ul> <h2 id="f2--refund-post-paymentsrefund"><a class="header" href="#f2--refund-post-paymentsrefund">F2 — Refund (<code>POST /payments/refund</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Label</th><th>Validation</th><th>Notes</th></tr></thead><tbody> <tr><td>paymentId</td><td>hidden (from S2 context)</td><td><code>@IsString</code></td><td>locked</td></tr> <tr><td>amount</td><td>Refund amount</td><td><code>@IsNumber @Min(0) @IsOptional</code>; max = amount − refundedAmount</td><td>default full refundable; 0 → inline error</td></tr> <tr><td>reason</td><td>Reason</td><td><code>@IsString @IsOptional</code>; client min 3 chars</td><td>show in confirmation</td></tr> </tbody></table> </div> <ul> <li><strong>Submission:</strong> loading; on success snackbar + status updated; on 409 "Only completed payments can be refunded."/"Refund amount exceeds payment amount." → inline/banner.</li> <li><strong>Optimistic:</strong> no.</li> </ul> <h2 id="f3--reconcile-patch-paymentstransactionrefreconcile"><a class="header" href="#f3--reconcile-patch-paymentstransactionrefreconcile">F3 — Reconcile (<code>PATCH /payments/:transactionRef/reconcile</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type</th><th>Validation</th></tr></thead><tbody> <tr><td>status (body)</td><td><code>AppRadio</code></td><td>"success"→completed, "failed"→failed, else pending</td></tr> <tr><td>gatewayResponse (optional client field)</td><td>text</td><td>informational</td></tr> </tbody></table> </div> <ul> <li><strong>Submission:</strong> loading; result status reflected after refresh.</li> <li><strong>Error 404:</strong> payment not found → error state.</li> </ul> <h2 id="f4--local-filters-s1"><a class="header" href="#f4--local-filters-s1">F4 — Local filters (S1)</a></h2> <ul> <li>Search term + status chips — <strong>client-side only</strong> (no server query support).</li> <li>Debounce 300 ms; clear restores.</li> <li>No validation.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-payments"><a class="header" href="#09--user-behaviour-payments">09 — User Behaviour (Payments)</a></h1> <ul> <li><a href="payments/09_User_Behaviour.html#expected-behaviour">Expected behaviour</a></li> <li><a href="payments/09_User_Behaviour.html#common-mistakes">Common mistakes</a></li> <li><a href="payments/09_User_Behaviour.html#fast-paths">Fast paths</a></li> <li><a href="payments/09_User_Behaviour.html#power-users">Power users</a></li> <li><a href="payments/09_User_Behaviour.html#new-users">New users</a></li> <li><a href="payments/09_User_Behaviour.html#expert-users">Expert users</a></li> <li><a href="payments/09_User_Behaviour.html#accessibility-users">Accessibility users</a></li> <li><a href="payments/09_User_Behaviour.html#interrupted-sessions--retries">Interrupted sessions & retries</a></li> <li><a href="payments/09_User_Behaviour.html#abandonment">Abandonment</a></li> <li><a href="payments/09_User_Behaviour.html#frustration-points--fixes">Frustration points + fixes</a></li> </ul> <blockquote> <p>Expected behaviour, mistakes, fast paths, and how the UX resolves them.</p> </blockquote> <hr /> <h2 id="expected-behaviour"><a class="header" href="#expected-behaviour">Expected behaviour</a></h2> <ul> <li>Accountant records a counter payment in < 60 s and sees a receipt instantly.</li> <li>Refunds are deliberate: reason required, exact amount shown, confirmation.</li> <li>Gateway outcomes surfaced as PENDING/PROCESSING/COMPLETED/FAILED, reconciled by the accountant, not auto-patched by clients.</li> <li>Invoice due amounts always reflect latest <code>paidAmount</code> (pull-to-refresh).</li> </ul> <h2 id="common-mistakes"><a class="header" href="#common-mistakes">Common mistakes</a></h2> <div class="table-wrapper"><table><thead><tr><th>Mistake</th><th>UX mitigation</th></tr></thead><tbody> <tr><td>Wrong payer selected</td><td>Payer search prefilled from invoice; editable before submit; detail shows full payer context</td></tr> <tr><td>Wrong amount</td><td>Decimal formatter + min 0 + server validation; no silent rounding</td></tr> <tr><td>Double-tap Submit → double payment</td><td>Submit disabled while pending; but <strong>no idempotency key server-side</strong> — flag OQ</td></tr> <tr><td>Refunding too much</td><td>Client max = amount − refundedAmount; server 409 backstop</td></tr> <tr><td>Recording gateway payment without gateway reference</td><td>Optional field lent; reconcile path exists</td></tr> <tr><td>Reconciling with wrong status string</td><td>Radio limits choices; server maps unknown → pending</td></tr> </tbody></table> </div> <h2 id="fast-paths"><a class="header" href="#fast-paths">Fast paths</a></h2> <ul> <li>Invoice detail → "Record payment" prefills amount + payer.</li> <li>FAB from list → new payment.</li> <li>Recent payment repeated → copy previous (payer + description) <code>(proposed)</code>.</li> </ul> <h2 id="power-users"><a class="header" href="#power-users">Power users</a></h2> <ul> <li>Keyboard-first desktop: <code>/</code> focus, Enter submit, ESC cancel, tab order fixed.</li> <li>Keyboard shortcuts: <code>N</code> new payment; <code>Ctrl/Cmd+F</code> filter.</li> </ul> <h2 id="new-users"><a class="header" href="#new-users">New users</a></h2> <ul> <li>First-time empty state explains "record your first payment"; help text on refund sheet explains guards.</li> </ul> <h2 id="expert-users"><a class="header" href="#expert-users">Expert users</a></h2> <ul> <li>Reconcile screen aggregates PENDING payments count; bulk-eligible design <code>(proposed)</code> — today one PATCH per payment.</li> </ul> <h2 id="accessibility-users"><a class="header" href="#accessibility-users">Accessibility users</a></h2> <ul> <li>Money values spoken fully; statuses icon+label; focus order logical; large type reflows (no fixed heights).</li> </ul> <h2 id="interrupted-sessions--retries"><a class="header" href="#interrupted-sessions--retries">Interrupted sessions & retries</a></h2> <ul> <li>Interrupted mid-submit: button pending until server responds; on failure keep form intact; never half-write locally.</li> <li>Retries: exponential backoff for 5xx/network; 429 → countdown, no auto-retry.</li> </ul> <h2 id="abandonment"><a class="header" href="#abandonment">Abandonment</a></h2> <ul> <li>Close form → confirm discard (if touched); refund sheet discard without warning (non-destructive until submit).</li> </ul> <h2 id="frustration-points--fixes"><a class="header" href="#frustration-points--fixes">Frustration points + fixes</a></h2> <div class="table-wrapper"><table><thead><tr><th>Frustration</th><th>Fix</th></tr></thead><tbody> <tr><td>"Where did my payment go?"</td><td>Traceable: payment → invoice → receipt links; search</td></tr> <tr><td>Invoice status stale after payment</td><td>Auto-link to invoice updates PAID/PARTIAL; pull-to-refresh</td></tr> <tr><td>Can't fix a wrong gateway outcome</td><td>Reconcile screen sets status explicitly</td></tr> <tr><td>Money precision doubt</td><td>Tabular figures + 2-decimal format; show refundedAmount explicitly</td></tr> <tr><td>Two payment modules confusing (fees payments vs payments_v2)</td><td>Cross-module inconsistency flagged OQ-1 (see 12); UI shows canonical payments module records</td></tr> <tr><td>404 instead of validation on business rule</td><td>Client pre-validates <code>marks</code>/amounts; 404s handled as not-found UI when weird codes arrive—log + report</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-payments"><a class="header" href="#10--interaction-specification-payments">10 — Interaction Specification (Payments)</a></h1> <ul> <li><a href="payments/10_Interaction_Specification.html#tap">Tap</a></li> <li><a href="payments/10_Interaction_Specification.html#long-press">Long press</a></li> <li><a href="payments/10_Interaction_Specification.html#double-tap">Double tap</a></li> <li><a href="payments/10_Interaction_Specification.html#swipe">Swipe</a></li> <li><a href="payments/10_Interaction_Specification.html#pull-to-refresh">Pull-to-refresh</a></li> <li><a href="payments/10_Interaction_Specification.html#scroll--infinite-scroll">Scroll & infinite scroll</a></li> <li><a href="payments/10_Interaction_Specification.html#back--hardware-back--esc">Back / hardware back / ESC</a></li> <li><a href="payments/10_Interaction_Specification.html#hover--mouse--desktop">Hover / mouse / desktop</a></li> <li><a href="payments/10_Interaction_Specification.html#gamepad">Gamepad</a></li> <li><a href="payments/10_Interaction_Specification.html#haptics">Haptics</a></li> <li><a href="payments/10_Interaction_Specification.html#motion">Motion</a></li> </ul> <blockquote> <p>Interaction detail for the payments module. Base gestures from <a href="payments/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>.</p> </blockquote> <hr /> <h2 id="tap"><a class="header" href="#tap">Tap</a></h2> <ul> <li>Row tap → detail push (<code>m-base</code>). FAB tap → new payment. Action buttons tap → submit/confirm.</li> <li>Copy reference → clipboard + snackbar "Copied".</li> </ul> <h2 id="long-press"><a class="header" href="#long-press">Long press</a></h2> <ul> <li>Payments row → context menu (View, Receipt, Refund, Reconcile).</li> <li>Receipt summary → copy menu.</li> </ul> <h2 id="double-tap"><a class="header" href="#double-tap">Double tap</a></h2> <ul> <li>Not used (amounts/cards are static; enabling double-tap has no value).</li> </ul> <h2 id="swipe"><a class="header" href="#swipe">Swipe</a></h2> <ul> <li>Right-swipe on COMPLETED row: quick "New receipt view"; swipe on PENDING row: "Reconcile" <code>(proposed)</code>.</li> <li>No destructive left-swipe (refund is deliberate, not a swipe).</li> </ul> <h2 id="pull-to-refresh"><a class="header" href="#pull-to-refresh">Pull-to-refresh</a></h2> <ul> <li>Payments list, Receipts list, Payment detail — re-fetch <code>{data,total}</code> / payment / receipt; <code>primary</code> spinner.</li> </ul> <h2 id="scroll--infinite-scroll"><a class="header" href="#scroll--infinite-scroll">Scroll & infinite scroll</a></h2> <ul> <li>Payments list is <strong>paged</strong> (Load more footer). No auto infinite scroll.</li> <li>FAB hides on scroll-down, reappears scroll-up.</li> </ul> <h2 id="back--hardware-back--esc"><a class="header" href="#back--hardware-back--esc">Back / hardware back / ESC</a></h2> <ul> <li>Detail → list; form → previous; sheets/dialogs: back/ESC = cancel (no discard warning on sheets).</li> <li>On root payments tab: Android back → previous shell branch.</li> </ul> <h2 id="hover--mouse--desktop"><a class="header" href="#hover--mouse--desktop">Hover / mouse / desktop</a></h2> <ul> <li>Row hover tint <code>surfaceContainerHigh</code>; destructive action hover <code>errorContainer</code>.</li> <li>Right-click = context menu (same as long-press).</li> <li>Focus ring <code>primary</code> on all interactive; Enter activates focused control.</li> </ul> <h2 id="gamepad"><a class="header" href="#gamepad">Gamepad</a></h2> <ul> <li>Not relevant (mobile/desktop ERP client).</li> </ul> <h2 id="haptics"><a class="header" href="#haptics">Haptics</a></h2> <ul> <li>Success record → <code>lightImpact</code>; refund confirmed → <code>heavyImpact</code>; error → <code>mediumImpact</code>.</li> </ul> <h2 id="motion-4"><a class="header" href="#motion-4">Motion</a></h2> <ul> <li>Success transition: sheet→inline success with <code>m-entrance</code> slide; receipt card <code>AnimatedSwitcher</code> 150 ms.</li> <li>Money updates: <code>AnimatedDefaultTextStyle</code> or <code>AnimatedSwitcher</code> (fast, 150 ms).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-payments"><a class="header" href="#11--design-system-mapping-payments">11 — Design System Mapping (Payments)</a></h1> <ul> <li><a href="payments/11_Design_System_Mapping.html#component-mapping">Component mapping</a></li> <li><a href="payments/11_Design_System_Mapping.html#iconography">Iconography</a></li> <li><a href="payments/11_Design_System_Mapping.html#motion">Motion</a></li> <li><a href="payments/11_Design_System_Mapping.html#responsive">Responsive</a></li> </ul> <blockquote> <p>Maps payments components to Material 3 + Flutter widget + tokens. Reference <a href="payments/../00-shared/04_Design_System_Mapping.html">00-shared/04</a> for the global mapping.</p> </blockquote> <hr /> <h2 id="component-mapping"><a class="header" href="#component-mapping">Component mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Component</th><th>M3 role</th><th>Flutter widget</th><th>Spacing</th><th>Typography</th><th>Elevation</th><th>Radius</th><th>Colors</th></tr></thead><tbody> <tr><td>PaymentRow</td><td>List item</td><td><code>ListTile</code> custom</td><td>h ≥ 72, gap 12</td><td>titleSmall ref</td><td>e-0</td><td>r-md</td><td>surface / onSurface</td></tr> <tr><td>RefundText</td><td>Money display</td><td><code>Text</code> + tabular</td><td>—</td><td>titleMedium</td><td>—</td><td>—</td><td>onSurface</td></tr> <tr><td>StatusBadge</td><td>Badge</td><td><code>Chip</code>/<code>Badge</code></td><td>—</td><td>labelMedium</td><td>e-0</td><td>r-full</td><td>per status on <code>*Container</code></td></tr> <tr><td>ReceiptViewCard</td><td>Elevated card</td><td><code>Card</code></td><td>padding 24</td><td>body/body</td><td>e-1</td><td>r-lg</td><td>surfaceContainerLowest</td></tr> <tr><td>PaymentActionsRow</td><td>Action row</td><td><code>Wrap</code> of buttons</td><td>gap 8</td><td>labelLarge</td><td>—</td><td>r-md</td><td>primary/error</td></tr> <tr><td>MoneyProgress</td><td>Progress</td><td><code>LinearProgressIndicator</code></td><td>—</td><td>bodySmall</td><td>—</td><td>r-sm</td><td>primary/success</td></tr> <tr><td>NewPaymentForm</td><td>Form</td><td><code>Form</code>+<code>AppTextField</code></td><td>gap 16</td><td>bodyMedium</td><td>e-1</td><td>r-md</td><td>surfaceVariant</td></tr> <tr><td>Reconcile radios</td><td>Radio group</td><td><code>RadioListTile</code></td><td>—</td><td>bodyMedium</td><td>—</td><td>—</td><td>primary</td></tr> </tbody></table> </div> <h2 id="iconography"><a class="header" href="#iconography">Iconography</a></h2> <ul> <li>Offline gateways: cash/cheque/bank_transfer.</li> <li>Gateway logos (generic icons; no brand logo w/o asset) <code>(forward-looking)</code>.</li> <li>Status icons: check_circle, schedule, cancel, replay.</li> </ul> <h2 id="motion-5"><a class="header" href="#motion-5">Motion</a></h2> <ul> <li>Page transitions <code>m-base</code>; sheet <code>m-base</code>; FAB <code>m-entrance</code>; money updates 150 ms <code>m-fast</code>; reduced motion → fades.</li> </ul> <h2 id="responsive"><a class="header" href="#responsive">Responsive</a></h2> <ul> <li>Phone: single column, FAB, bottom nav.</li> <li>Tablet (600–839): two-column payment list; detail in master pane.</li> <li>Landscape tablet / desktop: table layout (columns: ref, payer, gateway, amount, status, date), filter rail, hover actions.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-payments"><a class="header" href="#12--api-mapping-payments">12 — API Mapping (Payments)</a></h1> <ul> <li><a href="payments/12_API_Mapping.html#e1--process-payment-s3">E1 — Process payment (S3)</a></li> <li><a href="payments/12_API_Mapping.html#e2--refund-s4">E2 — Refund (S4)</a></li> <li><a href="payments/12_API_Mapping.html#e3--reconcile-s5">E3 — Reconcile (S5)</a></li> <li><a href="payments/12_API_Mapping.html#e4e7--lists-s1s7">E4/E7 — Lists (S1/S7)</a></li> <li><a href="payments/12_API_Mapping.html#e5e8--detail">E5/E8 — Detail</a></li> <li><a href="payments/12_API_Mapping.html#realtime">Realtime</a></li> <li><a href="payments/12_API_Mapping.html#caching--offline">Caching & offline</a></li> </ul> <blockquote> <p>Exact endpoints from <code>payments.controller.ts</code>, mapped to screens. Wire contract from <a href="payments/../00-shared/07_API_Conventions.html">00-shared/07</a> — note the <strong>pagination deviation</strong> below.</p> </blockquote> <p><strong>Base:</strong> <code>/api/v1</code> · Auth: <code>JwtAuthGuard</code> (RBAC perms <code>(planned)</code>).</p> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Method</th><th>Path</th><th>Screen</th><th>Notes</th></tr></thead><tbody> <tr><td>E1</td><td>POST</td><td><code>/payments</code></td><td>S3 record</td><td>body <code>ProcessPaymentDto</code>; returns <code>{payment, receipt}</code>; emits <code>PaymentProcessed</code></td></tr> <tr><td>E2</td><td>POST</td><td><code>/payments/refund</code></td><td>S4</td><td>body <code>RefundPaymentDto</code>; 409 rules</td></tr> <tr><td>E3</td><td>PATCH</td><td><code>/payments/:transactionRef/reconcile</code></td><td>S5</td><td>body <code>{status:string}</code></td></tr> <tr><td>E4</td><td>GET</td><td><code>/payments?page&limit</code></td><td>S1</td><td><strong>returns <code>{data, total}</code> — no shared <code>meta</code></strong></td></tr> <tr><td>E5</td><td>GET</td><td><code>/payments/:id</code></td><td>S2</td><td>404 if missing</td></tr> <tr><td>E6</td><td>GET</td><td><code>/payments/invoice/:invoiceId</code></td><td>S8</td><td>array (unpaginated)</td></tr> <tr><td>E7</td><td>GET</td><td><code>/payments/receipts?page&limit</code></td><td>S7</td><td><strong><code>{data, total}</code></strong></td></tr> <tr><td>E8</td><td>GET</td><td><code>/payments/receipts/:id</code></td><td>S6</td><td>404 if missing</td></tr> </tbody></table> </div> <h2 id="e1--process-payment-s3"><a class="header" href="#e1--process-payment-s3">E1 — Process payment (S3)</a></h2> <ul> <li><strong>Request:</strong></li> </ul> <pre><code class="language-json">{ "amount": 250, "currency": "USD", "gateway": "cash", "gatewayTransactionId": "G-123", "invoiceId": "…", "payerName": "A. Kumar", "payerEmail": "a@x.com", "description": "Term 1 fee" } </code></pre> <ul> <li><strong>Response (data):</strong> <code>{ payment: {transactionReference, amount, fee, refundedAmount, currency, gateway, status:"completed", invoiceId, payerName, payerEmail, createdAt}, receipt: {receiptNumber, paymentId, amount, fee, currency, paymentMethod, payerName, issuedAt} }</code></li> <li><strong>Loading:</strong> submit spinner → success screen. <strong>Caching:</strong> none (write).</li> <li><strong>Optimistic UI:</strong> none — server-confirmed only.</li> <li><strong>Errors:</strong> 400 validation (field <code>details</code>), 409 duplicates, 5xx.</li> <li><strong>Idempotency:</strong> <code>transactionReference</code> auto-generated server-side; <strong>no client Idempotency-Key</strong> (OQ-3).</li> </ul> <h2 id="e2--refund-s4"><a class="header" href="#e2--refund-s4">E2 — Refund (S4)</a></h2> <ul> <li><strong>Request:</strong> <code>{paymentId, amount?, reason?}</code></li> <li><strong>Rules (service <code>payments.service.ts</code>):</strong> status must be <code>completed</code> (409); refund total ≤ amount (409); full → <code>refunded</code>, partial → <code>partially_refunded</code>.</li> <li><strong>Response:</strong> updated payment doc.</li> </ul> <h2 id="e3--reconcile-s5"><a class="header" href="#e3--reconcile-s5">E3 — Reconcile (S5)</a></h2> <ul> <li><strong>Request:</strong> <code>PATCH /payments/{transactionRef}/reconcile</code> body <code>{status}</code> — <code>"success"</code> → completed, <code>"failed"</code> → failed, anything else → pending.</li> <li><strong>Response:</strong> updated payment.</li> </ul> <h2 id="e4e7--lists-s1s7"><a class="header" href="#e4e7--lists-s1s7">E4/E7 — Lists (S1/S7)</a></h2> <ul> <li>Query: <code>page</code> (default 1), <code>limit</code> (default 20).</li> <li><strong>Response shape deviation:</strong> <code>{data:[…], total}</code> — the shared envelope adds <code>meta</code> only for <code>{data, meta}</code> payloads (<code>response-envelope.interceptor.ts:25-32</code>), so the client must derive pagination from <code>data.length</code> + <code>total</code>. Documented as <strong>OQ-2</strong> (inconsistent with <code>PaginationMeta</code> elsewhere).</li> <li>Sort: server-fixed <code>createdAt</code>/<code>issuedAt</code> desc; <code>sort</code>/<code>q</code> params not supported.</li> </ul> <h2 id="e5e8--detail"><a class="header" href="#e5e8--detail">E5/E8 — Detail</a></h2> <ul> <li>Returns single doc; 404 → <code>RESOURCE_NOT_FOUND</code>.</li> </ul> <h2 id="realtime"><a class="header" href="#realtime">Realtime</a></h2> <ul> <li><code>PaymentProcessed</code> / <code>PaymentRefunded</code> events → <code>WsBridge</code> broadcasts to <code>tenant:{tenantId}</code> room (<code>ws-bridge.service.ts</code>) → client can live-append list / refresh invoice dues. <strong>Not</strong> routed to BullMQ (<code>event-queue-map.ts</code> has <code>PaymentCompleted</code>→emails dead entry; <code>PaymentProcessed</code> has no entry).</li> </ul> <h2 id="caching--offline"><a class="header" href="#caching--offline">Caching & offline</a></h2> <ul> <li>Lists: client cache 5 min (stale-while-revalidate); detail 1 min.</li> <li>Offline: read-only cached views + banner; writes blocked (no offline payment queue — money path never optimistic).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-payments"><a class="header" href="#13--state-management-payments">13 — State Management (Payments)</a></h1> <ul> <li><a href="payments/13_State_Management.html#cubits">Cubits</a> <ul> <li><a href="payments/13_State_Management.html#paymentslistcubit">PaymentsListCubit</a></li> <li><a href="payments/13_State_Management.html#paymentdetailcubit">PaymentDetailCubit</a></li> <li><a href="payments/13_State_Management.html#newpaymentcubit">NewPaymentCubit</a></li> <li><a href="payments/13_State_Management.html#receiptslistcubit">ReceiptsListCubit</a></li> </ul> </li> <li><a href="payments/13_State_Management.html#shared-selectors">Shared selectors</a></li> <li><a href="payments/13_State_Management.html#cache">Cache</a></li> <li><a href="payments/13_State_Management.html#realtime">Realtime</a></li> <li><a href="payments/13_State_Management.html#error-handling-client-table">Error handling (client table)</a></li> </ul> <blockquote> <p>Bloc/Cubit per screen following <a href="payments/../00-shared/06_State_Management.html">00-shared/06</a> conventions (proposed architecture).</p> </blockquote> <hr /> <h2 id="cubits"><a class="header" href="#cubits">Cubits</a></h2> <h3 id="paymentslistcubit"><a class="header" href="#paymentslistcubit">PaymentsListCubit</a></h3> <ul> <li><strong>State:</strong> <code>{LoadState, List<PaymentDto>, total, page, hasNext, filters: (status, query), busy}</code>.</li> <li><strong>Events:</strong> <code>LoadList</code>, <code>Refresh</code> (bypass cache), <code>LoadMore</code>, <code>FilterChanged</code>, <code>Retry</code>, <code>PaymentCreated</code> (append/refresh).</li> <li><strong>Behaviours:</strong> server returns <code>{data,total}</code> → <code>hasNext = data.length + current < total</code>; client-side filtering via local search + status chips; pull-to-refresh re-fetches.</li> <li><strong>Realtime:</strong> subscribes <code>tenant</code> WS room, filter topic <code>payment.processed</code>/<code>payment. refunded</code> → refresh or insert.</li> </ul> <h3 id="paymentdetailcubit"><a class="header" href="#paymentdetailcubit">PaymentDetailCubit</a></h3> <ul> <li><code>Load(id)</code> → <code>{data, LoadState}</code>; on WS event for same <code>transactionReference</code> → re-fetch.</li> <li>Actions emit <code>Refund(submit)</code> → success → reload + snackbar; <code>Reconcile(status)</code> → reload.</li> </ul> <h3 id="newpaymentcubit"><a class="header" href="#newpaymentcubit">NewPaymentCubit</a></h3> <ul> <li><strong>State machine (Mermaid):</strong></li> </ul> <pre class="mermaid">stateDiagram-v2 [*] --> Idle Idle --> Submitting : RecordPayment(valid payload) Submitting --> Success : server {payment,receipt} Submitting --> Idle : validation/network error Success --> [*] </pre> <ul> <li><strong>No optimistic</strong> state; values preserved across <code>Failed</code>.</li> <li>Uploads: none (no file uploads in payments).</li> </ul> <h3 id="receiptslistcubit"><a class="header" href="#receiptslistcubit">ReceiptsListCubit</a></h3> <ul> <li>Mirrors PaymentsList (paged <code>{data,total}</code>).</li> </ul> <h2 id="shared-selectors"><a class="header" href="#shared-selectors">Shared selectors</a></h2> <ul> <li><code>InvoiceStatusSelector</code> (from fees) — used on MoneyProgress.</li> <li><code>PermissionsSelector</code> — gates action visibility.</li> </ul> <h2 id="cache"><a class="header" href="#cache">Cache</a></h2> <ul> <li><code>sl:payments:list:{page}</code> 5 min; <code>sl:payments:{id}</code> 1 min; refresh bypasses.</li> <li>Clear payments cache when an invoice reaches PAID (fees event) so dues views refresh.</li> </ul> <h2 id="realtime-1"><a class="header" href="#realtime-1">Realtime</a></h2> <ul> <li>WS channel <code>tenant:{tenantId}</code>: on <code>PaymentProcessed</code> → refresh invoice dues + list (debounced 500 ms).</li> </ul> <h2 id="error-handling-client-table"><a class="header" href="#error-handling-client-table">Error handling (client table)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Code</th><th>Behaviour</th></tr></thead><tbody> <tr><td>400</td><td>map <code>details</code> → fields</td></tr> <tr><td>409</td><td>banner with server message; refresh list/detail</td></tr> <tr><td>404</td><td>empty/not-found state</td></tr> <tr><td>429</td><td>countdown; disable submit</td></tr> <tr><td>5xx/network</td><td>retry UI; form preserved</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-payments"><a class="header" href="#14--qa-checklist-payments">14 — QA Checklist (Payments)</a></h1> <ul> <li><a href="payments/14_QA_Checklist.html#functional">Functional</a></li> <li><a href="payments/14_QA_Checklist.html#concurrency--integrity">Concurrency / integrity</a></li> <li><a href="payments/14_QA_Checklist.html#visual">Visual</a></li> <li><a href="payments/14_QA_Checklist.html#accessibility">Accessibility</a></li> <li><a href="payments/14_QA_Checklist.html#performance">Performance</a></li> <li><a href="payments/14_QA_Checklist.html#offline--network">Offline / network</a></li> <li><a href="payments/14_QA_Checklist.html#security">Security</a></li> <li><a href="payments/14_QA_Checklist.html#localization">Localization</a></li> <li><a href="payments/14_QA_Checklist.html#animation">Animation</a></li> </ul> <blockquote> <p>Module-specific checks layered on <a href="payments/../00-shared/10_QA_Baseline.html">00-shared/10_QA_Baseline.md</a>.</p> </blockquote> <hr /> <h2 id="functional"><a class="header" href="#functional">Functional</a></h2> <ul> <li><input disabled="" type="checkbox"/> Record cash payment → receipt generated, invoice <code>paidAmount</code> updates to PAID/PARTIAL.</li> <li><input disabled="" type="checkbox"/> Payment without invoiceId → succeeds, no invoice link, no crash.</li> <li><input disabled="" type="checkbox"/> Partial then full refund → <code>partially_refunded</code> → <code>refunded</code>; <code>refundedAmount</code> sums.</li> <li><input disabled="" type="checkbox"/> Refund on non-completed → 409 message, no state change.</li> <li><input disabled="" type="checkbox"/> Refund exceeding amount → 409, blocked.</li> <li><input disabled="" type="checkbox"/> Reconcile success/failed/unknown → completed/failed/pending mapping.</li> <li><input disabled="" type="checkbox"/> Reconcile nonexistent ref → 404.</li> <li><input disabled="" type="checkbox"/> Lists paginate with <code>{data,total}</code>; last page shows "end".</li> <li><input disabled="" type="checkbox"/> GET by invoice returns only that invoice's payments (tenant-scoped).</li> <li><input disabled="" type="checkbox"/> Receipt view renders full data incl. fee/currency/paymentMethod/issuedAt.</li> <li><input disabled="" type="checkbox"/> Payment list filters (status/search) work client-side across pages.</li> <li><input disabled="" type="checkbox"/> Receptions: offline publishes payment → UI blocked (never optimistic).</li> </ul> <h2 id="concurrency--integrity"><a class="header" href="#concurrency--integrity">Concurrency / integrity</a></h2> <ul> <li><input disabled="" type="checkbox"/> Double-tap submit produces exactly one payment (flag OQ-3: no idempotency key).</li> <li><input disabled="" type="checkbox"/> Two concurrent refunds don't exceed <code>refundedAmount</code> (server 409 check).</li> <li><input disabled="" type="checkbox"/> <code>sumByInvoice</code> only counts <code>completed</code> payments.</li> <li><input disabled="" type="checkbox"/> Transaction reference + receipt numbers unique (report duplicates = bug).</li> </ul> <h2 id="visual"><a class="header" href="#visual">Visual</a></h2> <ul> <li><input disabled="" type="checkbox"/> Light/dark token-only colors; status colors meet contrast (non-colorblind safe).</li> <li><input disabled="" type="checkbox"/> Money tabular figures; currencies formatted locale.</li> <li><input disabled="" type="checkbox"/> Responsive phone/tablet/desktop reflow; FAB hide/show.</li> </ul> <h2 id="accessibility-1"><a class="header" href="#accessibility-1">Accessibility</a></h2> <ul> <li><input disabled="" type="checkbox"/> TalkBack/VoiceOver reads full money amounts + status labels.</li> <li><input disabled="" type="checkbox"/> Focus order on forms; Enter submits; ESC cancels sheets.</li> <li><input disabled="" type="checkbox"/> Dynamic type 2×: cards/lists reflow, no clipping.</li> <li><input disabled="" type="checkbox"/> Errors announced via live region; focus to first invalid field.</li> </ul> <h2 id="performance"><a class="header" href="#performance">Performance</a></h2> <ul> <li><input disabled="" type="checkbox"/> List first frame ≤ 300 ms cached; ≤ 2 s network.</li> <li><input disabled="" type="checkbox"/> Pagination doesn't drop frames; money rendering stable.</li> <li><input disabled="" type="checkbox"/> No duplicate request spikes on pull-to-refresh.</li> </ul> <h2 id="offline--network"><a class="header" href="#offline--network">Offline / network</a></h2> <ul> <li><input disabled="" type="checkbox"/> Offline banner on all screens; cached lists render; writes blocked with clear copy.</li> <li><input disabled="" type="checkbox"/> Network loss mid-submit shows failure + retry; no duplicate server money movement after retry (verify server dedup — OQ).</li> </ul> <h2 id="security"><a class="header" href="#security">Security</a></h2> <ul> <li><input disabled="" type="checkbox"/> Cross-tenant payment id access → 404 (repository tenant-scope).</li> <li><input disabled="" type="checkbox"/> Refund/reconcile hidden without permission (client) and enforced server <code>(planned)</code>.</li> <li><input disabled="" type="checkbox"/> No payer PII in analytics event payloads.</li> <li><input disabled="" type="checkbox"/> Gateway refs not logged to crash logs.</li> </ul> <h2 id="localization"><a class="header" href="#localization">Localization</a></h2> <ul> <li><input disabled="" type="checkbox"/> En + candidate locales numbers/dates; RTL announcement-ready.</li> <li><input disabled="" type="checkbox"/> Long translated strings don't break receipt card.</li> </ul> <h2 id="animation"><a class="header" href="#animation">Animation</a></h2> <ul> <li><input disabled="" type="checkbox"/> Transitions use tokens; reduced-motion respected; no dropped frames on success transition.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-payments"><a class="header" href="#15--flutter-implementation-guide-payments">15 — Flutter Implementation Guide (Payments)</a></h1> <ul> <li><a href="payments/15_Flutter_Implementation_Guide.html#folder-structure">Folder structure</a></li> <li><a href="payments/15_Flutter_Implementation_Guide.html#key-implementation-notes">Key implementation notes</a> <ul> <li><a href="payments/15_Flutter_Implementation_Guide.html#dtomodel-mapping">DTO/model mapping</a></li> <li><a href="payments/15_Flutter_Implementation_Guide.html#repository">Repository</a></li> <li><a href="payments/15_Flutter_Implementation_Guide.html#cubits">Cubits</a></li> <li><a href="payments/15_Flutter_Implementation_Guide.html#navigation">Navigation</a></li> <li><a href="payments/15_Flutter_Implementation_Guide.html#money-formatting">Money formatting</a></li> <li><a href="payments/15_Flutter_Implementation_Guide.html#localization">Localization</a></li> <li><a href="payments/15_Flutter_Implementation_Guide.html#testing">Testing</a></li> <li><a href="payments/15_Flutter_Implementation_Guide.html#performance">Performance</a></li> <li><a href="payments/15_Flutter_Implementation_Guide.html#gotchas-from-source">Gotchas from source</a></li> </ul> </li> </ul> <blockquote> <p>How Flutter developers build the payments feature. Base architecture: <a href="payments/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a>.</p> </blockquote> <hr /> <h2 id="folder-structure"><a class="header" href="#folder-structure">Folder structure</a></h2> <pre><code>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) </code></pre> <h2 id="key-implementation-notes"><a class="header" href="#key-implementation-notes">Key implementation notes</a></h2> <h3 id="dtomodel-mapping"><a class="header" href="#dtomodel-mapping">DTO/model mapping</a></h3> <ul> <li><code>PaymentDto.fromJson</code> parses snake_case payload; map <code>status</code>/<code>gateway</code> to enums with unknown-value fallback (<code>unknown</code>).</li> <li>Money: keep server <code>double</code> internally BUT format via <code>NumberFormat.currency</code>; never do arithmetic with floats where avoidable — round to 2 decimals on display. Document minor-units migration as future work.</li> <li><code>Payment</code> model exposes: <code>refundableAmount = amount - refundedAmount</code>.</li> </ul> <h3 id="repository"><a class="header" href="#repository">Repository</a></h3> <ul> <li><code>fetchPayments(page, limit)</code> → parse <code>{data,total}</code> (not <code>meta</code> — deviation!).</li> <li><code>processPayment(ProcessPaymentRequest)</code> → returns <code>(Payment, Receipt)</code>.</li> <li><code>refundPayment(paymentId, amount?, reason?)</code>.</li> <li><code>reconcilePayment(ref, status)</code>.</li> <li><code>fetchReceipts(page, limit)</code>, <code>fetchReceipt(id)</code>, <code>fetchByInvoice(invoiceId)</code>.</li> <li>Throws typed <code>ApiException(code, status, details)</code> via <code>AppDio</code> interceptors.</li> </ul> <h3 id="cubits-1"><a class="header" href="#cubits-1">Cubits</a></h3> <ul> <li><code>PaymentsListCubit</code> extends <code>PaginatedListMixin<Payment></code> (client-side filter keeps <code>List<Payment> filtered</code> derived in UI or via <code>emitter</code>).</li> <li><code>NewPaymentCubit</code>: <code>Idle/Submitting/Success/Failed</code>; on Success emit <code>PaymentCreated</code> to refresh list + invoice.</li> <li>WS: <code>WsClient.subscribe('payment.processed')</code> → debounce reload.</li> </ul> <h3 id="navigation"><a class="header" href="#navigation">Navigation</a></h3> <ul> <li>Routes: <code>/payments</code>, <code>/payments/new</code>, <code>/payments/:id</code>, <code>/payments/receipts</code>, <code>/payments/receipts/:id</code>.</li> <li>Deep links (forward-looking) mapped in <code>AppRouter</code>.</li> <li>Guard: <code>permissionGuard('payments.read')</code> on routes; action visibility via permissions selector.</li> </ul> <h3 id="money-formatting"><a class="header" href="#money-formatting">Money formatting</a></h3> <ul> <li>Extension <code>double.toMoney(currency, locale)</code> → <code>NumberFormat.currency(locale: locale, name: currencyCode)</code>; tabular figures.</li> </ul> <h3 id="localization-1"><a class="header" href="#localization-1">Localization</a></h3> <ul> <li>Keys: <code>payments.*</code> (list.title, record.title, receipt.title, errors.<em>, status.</em>).</li> </ul> <h3 id="testing"><a class="header" href="#testing">Testing</a></h3> <ul> <li><strong>Unit:</strong> <code>PaymentMapperTest</code>, <code>RefundRulesTest</code> (max/partial/full), <code>CubitTest</code> (list pagination + filters, new-payment state machine, 409 mapping).</li> <li><strong>Widget:</strong> S1 states (loading/empty/error/data), S3 validation + success view, S4 sheet guards.</li> <li><strong>Golden:</strong> PaymentRow, StatusBadge (7 states), ReceiptViewCard — light/dark, 3 sizes.</li> <li><strong>Integration:</strong> record cash payment against prefilled invoice → receipt visible → invoice status PAID.</li> </ul> <h3 id="performance-1"><a class="header" href="#performance-1">Performance</a></h3> <ul> <li><code>ListView.builder</code>; rows <code>const</code>-friendly; <code>RepaintBoundary</code> on receipt card + charts; pagination via footer button not scroll listener (server contract).</li> <li>Avoid rebuilding filter chips per keystroke (debounce 300 ms).</li> </ul> <h3 id="gotchas-from-source"><a class="header" href="#gotchas-from-source">Gotchas from source</a></h3> <ul> <li><code>GET /payments</code> and <code>/payments/receipts</code> return <code>{data, total}</code> — client pagination derived manually; do not assume <code>meta</code>.</li> <li><code>PaymentProcessed</code> is WS-broadcast but not BullMQ-routed — realtime refresh is the only push signal; receipt email (<code>PaymentCompleted</code>→emails) is a dead map entry.</li> <li>Receipt numbers derive from an in-process counter — do not rely on monotonicity across instances.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-rbac-module"><a class="header" href="#01--product-overview-rbac-module">01 — Product Overview (RBAC Module)</a></h1> <ul> <li><a href="rbac/01_Product_Overview.html#1-purpose">1. Purpose</a></li> <li><a href="rbac/01_Product_Overview.html#2-the-guard-pipeline-derived-exact">2. The guard pipeline (derived, exact)</a></li> <li><a href="rbac/01_Product_Overview.html#3-domain-model-derived">3. Domain model (derived)</a></li> <li><a href="rbac/01_Product_Overview.html#4-default-roles-seeded-immutable">4. Default roles (seeded, immutable)</a></li> <li><a href="rbac/01_Product_Overview.html#5-permission-catalog-complete-quoted-from-source">5. Permission catalog (complete, quoted from source)</a></li> <li><a href="rbac/01_Product_Overview.html#6-business-goals">6. Business goals</a></li> <li><a href="rbac/01_Product_Overview.html#7-edge-cases-derived-from-source">7. Edge cases (derived from source)</a></li> <li><a href="rbac/01_Product_Overview.html#8-success-metrics-proposed">8. Success metrics (proposed)</a></li> <li><a href="rbac/01_Product_Overview.html#9-prd-conflict-flagged">9. PRD conflict (flagged)</a></li> <li><a href="rbac/01_Product_Overview.html#10-module-assumptions">10. Module assumptions</a></li> <li><a href="rbac/01_Product_Overview.html#11-open-questions-module-global-ledger-in-00-shared12">11. Open questions (module; global ledger in <code>00-shared/12</code>)</a></li> <li><a href="rbac/01_Product_Overview.html#12-glossary-module">12. Glossary (module)</a></li> </ul> <blockquote> <p>StudyLyon — multi-tenant ERP / School Management API. This package designs the <strong>RBAC module client</strong> (Flutter, forward-looking spec) against the implemented NestJS backend. Every endpoint, DTO field, schema, permission name, and guard behaviour is derived from <code>src/modules/rbac/**</code>, <code>src/modules/auth/**</code>, <code>src/modules/organizations/**</code>, <code>src/common/**</code>, and <code>src/app/app.module.ts</code>. No feature is invented; gaps are flagged in the <strong>Assumptions & Open Questions</strong> section (and mirrored in <a href="rbac/../00-shared/12_Assumptions_&_Open_Questions.html">00-shared/12</a>).</p> </blockquote> <hr /> <h2 id="1-purpose-18"><a class="header" href="#1-purpose-18">1. Purpose</a></h2> <p>RBAC (role-based access control) is the authorization spine of StudyLyon. Auth answers <em>who you are</em>; RBAC answers <em>what you may do, inside which tenant</em>. The module owns:</p> <div class="table-wrapper"><table><thead><tr><th>Responsibility</th><th>Source</th></tr></thead><tbody> <tr><td>Catalog of all 95 permission names (single source of truth)</td><td><code>permissions.constants.ts:1-97</code> <code>ALL_PERMISSIONS</code></td></tr> <tr><td>Role definitions (7 seeded system roles + unlimited tenant custom roles)</td><td><code>role.schema.ts:8-65</code> <code>DEFAULT_ROLES</code>; <code>rbac.service.ts:83</code> <code>createRole</code></td></tr> <tr><td>Role → permission resolution at runtime (per-tenant)</td><td><code>rbac.service.ts:59-65</code> <code>findBySlugsUnscoped</code> + flatMap</td></tr> <tr><td>Organization membership (user ↔ org ↔ roles)</td><td><code>organization-member.schema.ts:13-44</code>; <code>rbac.service.ts:113-140</code></td></tr> <tr><td>Permission enforcement on <strong>every</strong> endpoint (global guard)</td><td><code>app.module.ts:129-133</code>; <code>rbac.guard.ts:20-52</code></td></tr> <tr><td>Fast permission lookup via Redis (5-minute TTL)</td><td><code>rbac.service.ts:48,68</code></td></tr> <tr><td>Default-role seeding at tenant bootstrap</td><td><code>auth.service.ts:93</code>; <code>organizations.service.ts:58</code></td></tr> </tbody></table> </div> <h2 id="2-the-guard-pipeline-derived-exact"><a class="header" href="#2-the-guard-pipeline-derived-exact">2. The guard pipeline (derived, exact)</a></h2> <p>Every HTTP request passes the three <strong>global</strong> <code>APP_GUARD</code>s registered in <code>app.module.ts:129-133</code>, in this order:</p> <pre><code>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 </code></pre> <ul> <li><code>JwtAuthGuard</code> (<code>jwt-auth.guard.ts:40-55</code>) verifies the token, sets <code>req.user = { id, tenantId, roles }</code>, and populates the <code>TenantContextService</code> (<code>isPlatformAdmin</code> = <code>roles.includes('platform_admin')</code>, <code>jwt-auth.guard.ts:54</code>).</li> <li><code>RbacGuard</code> (<code>rbac.guard.ts:20-52</code>) reads <code>@Roles()</code> / <code>@Permissions()</code> metadata: <ul> <li>no metadata → <strong>allow</strong> (<code>rbac.guard.ts:29</code>);</li> <li>roles → pass if <strong>any</strong> required role is in the JWT claim (<code>rbac.guard.ts:39-41</code>);</li> <li>permissions → pass only if <strong>every</strong> required permission is in the resolved set (<code>rbac.guard.ts:43-49</code>, AND semantics); resolution hits the Redis cache <code>sl:{tenantId}:perm:{userId}</code> with 300 s TTL, then falls back to the DB (<code>rbac.service.ts:44-73</code>);</li> <li>failure → <code>ForbiddenException</code> → 403 <code>PERMISSION_DENIED</code> (<code>http-exception.filter.ts:30</code>).</li> </ul> </li> </ul> <p>The RBAC controller itself re-declares <code>@UseGuards(JwtAuthGuard, RbacGuard)</code> and is class-gated with <code>@Roles('org_admin')</code> (<code>rbac.controller.ts:21-22</code>) — meaning today <strong>only users holding the <code>org_admin</code> role can manage roles/members</strong>, regardless of the <code>rbac.*</code> permissions that exist in the catalog. See OQ-R1.</p> <h2 id="3-domain-model-derived"><a class="header" href="#3-domain-model-derived">3. Domain model (derived)</a></h2> <pre><code>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] </code></pre> <ul> <li><strong>Every business doc carries <code>tenantId</code></strong> (<code>base.schema.ts:11</code>); repositories inject it structurally (<code>base.repository.ts:20-30</code>) — cross-tenant reads are impossible at the repo layer.</li> <li>Member statuses exist (<code>invited | active | suspended</code>, <code>organization-member.schema.ts:7-11</code>) but the service only ever creates <code>active</code> (<code>rbac.service.ts:122</code>); no endpoint changes status (OQ-R3).</li> <li>Roles are soft-deleted (<code>rbac.service.ts:106</code>); members are soft-deleted (<code>rbac.service.ts:139</code>).</li> </ul> <h2 id="4-default-roles-seeded-immutable"><a class="header" href="#4-default-roles-seeded-immutable">4. Default roles (seeded, immutable)</a></h2> <p><code>role.schema.ts:8-65</code> — seeded by <code>seedDefaults</code> at registration (<code>auth.service.ts:93</code>) and at organization creation (<code>organizations.service.ts:58</code>, note: seeded under <code>org.slug</code> as tenantId, OQ-R6). All seven are <code>isSystem: true</code> — the API refuses update or delete (<code>rbac.service.ts:94-95,104-105</code>).</p> <div class="table-wrapper"><table><thead><tr><th>Name</th><th>Slug</th><th>Priority</th><th>Permissions</th><th>Locked</th></tr></thead><tbody> <tr><td>Platform Admin</td><td><code>platform_admin</code></td><td>1000</td><td><code>[]</code> (cross-tenant; bypasses tenant scope via <code>base.repository.ts:21</code>)</td><td>system</td></tr> <tr><td>Organization Admin</td><td><code>org_admin</code></td><td>100</td><td><strong>all 95</strong></td><td>system</td></tr> <tr><td>Teacher</td><td><code>teacher</code></td><td>50</td><td><code>student.read, attendance.mark, attendance.edit</code></td><td>system</td></tr> <tr><td>Staff</td><td><code>staff</code></td><td>40</td><td><code>student.read</code></td><td>system</td></tr> <tr><td>Accountant</td><td><code>accountant</code></td><td>30</td><td><code>fees.collect, student.read</code></td><td>system</td></tr> <tr><td>Parent</td><td><code>parent</code></td><td>20</td><td><code>student.read</code></td><td>system</td></tr> <tr><td>Student</td><td><code>student</code></td><td>10</td><td><code>[]</code></td><td>system</td></tr> </tbody></table> </div> <p>Roles list is server-sorted by <code>priority: -1</code> (<code>rbac.service.ts:76</code>) — priority also acts as a rough "power" ordering the UI uses for display.</p> <h2 id="5-permission-catalog-complete-quoted-from-source"><a class="header" href="#5-permission-catalog-complete-quoted-from-source">5. Permission catalog (complete, quoted from source)</a></h2> <p><code>ALL_PERMISSIONS</code>, <code>permissions.constants.ts:1-97</code> — <strong>the only source of permission names</strong>. 95 permissions. The client mirror must be generated from this list (see <code>13_State_Management.md</code>); the full UI matrix is in <code>06_Screen_Specifications.md §3</code>.</p> <pre><code>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 </code></pre> <p><strong>Forward-looking (planned, not in source):</strong> <code>docs/IMPLEMENTATION_PLAN.md:751-761</code> adds coaching permissions <code>batch.manage, session.manage, test_series.manage, dpp.manage, study_material.manage, analytics.view, question_bank.manage</code>, and <code>:713-749</code> adds roles <code>batch_coordinator, test_coordinator, content_manager, coaching_student</code>. None exist in <code>permissions.constants.ts</code> today — the client must <strong>not</strong> render them until the backend ships them; the matrix renders only the 95 above.</p> <h2 id="6-business-goals"><a class="header" href="#6-business-goals">6. Business goals</a></h2> <div class="table-wrapper"><table><thead><tr><th>Goal</th><th>Measure</th></tr></thead><tbody> <tr><td>Least-privilege out of the box</td><td>7 seeded roles cover school staff; custom roles are tenant-scoped</td></tr> <tr><td>Enforcement everywhere</td><td>Global <code>RbacGuard</code> on all routes; <code>@Roles</code>/<code>@Permissions</code> decorators (<code>roles.decorator.ts:3</code>, <code>permissions.decorator.ts:3</code>)</td></tr> <tr><td>Fast enforcement</td><td>Permission resolution cached 300 s in Redis (<code>rbac.service.ts:68</code>); JWT roles are claim-carried (no DB hit on role checks)</td></tr> <tr><td>Tenant isolation</td><td><code>tenantId</code> injected in every query; unique <code>{tenantId, slug}</code> / <code>{tenantId, userId}</code> (<code>role.schema.ts:89</code>, <code>organization-member.schema.ts:48</code>)</td></tr> <tr><td>Auditability</td><td>RBAC writes surface in <code>audit-logs</code> (Phase-5 permission audit <code>(planned)</code>, <code>docs/IMPLEMENTATION_PLAN.md:241</code>; <code>audit.read</code> perm exists)</td></tr> <tr><td>No accidental lockout of admins</td><td>system roles immutable; <code>org_admin</code> can never be deleted/edited</td></tr> </tbody></table> </div> <h2 id="7-edge-cases-derived-from-source"><a class="header" href="#7-edge-cases-derived-from-source">7. Edge cases (derived from source)</a></h2> <ul> <li>Custom role created <strong>without</strong> <code>isSystem:false</code> becomes system-locked: schema default is <code>isSystem: true</code> (<code>role.schema.ts:78-79</code>) and <code>updateRole</code>/<code>deleteRole</code> refuse system roles (<code>rbac.service.ts:94-95,104-105</code>). The client must always send <code>isSystem:false</code> for custom roles (OQ-R2).</li> <li>Duplicate role slug → 409 <code>ConflictException</code> (<code>rbac.service.ts:86-87</code>; unique index <code>role.schema.ts:89</code>).</li> <li><code>PATCH /rbac/members/:id</code> takes <code>AddMemberDto</code> (requires <code>userId</code> <strong>and</strong> <code>roles</code>) but the service ignores <code>userId</code> (<code>rbac.controller.ts:71-72</code>; <code>rbac.service.ts:129-136</code>).</li> <li><code>addMember</code> for an already-member user hits the unique index → <strong>Mongo duplicate-key error → 500</strong> (no 409 mapping in <code>http-exception.filter.ts:47-55</code>); client treats as conflict (OQ-R5).</li> <li><code>removeMember</code> on unknown id is silent success (no 404; <code>rbac.service.ts:138-140</code>).</li> <li>Permission set is not validated against <code>ALL_PERMISSIONS</code> server-side (<code>create-role.dto.ts:34-37</code> <code>@IsArray</code> only) — the client matrix is the guard rail.</li> <li>Roles in the JWT are minted <strong>at login</strong> (<code>auth.service.ts:145-154</code>) and carried unchanged by refresh (<code>auth.service.ts:192-196</code>) — role changes don't take effect until re-login; permission changes take effect ≤ 300 s via cache expiry (OQ-R4).</li> <li><code>platform_admin</code> has no permissions and is not <code>org_admin</code> — cannot access <code>/rbac/*</code> today; but it bypasses tenant scoping in repositories (<code>base.repository.ts:21</code>).</li> </ul> <h2 id="8-success-metrics-proposed"><a class="header" href="#8-success-metrics-proposed">8. Success metrics (proposed)</a></h2> <ul> <li>Role creation → first member assigned in < 2 min (admin goal).</li> <li>Permission matrix save round-trip < 1.5 s p95 (single PATCH, no per-cell writes).</li> <li>Zero reported cross-tenant role/member reads (QA 14).</li> <li>100% of endpoints covered by <code>@Roles</code>/<code>@Permissions</code> <strong>after</strong> Phase-5 audit (<code>(planned)</code>, <code>docs/IMPLEMENTATION_PLAN.md:241</code>) — today only webhooks/files/scheduler/ search controllers use <code>@Permissions</code> (see <code>12_API_Mapping.md §4</code>).</li> </ul> <h2 id="9-prd-conflict-flagged"><a class="header" href="#9-prd-conflict-flagged">9. PRD conflict (flagged)</a></h2> <ul> <li><strong>PRD FR-AUTH-07</strong> (<code>PRODUCT_REQUIREMENTS_DOCUMENT.md:75</code>): "RBAC enforces role + permission checks on <strong>every</strong> endpoint." Today the global guard runs everywhere, but <strong>only four controllers carry <code>@Permissions</code></strong> (webhooks, files, scheduler, search; <code>webhooks.controller.ts:22-86</code>, <code>files.controller.ts:30-67</code>, <code>scheduler.controller.ts:24-38</code>, <code>search.controller.ts:16</code>); every other controller (students, fees, audit, …) is either ungated or role-gated only. The Phase-5 "Permissions audit (all endpoints)" (<code>docs/IMPLEMENTATION_PLAN.md:241</code>) is <code>(planned)</code>.</li> <li><strong>PRD §8</strong> (<code>PRODUCT_REQUIREMENTS_DOCUMENT.md:144</code>): native mobile apps are out of Phase-1 scope ("web-first"). Per global ledger <code>00-shared/12</code> A1, this package specs the full Flutter client anyway (owner decision, documented, not a contradiction).</li> </ul> <h2 id="10-module-assumptions"><a class="header" href="#10-module-assumptions">10. Module assumptions</a></h2> <ul> <li>Client is <strong>forward-looking</strong>: backend is complete and authoritative; this package is the UI-side spec.</li> <li>The <strong>permission matrix is rendered from the server</strong> (<code>GET /rbac/permissions</code>) — the client never hardcodes the 95 names as a second source of truth; the quoted list above is the current snapshot.</li> <li>Membership ≠ account: adding a member requires an existing <code>users</code> doc (<code>add-member.dto.ts:6</code> <code>@IsMongoId</code>); the members list does <strong>not</strong> join user profiles (<code>rbac.service.ts:109-111</code>) — display names require a parallel <code>user.read</code> fetch or the <code>metadata</code> field (OQ-R7).</li> </ul> <h2 id="11-open-questions-module-global-ledger-in-00-shared12"><a class="header" href="#11-open-questions-module-global-ledger-in-00-shared12">11. Open questions (module; global ledger in <code>00-shared/12</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Item</th><th>Impact</th></tr></thead><tbody> <tr><td>OQ-R1</td><td><code>/rbac/*</code> is class-gated <code>@Roles('org_admin')</code> (<code>rbac.controller.ts:21</code>) even though <code>rbac.role.*</code> / <code>rbac.member.*</code> permissions exist. Move to <code>@Permissions('rbac.role.read')</code> style so custom roles can manage RBAC?</td><td>Role-manager persona (02), matrix gating</td></tr> <tr><td>OQ-R2</td><td><code>isSystem</code> defaults <code>true</code> on create (<code>role.schema.ts:78-79</code>) — silent lock-in of custom roles. Server default → <code>false</code> for non-seeded creates?</td><td>Create-role form, QA privilege-escalation tests</td></tr> <tr><td>OQ-R3</td><td><code>MemberStatus.INVITED/SUSPENDED</code> defined but never produced (<code>organization-member.schema.ts:7-11</code>; service hardcodes ACTIVE <code>rbac.service.ts:122</code>). Invite/suspend flows <code>(planned)</code>?</td><td>Member lifecycle UX</td></tr> <tr><td>OQ-R4</td><td>JWT <code>roles</code> claim frozen at login; refresh copies old claim (<code>auth.service.ts:192-196</code>). Role change forces re-login. Server-side claim refresh?</td><td>13 route rebuild, session UX</td></tr> <tr><td>OQ-R5</td><td>Duplicate member → 500 (Mongo E11000) instead of 409 <code>DUPLICATE_RESOURCE</code>. Map in filter?</td><td>Add-member error UX</td></tr> <tr><td>OQ-R6</td><td><code>organizations.service.ts:58</code> seeds defaults with <code>org.slug</code> as tenantId (vs <code>auth.service.ts:93</code> using user.tenantId) — tenantId semantics inconsistency</td><td>Tenant-scope tests</td></tr> <tr><td>OQ-R7</td><td>Members list has no user profile join; avatar/name/email require <code>user.read</code> calls. Join endpoint or client-side merge <code>(planned)</code>?</td><td>Members list spec</td></tr> </tbody></table> </div> <h2 id="12-glossary-module"><a class="header" href="#12-glossary-module">12. Glossary (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Term</th><th>Meaning</th></tr></thead><tbody> <tr><td>Role</td><td>Named permission set, tenant-scoped; <code>slug</code> unique per tenant; system roles immutable</td></tr> <tr><td>Permission</td><td><code>domain.action</code> string (95 total); <code>domain</code> ≈ owning module</td></tr> <tr><td>Membership</td><td><code>organization_members</code> doc tying <code>userId</code> to roles within a tenant</td></tr> <tr><td>Priority</td><td>Int; higher = listed first and "more powerful" by convention</td></tr> <tr><td>Permission cache</td><td>Redis <code>sl:{tenantId}:perm:{userId}</code> TTL 300 s (<code>rbac.service.ts:48,68</code>)</td></tr> <tr><td>403 <code>PERMISSION_DENIED</code></td><td>Envelope code for role/permission refusal (<code>http-exception.filter.ts:30</code>)</td></tr> <tr><td>System role</td><td><code>isSystem: true</code>; cannot be edited/deleted (<code>rbac.service.ts:94-95,104-105</code>)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-rbac-module"><a class="header" href="#02--user-personas-rbac-module">02 — User Personas (RBAC Module)</a></h1> <ul> <li><a href="rbac/02_User_Personas.html#1-priya--organization-admin-primary-persona">1. Priya — Organization Admin (primary persona)</a></li> <li><a href="rbac/02_User_Personas.html#2-arjun--role-manager-forward-looking-oq-r1">2. Arjun — Role Manager (forward-looking, OQ-R1)</a></li> <li><a href="rbac/02_User_Personas.html#3-samira--member-viewer">3. Samira — Member Viewer</a></li> <li><a href="rbac/02_User_Personas.html#4-vikram--platform-admin-sysadmin">4. Vikram — Platform Admin (sysadmin)</a></li> <li><a href="rbac/02_User_Personas.html#5-david--guarded-end-user-teacher">5. David — Guarded End-User (teacher)</a></li> <li><a href="rbac/02_User_Personas.html#persona--screen-matrix">Persona → screen matrix</a></li> </ul> <blockquote> <p>Five personas cover the RBAC surface. Derived from the seeded roles (<code>role.schema.ts:8-65</code>), the endpoints (<code>rbac.controller.ts</code>), and the guard pipeline (<code>rbac.guard.ts</code>). The "Role manager" and "Member viewer" personas are <strong>forward-looking</strong>: the server currently gates <code>/rbac/*</code> behind <code>@Roles('org_admin')</code> (<code>rbac.controller.ts:21</code>), so their ability to act depends on OQ-R1.</p> </blockquote> <hr /> <h2 id="1-priya--organization-admin-primary-persona"><a class="header" href="#1-priya--organization-admin-primary-persona">1. Priya — Organization Admin (primary persona)</a></h2> <blockquote> <p>"I need to control who can touch what, and I don't have time for IT tickets."</p> </blockquote> <ul> <li><strong>Slug/roles:</strong> <code>org_admin</code> — all 95 permissions (<code>role.schema.ts:17-24</code>).</li> <li><strong>Context:</strong> Principal/admin of a 400-student school. Created the org at registration; the system auto-added her as <code>org_admin</code> member (<code>auth.service.ts:84-99</code>).</li> <li><strong>Goals</strong> <ul> <li>Seed the org quickly: teachers, accountant, librarian, transport manager each get a fitting role without exposing admin powers.</li> <li>Create a custom role ("Fee Counter Clerk") with exactly <code>fees.collect, student.read, receipts.read</code> — no more, no less.</li> <li>Add members, fix a wrong role, and remove a leaver in under a minute.</li> <li>Never accidentally lock herself out; never let a staff member see salary/finance data.</li> </ul> </li> <li><strong>Frustrations</strong> <ul> <li>A custom role she creates becomes un-editable (system-lock bug, <code>role.schema.ts:78-79</code> default; see OQ-R2) — the UI must force <code>isSystem:false</code> and warn.</li> <li>Members list shows raw member docs without names (<code>rbac.service.ts:109-111</code>) — the UI must merge <code>user.read</code> data (OQ-R7).</li> <li>She can't tell a leaver's permissions at a glance; wants an audit trail.</li> </ul> </li> <li><strong>Needs:</strong> 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 <code>org_admin</code> — you will lose access to this screen").</li> </ul> <h2 id="2-arjun--role-manager-forward-looking-oq-r1"><a class="header" href="#2-arjun--role-manager-forward-looking-oq-r1">2. Arjun — Role Manager (forward-looking, OQ-R1)</a></h2> <blockquote> <p>"I audit access quarterly. I want to see who holds what, and why."</p> </blockquote> <ul> <li><strong>Roles (proposed):</strong> a custom role holding <code>rbac.role.read, rbac.role.update, rbac.member.read, rbac.member.update, user.read, audit.read</code> — <strong>not</strong> <code>org_admin</code>.</li> <li><strong>Context:</strong> Operations head trusted with access governance but deliberately not with org billing/settings.</li> <li><strong>Goals:</strong> browse roles, inspect a role's permission set, adjust a role's perms, reassign a member's roles, review the permission-audit view (<code>GET /audit-logs</code>, <code>audit.controller.ts:17</code>).</li> <li><strong>Frustrations:</strong> today the server rejects him with 403 — <code>@Roles('org_admin')</code> (<code>rbac.controller.ts:21</code>) ignores his <code>rbac.*</code> permissions. Screen copy must explain "managed by Organization Admin" until OQ-R1 ships.</li> <li><strong>Needs:</strong> read-only-friendly layouts (view mode for the matrix), permission diff preview before saving, audit filter by actor/action.</li> </ul> <h2 id="3-samira--member-viewer"><a class="header" href="#3-samira--member-viewer">3. Samira — Member Viewer</a></h2> <blockquote> <p>"I just need to know who's on the transport desk this week."</p> </blockquote> <ul> <li><strong>Roles:</strong> custom role with <code>rbac.member.read, user.read</code> (or a staff member).</li> <li><strong>Context:</strong> receptionist cross-checking who can issue books.</li> <li><strong>Goals:</strong> open the members list, see names + role chips, search a member.</li> <li><strong>Frustrations:</strong> zero write affordances must ever render for her; a stray "Add member" button that 403s on tap is a trust-breaker.</li> <li><strong>Needs:</strong> view-only matrix/member list; no FAB; 403 states never reached via UI.</li> </ul> <h2 id="4-vikram--platform-admin-sysadmin"><a class="header" href="#4-vikram--platform-admin-sysadmin">4. Vikram — Platform Admin (sysadmin)</a></h2> <blockquote> <p>"One product, thousands of tenants. I watch from above, not inside."</p> </blockquote> <ul> <li><strong>Slug:</strong> <code>platform_admin</code> — <code>[]</code> permissions (<code>role.schema.ts:8-16</code>), cross-tenant repository bypass (<code>base.repository.ts:21</code>).</li> <li><strong>Context:</strong> platform operator. Tenant-scoped RBAC is none of his daily business; his concern is that tenant A's admin can never touch tenant B.</li> <li><strong>Goals:</strong> verify tenant isolation (QA 14), support escalation reads, never appear in tenant RBAC screens as a normal actor.</li> <li><strong>Frustrations:</strong> <code>platform_admin</code> is <strong>not</strong> <code>org_admin</code> — the <code>/rbac/*</code> controller would 403 him (<code>rbac.controller.ts:21</code>); repository scoping lets him read across tenants (<code>base.repository.ts:22</code>). 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.</li> </ul> <h2 id="5-david--guarded-end-user-teacher"><a class="header" href="#5-david--guarded-end-user-teacher">5. David — Guarded End-User (teacher)</a></h2> <blockquote> <p>"I hit a locked door once; I never want to hit it again."</p> </blockquote> <ul> <li><strong>Slug:</strong> <code>teacher</code> — <code>student.read, attendance.mark, attendance.edit</code> (<code>role.schema.ts:25-32</code>).</li> <li><strong>Context:</strong> marks attendance daily, sees student rosters. RBAC is invisible to him — until it isn't.</li> <li><strong>Goals:</strong> 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.</li> <li><strong>Frustrations:</strong> 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, <code>auth.service.ts:145-154</code>; OQ-R4).</li> <li><strong>Needs:</strong> 403 screen with cause + contact copy; route tree rebuilt on permission change (<a href="rbac/05_Screen_Inventory.html">05_Global_Information_Architecture.md §9</a>); offline-tolerant lists.</li> </ul> <hr /> <h2 id="persona--screen-matrix"><a class="header" href="#persona--screen-matrix">Persona → screen matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Priya (org_admin)</th><th>Arjun (role mgr, planned)</th><th>Samira (viewer)</th><th>Vikram (platform)</th><th>David (teacher)</th></tr></thead><tbody> <tr><td>Roles list</td><td>full</td><td>read/edit (planned)</td><td>—</td><td>—</td><td>—</td></tr> <tr><td>Role detail</td><td>full</td><td>read</td><td>—</td><td>—</td><td>—</td></tr> <tr><td>Role editor + matrix</td><td>full</td><td>edit (planned)</td><td>—</td><td>—</td><td>—</td></tr> <tr><td>Members list</td><td>full</td><td>read/edit (planned)</td><td>read (if granted)</td><td>tenant-picker (planned)</td><td>—</td></tr> <tr><td>Add member</td><td>full</td><td>edit (planned)</td><td>—</td><td>—</td><td>—</td></tr> <tr><td>Permission audit</td><td>full (<code>audit.read</code>)</td><td>read</td><td>—</td><td>—</td><td>—</td></tr> <tr><td>403 screen</td><td>never</td><td>until OQ-R1</td><td>never via UI</td><td>never via UI</td><td>when role revoked mid-session</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journeys-rbac-module"><a class="header" href="#03--user-journeys-rbac-module">03 — User Journeys (RBAC Module)</a></h1> <ul> <li><a href="rbac/03_User_Journey.html#j1--create-a-custom-role-priya">J1 — Create a custom role (Priya)</a></li> <li><a href="rbac/03_User_Journey.html#j2--assign-permissions-via-the-matrix-priya">J2 — Assign permissions via the matrix (Priya)</a></li> <li><a href="rbac/03_User_Journey.html#j3--add-a-member-priya">J3 — Add a member (Priya)</a></li> <li><a href="rbac/03_User_Journey.html#j4--custom-role-for-accountanthr-then-assign-priya-j1j3-combined">J4 — Custom role for Accountant/HR, then assign (Priya, J1+J3 combined)</a></li> <li><a href="rbac/03_User_Journey.html#j5--guard-denied-flow-david-teacher-also-planmd17-15">J5 — Guard-denied flow (David, teacher; also PLAN.md:17 1.5)</a></li> </ul> <blockquote> <p>Five end-to-end journeys with Mermaid diagrams. Every step maps to a real endpoint (<code>rbac.controller.ts</code>), a service rule (<code>rbac.service.ts</code>), or a guard behaviour (<code>rbac.guard.ts</code>). Personas per <a href="rbac/02_User_Personas.html">02</a>.</p> </blockquote> <hr /> <h2 id="j1--create-a-custom-role-priya"><a class="header" href="#j1--create-a-custom-role-priya">J1 — Create a custom role (Priya)</a></h2> <p><strong>Entry:</strong> Roles list → FAB "Create role" → Role editor → Save.</p> <pre class="mermaid">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 </pre> <p><strong>Rules exercised:</strong> <code>createRole</code> conflict check (<code>rbac.service.ts:85-87</code>); <code>@Roles('org_admin')</code> at class level (<code>rbac.controller.ts:21</code>); create is tenant-scoped via <code>createWithTenant</code> (<code>role.repository.ts:17-22</code>).</p> <h2 id="j2--assign-permissions-via-the-matrix-priya"><a class="header" href="#j2--assign-permissions-via-the-matrix-priya">J2 — Assign permissions via the matrix (Priya)</a></h2> <p><strong>Entry:</strong> Role detail → Edit permissions → matrix → Save. Matrix = hero component (<code>06 §3</code>).</p> <pre class="mermaid">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"] </pre> <p><strong>Rules exercised:</strong> update refused for <code>isSystem</code> (<code>rbac.service.ts:94-95</code>); permission list sent wholesale (no per-cell endpoint — <code>updateRole</code> is full-doc <code>$set</code>, <code>rbac.service.ts:96</code>); effective permissions change for holders ≤ 300 s after cache TTL (<code>rbac.service.ts:68</code>) but JWT roles unchanged until re-login (<code>auth.service.ts:145-154</code>).</p> <h2 id="j3--add-a-member-priya"><a class="header" href="#j3--add-a-member-priya">J3 — Add a member (Priya)</a></h2> <p><strong>Entry:</strong> Members list → FAB "Add member" → pick user + roles → Add.</p> <pre class="mermaid">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 </pre> <h2 id="j4--custom-role-for-accountanthr-then-assign-priya-j1j3-combined"><a class="header" href="#j4--custom-role-for-accountanthr-then-assign-priya-j1j3-combined">J4 — Custom role for Accountant/HR, then assign (Priya, J1+J3 combined)</a></h2> <p><strong>Entry:</strong> Roles → create "HR Officer" → Members → assign.</p> <pre class="mermaid">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)] </pre> <p><strong>Note (derived):</strong> priority is display-ordering only (<code>rbac.service.ts:76</code>); it does not influence authorization — authorization comes from JWT roles + permission resolution.</p> <h2 id="j5--guard-denied-flow-david-teacher-also-planmd17-15"><a class="header" href="#j5--guard-denied-flow-david-teacher-also-planmd17-15">J5 — Guard-denied flow (David, teacher; also PLAN.md:17 1.5)</a></h2> <p><strong>Entry:</strong> Teacher taps an admin-only action (or a stale route after his role was edited mid-session).</p> <pre class="mermaid">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 </pre> <p><strong>Covered by QA plan:</strong> <code>PLAN.md:17</code> (1.5 RBAC admin hits teacher-only endpoint → 403), <code>PLAN.md:107-110</code> (10.2 cross-tenant JWT → 403), <code>PLAN.md:146-148</code> (14 soft-delete).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-rbac-module"><a class="header" href="#04--information-architecture-rbac-module">04 — Information Architecture (RBAC Module)</a></h1> <ul> <li><a href="rbac/04_Information_Architecture.html#1-placement-in-the-app-shell">1. Placement in the app shell</a></li> <li><a href="rbac/04_Information_Architecture.html#2-information-hierarchy">2. Information hierarchy</a></li> <li><a href="rbac/04_Information_Architecture.html#3-screen-to-route-table">3. Screen-to-route table</a></li> <li><a href="rbac/04_Information_Architecture.html#4-navigation-rules">4. Navigation rules</a></li> <li><a href="rbac/04_Information_Architecture.html#5-content-model-what-each-screen-shows">5. Content model (what each screen shows)</a></li> <li><a href="rbac/04_Information_Architecture.html#6-related-modules-cross-references">6. Related modules (cross-references)</a></li> </ul> <blockquote> <p>Where RBAC lives in the app shell (<a href="rbac/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a>). RBAC is an <strong>admin-governance</strong> surface: it sits under the Settings umbrella, gated by the <code>rbac.*</code> permission family, and is invisible to non-admin personas.</p> </blockquote> <hr /> <h2 id="1-placement-in-the-app-shell"><a class="header" href="#1-placement-in-the-app-shell">1. Placement in the app shell</a></h2> <pre><code>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) </code></pre> <ul> <li><strong>Entry point:</strong> Settings → "Roles & Permissions" (routes <code>/settings/roles</code>, <code>/settings/members</code>, <code>/settings/access-audit</code>).</li> <li><strong>Why Settings, not a top-level tab:</strong> the global nav (05 §2) reserves top-level destinations for daily-use modules; RBAC is occasional governance. Matches <code>05 §2</code> "Users & Roles | <code>/users</code>, <code>/roles</code> | admin".</li> </ul> <h2 id="2-information-hierarchy-1"><a class="header" href="#2-information-hierarchy-1">2. Information hierarchy</a></h2> <pre><code>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 </code></pre> <p><strong>Parent-child rule:</strong> Role detail is reachable from Roles list only; member rows link forward to nothing (no per-member detail screen — the API exposes none; <code>PATCH /rbac/ members/:id</code> is the only member write, <code>rbac.controller.ts:69-73</code>).</p> <h2 id="3-screen-to-route-table"><a class="header" href="#3-screen-to-route-table">3. Screen-to-route table</a></h2> <div class="table-wrapper"><table><thead><tr><th>Route</th><th>Screen</th><th>Guard (client)</th><th>Server gate</th></tr></thead><tbody> <tr><td><code>/settings/roles</code></td><td>Roles list</td><td><code>rbac.role.read</code></td><td><code>@Roles('org_admin')</code> (<code>rbac.controller.ts:21</code>)</td></tr> <tr><td><code>/settings/roles/new</code></td><td>Role editor (create)</td><td><code>rbac.role.create</code></td><td>same</td></tr> <tr><td><code>/settings/roles/:id</code></td><td>Role detail</td><td><code>rbac.role.read</code></td><td>same</td></tr> <tr><td><code>/settings/roles/:id/edit</code></td><td>Role editor (edit)</td><td><code>rbac.role.update</code></td><td>same</td></tr> <tr><td><code>/settings/members</code></td><td>Members list</td><td><code>rbac.member.read</code></td><td>same</td></tr> <tr><td><code>/settings/members/add</code></td><td>Add member (sheet)</td><td><code>rbac.member.create</code></td><td>same</td></tr> <tr><td><code>/settings/access-audit</code></td><td>Permission audit</td><td><code>audit.read</code></td><td><code>@UseGuards(JwtAuthGuard)</code> only (<code>audit.controller.ts:9</code>; perm gate <code>(planned)</code>)</td></tr> </tbody></table> </div> <blockquote> <p><strong>Client guard vs server gate mismatch (flagged):</strong> the client gates on <code>rbac.*</code> permissions, the server on the <code>org_admin</code> role (OQ-R1). Until OQ-R1 lands, client routes must additionally require the <code>org_admin</code> role claim, else users with <code>rbac.*</code> perms would see screens that 403. Implemented as <code>roleOrPermissionGuard(['org_admin'], ['rbac.role.read'])</code> (see 13 §4, 15 §5).</p> </blockquote> <h2 id="4-navigation-rules"><a class="header" href="#4-navigation-rules">4. Navigation rules</a></h2> <ul> <li>Destinations hidden for anyone without the gate (05 §3 "Unauthorized destinations are hidden and unroutable").</li> <li><code>Roles</code> and <code>Members</code> are sibling tabs under one "Access" section header; <code>Access audit</code> is a third tab (not nested deeper).</li> <li>Breadcrumb (desktop ≥ 2 levels): <code>Settings / Roles / {role name}</code>.</li> <li>Keyboard: <code>Ctrl+K</code> global search may surface roles/members by name; <code>N</code> on Roles and Members lists starts the create flow (desktop).</li> </ul> <h2 id="5-content-model-what-each-screen-shows"><a class="header" href="#5-content-model-what-each-screen-shows">5. Content model (what each screen shows)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Primary entities</th><th>Secondary</th><th>Empty state</th></tr></thead><tbody> <tr><td>Roles list</td><td>Role (<code>name, slug, description, isSystem, priority, permissions[]</code> — <code>role.schema.ts:69-85</code>)</td><td>perm count badge, member count <code>(proposed)</code></td><td>"No custom roles yet"</td></tr> <tr><td>Role detail</td><td>Role identity + full perm set</td><td>member holders <code>(proposed)</code></td><td>—</td></tr> <tr><td>Role editor</td><td>95-perm matrix</td><td>search, group select-all, count</td><td>—</td></tr> <tr><td>Members list</td><td>Member (<code>userId, roles[], status, joinedAt</code> — <code>organization-member.schema.ts:15-31</code>) + merged user profile</td><td>role chips, status badge</td><td>"No members yet"</td></tr> <tr><td>Add member</td><td>User picker + role picker</td><td>—</td><td>—</td></tr> <tr><td>Access audit</td><td>AuditLog rows (action, entityType, actorId, ts)</td><td>filters</td><td>"No activity recorded"</td></tr> </tbody></table> </div> <h2 id="6-related-modules-cross-references"><a class="header" href="#6-related-modules-cross-references">6. Related modules (cross-references)</a></h2> <ul> <li><strong>Users</strong> — identity for member tiles; <code>user.read…user.import</code> (<code>studylyon-blueprint/04-Modules/Users.md:71-79</code>).</li> <li><strong>Auth</strong> — JWT carries <code>roles</code> claim (<code>jwt-payload.interface.ts:3</code>); re-login required for claim refresh.</li> <li><strong>Organizations</strong> — tenant context; <code>seedDefaults(org.slug)</code> quirk (<code>organizations.service.ts:58</code>).</li> <li><strong>Audit</strong> — <code>GET /audit-logs</code> backs the permission audit view (<code>audit.controller.ts:17-34</code>).</li> <li><strong>05 Shared GIA §9</strong> — client mirrors <code>permissions.constants.ts</code>; route rebuild on role change.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-rbac-module"><a class="header" href="#05--screen-inventory-rbac-module">05 — Screen Inventory (RBAC Module)</a></h1> <ul> <li><a href="rbac/05_Screen_Inventory.html#s1--roles-list">S1 — Roles list</a></li> <li><a href="rbac/05_Screen_Inventory.html#s2--role-detail">S2 — Role detail</a></li> <li><a href="rbac/05_Screen_Inventory.html#s3--role-editor-permission-matrix--hero">S3 — Role editor (permission matrix — hero)</a></li> <li><a href="rbac/05_Screen_Inventory.html#s4--members-list">S4 — Members list</a></li> <li><a href="rbac/05_Screen_Inventory.html#s5--add-member">S5 — Add member</a></li> <li><a href="rbac/05_Screen_Inventory.html#s6--permission-audit-view">S6 — Permission audit view</a></li> </ul> <blockquote> <p>Six screens. Every element traces to an endpoint (<code>rbac.controller.ts</code>) or schema field (<code>role.schema.ts</code>, <code>organization-member.schema.ts</code>). Detailed specs in <a href="rbac/06_Screen_Specifications.html">06</a>; forms in <a href="rbac/08_Form_Specifications.html">08</a>; wire contracts in <a href="rbac/12_API_Mapping.html">12</a>.</p> </blockquote> <hr /> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Screen</th><th>Route</th><th>Server endpoint(s)</th><th>Personas</th></tr></thead><tbody> <tr><td>S1</td><td>Roles list</td><td><code>/settings/roles</code></td><td><code>GET /rbac/roles</code></td><td>Priya, Arjun</td></tr> <tr><td>S2</td><td>Role detail</td><td><code>/settings/roles/:id</code></td><td><code>GET /rbac/roles</code> (client-side lookup)</td><td>Priya, Arjun</td></tr> <tr><td>S3</td><td>Role editor (permission matrix)</td><td><code>/settings/roles/new</code>, <code>/settings/roles/:id/edit</code></td><td><code>GET /rbac/permissions</code>, <code>POST /rbac/roles</code>, <code>PATCH /rbac/roles/:id</code></td><td>Priya, Arjun</td></tr> <tr><td>S4</td><td>Members list</td><td><code>/settings/members</code></td><td><code>GET /rbac/members</code> + <code>user.read</code> merge</td><td>Priya, Arjun, Samira</td></tr> <tr><td>S5</td><td>Add member</td><td><code>/settings/members/add</code> (bottom sheet)</td><td><code>POST /rbac/members</code></td><td>Priya, Arjun</td></tr> <tr><td>S6</td><td>Permission audit view</td><td><code>/settings/access-audit</code></td><td><code>GET /audit-logs?action=&entityType=&actorId=</code> (<code>audit.controller.ts:17-34</code>)</td><td>Priya, Arjun</td></tr> </tbody></table> </div> <hr /> <h2 id="s1--roles-list"><a class="header" href="#s1--roles-list">S1 — Roles list</a></h2> <ul> <li><strong>Purpose:</strong> browse the tenant's roles; distinguish locked system roles from custom.</li> <li><strong>Elements:</strong> <code>headlineMedium</code> "Roles & Permissions"; search field (client-filter on name/slug); list of <code>RoleRow</code> sorted <strong>priority desc</strong> (<code>rbac.service.ts:76</code>); <code>RoleRow</code> = name + slug (<code>mono</code>) + description + system badge + permission-count badge; trailing <code>AppMenu</code> (Edit / Delete) on custom roles only; FAB "Create role".</li> <li><strong>States:</strong> loading <code>AppSkeleton(list)</code>; error <code>AppErrorState</code>; empty "No custom roles yet — create your first role".</li> <li><strong>Server note:</strong> returns raw array, <strong>not paginated</strong> (<code>rbac.service.ts:75-77</code>); no <code>meta</code> in envelope (<code>response-envelope.interceptor.ts:55-59</code>).</li> </ul> <h2 id="s2--role-detail"><a class="header" href="#s2--role-detail">S2 — Role detail</a></h2> <ul> <li><strong>Purpose:</strong> read a role; jump to edit; see what it grants.</li> <li><strong>Elements:</strong> header (name, slug, description, priority, system badge); "N of 95 permissions" summary with <strong>permission group tiles</strong> (each group: label + chips of granted perms — reuse <code>PermissionGroupSection</code> read-mode); CTA "Edit permissions" (disabled for system roles); member holders section <code>(proposed)</code> — server offers no members-by-role endpoint, so holders would require client-side join of <code>GET /rbac/ members</code> + roles (<code>(proposed)</code>, OQ-R7).</li> <li><strong>States:</strong> loading; error 404 (role soft-deleted or wrong tenant → treat as not found, <code>RESOURCE_NOT_FOUND</code>); system-role banner "System roles cannot be edited".</li> </ul> <h2 id="s3--role-editor-permission-matrix--hero"><a class="header" href="#s3--role-editor-permission-matrix--hero">S3 — Role editor (permission matrix — hero)</a></h2> <ul> <li><strong>Purpose:</strong> create/edit a role's permission set. <strong>The matrix is the module's hero component</strong> (<a href="rbac/07_Component_Library.html">07 §1</a>, <a href="rbac/06_Screen_Specifications.html">06 §3</a>, <a href="rbac/15_Flutter_Implementation_Guide.html">15 §2</a>).</li> <li><strong>Elements:</strong> form header (name, slug, description, priority — <a href="rbac/08_Form_Specifications.html">08 §1</a>); live badge "X of 95 selected"; <code>AppSearchBar</code> filtering groups/perms; group sections (23 groups, §06) each with select-all toggle + <code>PermissionChip</code>s; bottom bar: "Cancel" / "Save role" (loading state); duplicate-slug inline error on 409.</li> <li><strong>Rules (derived):</strong> system roles never reach the editor (server refuses with 400 "Cannot modify system roles." <code>rbac.service.ts:94-95</code>); create always sends <code>isSystem:false</code> (OQ-R2); save is <strong>one</strong> PATCH with the full array (<code>rbac.service.ts:96</code> <code>$set</code> — no per-cell endpoint).</li> <li><strong>States:</strong> loading (permissions + role fetch); error; dirty-state unsaved-changes dialog on back.</li> </ul> <h2 id="s4--members-list"><a class="header" href="#s4--members-list">S4 — Members list</a></h2> <ul> <li><strong>Purpose:</strong> who belongs to the tenant and with which roles.</li> <li><strong>Elements:</strong> <code>headlineMedium</code> "Members"; search (name/email via merged profile); <code>MemberTile</code> = <code>AppAvatar</code> (user initials/photo) + displayName + email (<code>user.read</code> merge, OQ-R7) + role chips + status badge (<code>active</code> only today, <code>organization-member.schema.ts:7-11</code>; <code>invited/suspended</code> render-only) + joined date; trailing <code>AppMenu</code> (Edit roles / Remove) — hidden for the <strong>viewer</strong> persona; FAB "Add member".</li> <li><strong>Server note:</strong> sorted <code>joinedAt desc</code> (<code>rbac.service.ts:110</code>); array, not paginated; no profile join (see OQ-R7).</li> <li><strong>States:</strong> loading/error/empty ("No members yet — add your first member").</li> </ul> <h2 id="s5--add-member"><a class="header" href="#s5--add-member">S5 — Add member</a></h2> <ul> <li><strong>Purpose:</strong> create a membership: pick an existing user + one or more role slugs.</li> <li><strong>Elements:</strong> full-height <code>AppBottomSheet</code>: <code>AppSearchBar</code> user picker (from <code>GET /api/v1/users</code>, <code>users.controller.ts</code>); selected user tile; role picker (<code>FilterChip</code>s from <code>GET /rbac/roles</code>, system + custom, ordered by priority); "Add member" CTA; inline error for duplicate member (client pre-check — server 500s on E11000, OQ-R5).</li> <li><strong>Fields/validation:</strong> <a href="rbac/08_Form_Specifications.html">08 §2</a> — <code>userId</code> <code>@IsMongoId</code> (<code>add-member.dto.ts:6</code>), <code>roles</code> <code>@IsArray @IsString({each})</code> (<code>add-member.dto.ts:10-12</code>).</li> <li><strong>Server note:</strong> <code>PATCH /rbac/members/:id</code> reuses this DTO but ignores <code>userId</code> (<code>rbac.controller.ts:71-72</code>) — the edit-roles sheet can reuse the same role picker.</li> </ul> <h2 id="s6--permission-audit-view"><a class="header" href="#s6--permission-audit-view">S6 — Permission audit view</a></h2> <ul> <li><strong>Purpose:</strong> answer "who changed what, when" for RBAC events (and all other actions).</li> <li><strong>Elements:</strong> filter bar (action dropdown, entityType dropdown, actorId picker — all query params of <code>audit.controller.ts:20-25</code>); paginated log list (page/limit, default limit 50 server-side <code>audit.controller.ts:20</code>; envelope has <code>meta</code> — paginated shape per <a href="rbac/07_Component_Library.html">07 §2</a>); row = timestamp, action, entityType, actorId (mono), requestId.</li> <li><strong>States:</strong> loading/error/empty "No activity recorded"; pull-to-refresh; infinite scroll (paginated).</li> <li><strong>Gate note:</strong> server guard is <code>JwtAuthGuard</code> only (<code>audit.controller.ts:9</code>) — the <code>audit.read</code> permission is <strong>not enforced</strong> yet (Phase-5 <code>(planned)</code>, <code>docs/IMPLEMENTATION_PLAN.md:241</code>); client gates the route on <code>audit.read</code> regardless.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-rbac-module"><a class="header" href="#06--screen-specifications-rbac-module">06 — Screen Specifications (RBAC Module)</a></h1> <ul> <li><a href="rbac/06_Screen_Specifications.html#s1--roles-list-settingsroles">S1 — Roles List <code>/settings/roles</code></a></li> <li><a href="rbac/06_Screen_Specifications.html#s2--role-detail-settingsrolesid">S2 — Role Detail <code>/settings/roles/:id</code></a></li> <li><a href="rbac/06_Screen_Specifications.html#s3--role-editor-permission-matrix-settingsrolesnew--settingsrolesidedit">S3 — Role Editor (permission matrix) <code>/settings/roles/new</code> · <code>/settings/roles/:id/edit</code></a></li> <li><a href="rbac/06_Screen_Specifications.html#s4--members-list-settingsmembers">S4 — Members List <code>/settings/members</code></a></li> <li><a href="rbac/06_Screen_Specifications.html#s5--add-member-settingsmembersadd-bottom-sheet">S5 — Add Member <code>/settings/members/add</code> (bottom sheet)</a></li> <li><a href="rbac/06_Screen_Specifications.html#s6--permission-audit-view-settingsaccess-audit">S6 — Permission Audit View <code>/settings/access-audit</code></a></li> </ul> <blockquote> <p>Production-grade specs for all six screens (S1–S6, <a href="rbac/05_Screen_Inventory.html">05_Screen_Inventory.md</a>). Tokens: <code>Sp</code> = <a href="rbac/../00-shared/02_Design_Tokens.html">00-shared/02 §3</a>, <code>Ty</code> = §2, <code>El</code> = §5, <code>R</code> = §4, <code>Mo</code> = §6. Components reference <a href="rbac/../00-shared/03_Component_Library.html">00-shared/03</a> and <a href="rbac/07_Component_Library.html">07_Component_Library.md</a> (this module). The <strong>permission matrix</strong> (§3, S3) is the module's hero component and gets the deepest spec.</p> </blockquote> <hr /> <h2 id="s1--roles-list-settingsroles"><a class="header" href="#s1--roles-list-settingsroles">S1 — Roles List <code>/settings/roles</code></a></h2> <p><strong>Chrome:</strong> <code>AppBar</code> (title "Roles & Permissions", back); <code>headlineMedium</code>; list area.</p> <p><strong>Layout (phone):</strong></p> <pre><code>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) </code></pre> <p><strong>Elements & behaviour:</strong></p> <ul> <li><code>RoleRow</code>: leading <code>AppAvatar</code> (initials of role name, <code>primaryContainer</code>); title = role <code>name</code> (<code>role.schema.ts:69-70</code>); subtitle = slug in <code>mono</code> + description (<code>role.schema.ts:75-76</code>) ellipsized; trailing = <code>AppBadge</code> "System" (locked, <code>outline</code>) or perm count <code>AppBadge "{n} perms"</code>; <code>AppMenu</code>: custom → Edit / Delete; system → disabled (tooltip "System role").</li> <li>Sections split by <code>isSystem</code> (<code>role.schema.ts:78-79</code>); ordering <strong>priority desc</strong> (<code>rbac.service.ts:76</code>) within each section — Platform Admin (1000) then Org Admin (100) then Teacher (50)… in system; custom roles sorted by their priority.</li> <li>Search: <code>AppSearchBar</code> client-filters name/slug (list is small; server has no <code>q</code>).</li> <li>Delete: <code>AppDialog</code> confirm ("Delete role '{name}'? N members hold this role — they will lose its permissions within 5 minutes.") → <code>DELETE /rbac/roles/:id</code> (soft delete, <code>rbac.service.ts:106</code>) → optimistic remove + snackbar.</li> <li>FAB: "Create role" → <code>/settings/roles/new</code>.</li> </ul> <p><strong>States:</strong> loading <code>AppSkeleton(list)</code>; error <code>AppErrorState</code> (<code>UNAUTHENTICATED</code> → refresh flow; <code>PERMISSION_DENIED</code> → 403 screen); empty → only system section renders, empty-state card "No custom roles yet".</p> <p><strong>A11y:</strong> system badge read as "System role — locked"; row semantics <code>button</code>; menu actions labelled ("Edit role", "Delete role").</p> <p><strong>Tablet/desktop (≥ 840 dp):</strong> master-detail — list left (width 360), detail pane right (<code>go_router</code> split). Hover states; keyboard <code>↑↓</code> moves selection, <code>Enter</code> opens detail.</p> <hr /> <h2 id="s2--role-detail-settingsrolesid"><a class="header" href="#s2--role-detail-settingsrolesid">S2 — Role Detail <code>/settings/roles/:id</code></a></h2> <p><strong>Chrome:</strong> <code>AppBar</code> (role name, back); scroll body; <code>SafeArea</code> bottom action bar.</p> <p><strong>Layout:</strong></p> <pre><code>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) </code></pre> <p><strong>Behaviour:</strong></p> <ul> <li>Data = role doc from the list fetch (client holds the loaded list; no per-id GET endpoint exists — <code>rbac.controller.ts</code> exposes only <code>GET /rbac/roles</code>). Deep-linked <code>:id</code> → load full list, pick by id; missing → <code>AppErrorState</code> 404 semantics.</li> <li>Edit → <code>/settings/roles/:id/edit</code> (S3). Save → detail refreshes from the returned doc (<code>rbac.service.ts:96</code> returns updated doc).</li> <li>Members-with-this-role: <code>(proposed)</code> — computed client-side by intersecting <code>GET /rbac/members</code> with the role slug; no server endpoint (OQ-R7).</li> </ul> <p><strong>A11y:</strong> group headers <code>Semantics(headers:)</code>; chips readable in one pass; contrast for locked badge ≥ 3:1.</p> <hr /> <h2 id="s3--role-editor-permission-matrix-settingsrolesnew--settingsrolesidedit"><a class="header" href="#s3--role-editor-permission-matrix-settingsrolesnew--settingsrolesidedit">S3 — Role Editor (permission matrix) <code>/settings/roles/new</code> · <code>/settings/roles/:id/edit</code></a></h2> <p><strong>The hero.</strong> One screen for create and edit; create = same form with empty matrix.</p> <p><strong>Chrome:</strong> <code>AppBar</code> ("Create role" / "Edit role"), subtitle slug; body = form fields (Section A, <code>08 §1</code>) + matrix; bottom sticky bar.</p> <p><strong>Layout (phone portrait):</strong></p> <pre><code>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) </code></pre> <p><strong>Matrix component spec (hero):</strong></p> <ul> <li><strong>Source of truth:</strong> <code>GET /rbac/permissions</code> (<code>rbac.service.ts:79-81</code> returns <code>ALL_PERMISSIONS</code>, <code>permissions.constants.ts:1-97</code>) fetched on editor open; role's current perms = its <code>permissions[]</code> (<code>role.schema.ts:84-85</code>).</li> <li><strong>Grouping (forward-looking client matrix):</strong> 26 groups derived from the <code>domain</code> prefix of each permission. Full table (order = source order within group):</li> </ul> <div class="table-wrapper"><table><thead><tr><th>Group</th><th>Permissions</th></tr></thead><tbody> <tr><td>Organization</td><td><code>organization.read</code>, <code>organization.update</code>, <code>organization.delete</code>, <code>organization.settings.update</code></td></tr> <tr><td>Users</td><td><code>user.read</code>, <code>user.create</code>, <code>user.update</code>, <code>user.delete</code>, <code>user.import</code></td></tr> <tr><td>Roles & Access</td><td><code>rbac.role.read</code>, <code>rbac.role.create</code>, <code>rbac.role.update</code>, <code>rbac.role.delete</code>, <code>rbac.member.read</code>, <code>rbac.member.create</code>, <code>rbac.member.update</code>, <code>rbac.member.delete</code></td></tr> <tr><td>Staff</td><td><code>staff.read</code>, <code>staff.create</code>, <code>staff.update</code>, <code>staff.delete</code>, <code>department.manage</code>, <code>designation.manage</code></td></tr> <tr><td>Students</td><td><code>student.read</code>, <code>student.create</code>, <code>student.update</code>, <code>student.delete</code></td></tr> <tr><td>Attendance</td><td><code>attendance.mark</code>, <code>attendance.edit</code></td></tr> <tr><td>Fees</td><td><code>fees.collect</code></td></tr> <tr><td>Notifications</td><td><code>notification.read</code>, <code>notification.update</code></td></tr> <tr><td>CRM</td><td><code>crm.read</code>, <code>crm.lead.manage</code>, <code>crm.campaign.manage</code></td></tr> <tr><td>Dashboard</td><td><code>dashboard.read</code>, <code>dashboard.widget.manage</code></td></tr> <tr><td>Reports</td><td><code>report.generate</code>, <code>report.read</code></td></tr> <tr><td>Biometric</td><td><code>biometric.log.create</code>, <code>biometric.log.read</code>, <code>biometric.device.manage</code></td></tr> <tr><td>Timetable</td><td><code>timetable.read</code>, <code>timetable.create</code></td></tr> <tr><td>Houses</td><td><code>houses.read</code>, <code>houses.create</code>, <code>houses.update</code>, <code>houses.delete</code></td></tr> <tr><td>Rooms</td><td><code>rooms.read</code>, <code>rooms.create</code>, <code>rooms.update</code>, <code>rooms.delete</code></td></tr> <tr><td>Audit</td><td><code>audit.read</code></td></tr> <tr><td>Library</td><td><code>books.read</code>, <code>books.create</code>, <code>books.update</code>, <code>books.delete</code>, <code>books.issue</code>, <code>books.return</code></td></tr> <tr><td>Fines</td><td><code>fines.pay</code></td></tr> <tr><td>Transport</td><td><code>transport.vehicle.read</code>, <code>transport.vehicle.create</code>, <code>transport.vehicle.update</code>, <code>transport.vehicle.delete</code>, <code>transport.route.read</code>, <code>transport.route.create</code>, <code>transport.route.update</code>, <code>transport.route.delete</code>, <code>transport.driver.read</code>, <code>transport.driver.create</code>, <code>transport.driver.update</code>, <code>transport.driver.delete</code>, <code>transport.assign</code></td></tr> <tr><td>Settings</td><td><code>settings.read</code>, <code>settings.update</code>, <code>settings.delete</code></td></tr> <tr><td>Feature Flags</td><td><code>feature-flags.read</code>, <code>feature-flags.update</code>, <code>feature-flags.delete</code></td></tr> <tr><td>Payments</td><td><code>payments.read</code>, <code>payments.process</code>, <code>payments.refund</code>, <code>payments.reconcile</code>, <code>receipts.read</code></td></tr> <tr><td>Files</td><td><code>file.read</code>, <code>file.upload</code>, <code>file.delete</code></td></tr> <tr><td>Webhooks</td><td><code>webhook.create</code>, <code>webhook.read</code>, <code>webhook.update</code>, <code>webhook.delete</code></td></tr> <tr><td>Search</td><td><code>search</code></td></tr> <tr><td>Scheduler</td><td><code>scheduler.read</code>, <code>scheduler.create</code>, <code>scheduler.delete</code></td></tr> </tbody></table> </div> <ul> <li><code>PermissionGroupSection</code>: group header = label + <code>Checkbox</code> (tri-state: none/some/all) <ul> <li>"{k}/{n}"; body = <code>Wrap</code> of <code>PermissionChip</code>s (<code>FilterChip</code>-style, label = full perm string in <code>mono</code>-ish small type, tooltip = humanized name <code>(proposed)</code>).</li> </ul> </li> <li>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").</li> <li>Live count badge updates on every toggle; <strong>no server call until Save</strong> (one PATCH, <code>rbac.service.ts:96</code> — no per-cell endpoint exists).</li> <li><strong>Forward-looking notes:</strong> the matrix renders only the 95 permissions returned by the server; coaching perms (<code>batch.manage</code> etc.) and roles (<code>batch_coordinator</code>…, <code>docs/IMPLEMENTATION_PLAN.md:713-761</code>) are <code>(planned)</code> and must not be rendered until the server ships them.</li> </ul> <p><strong>Behaviour & rules:</strong></p> <ul> <li>Create: sends <code>{name, slug, description?, priority?, isSystem:false, permissions}</code> — <code>isSystem:false</code> is <strong>forced</strong> (OQ-R2; server default <code>true</code> locks the role, <code>role.schema.ts:78-79</code>).</li> <li>Save → <code>POST /rbac/roles</code> or <code>PATCH /rbac/roles/:id</code>; 409 duplicate slug → inline under slug field, focus it; 400 (system role — should be unreachable) → banner.</li> <li>Dirty state: back navigation with unsaved changes → <code>AppDialog</code> "Discard changes?".</li> <li>Empty selection on create → warning banner "Role with no permissions — members will only see the base shell" (allowed server-side; e.g. <code>student</code> role is <code>[]</code>).</li> <li>Priority field hint: "Higher number lists the role first" (<code>rbac.service.ts:76</code>).</li> </ul> <p><strong>States:</strong> loading (catalog + role) skeleton; saving → CTA spinner, matrix disabled, anti-double-submit; success → snackbar + pop to detail.</p> <p><strong>A11y:</strong> every chip = toggle with <code>Semantics(checked:)</code> + label + group context; search results announced (<code>liveRegion</code> "3 groups match"); checkbox tri-state announced ("none / some / all selected").</p> <p><strong>Tablet/desktop:</strong> matrix in a 2-column grid (groups wrap); sticky group header on scroll; <code>Ctrl+F</code> focuses search; tab order = fields → matrix rows.</p> <hr /> <h2 id="s4--members-list-settingsmembers"><a class="header" href="#s4--members-list-settingsmembers">S4 — Members List <code>/settings/members</code></a></h2> <p><strong>Chrome:</strong> <code>AppBar</code> "Members"; <code>headlineMedium</code>; list; FAB.</p> <p><strong>Layout:</strong></p> <pre><code>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) </code></pre> <p><strong>Behaviour:</strong></p> <ul> <li>Data: <code>GET /rbac/members</code> (<code>rbac.service.ts:109-111</code>, joinedAt desc) + profile merge via <code>user.read</code> (batch <code>GET /api/v1/users</code>, OQ-R7); merge failures render <code>userId</code> in <code>mono</code> fallback.</li> <li>Roles per member = <code>roles[]</code> (<code>organization-member.schema.ts:22</code>) resolved to role docs for labels/priority; unknown slugs (role deleted) render as plain slug chips.</li> <li>Status badge from <code>status</code> (<code>organization-member.schema.ts:27</code>); today always <code>active</code> (OQ-R3); <code>suspended</code>/<code>invited</code> render-only until server produces them.</li> <li>Row menu (admin): "Edit roles" → S5 sheet in edit mode (<code>PATCH /rbac/members/:id</code>, DTO requires <code>userId</code> but service ignores it — client may send the same <code>userId</code> from the row, <code>rbac.controller.ts:71-72</code>); "Remove member" → <code>AppDialog</code> destructive confirm ("Remove {name}? Their access ends within 5 minutes.") → <code>DELETE /rbac/members/:id</code> (<code>rbac.service.ts:138-140</code> soft delete; silent 200 even if already removed) → optimistic removal.</li> <li>Self-guard: Priya removing her own <code>org_admin</code> membership → extra typed confirm (type the role slug pattern, <code>05 §5</code>) + warning "You will lose access to this screen immediately on next refresh."</li> </ul> <p><strong>States:</strong> skeleton / error / empty ("No members yet — add your first member"); pull to refresh.</p> <hr /> <h2 id="s5--add-member-settingsmembersadd-bottom-sheet"><a class="header" href="#s5--add-member-settingsmembersadd-bottom-sheet">S5 — Add Member <code>/settings/members/add</code> (bottom sheet)</a></h2> <p>Full-height <code>AppBottomSheet</code> (Sp-16 padding, <code>r-xl</code> top):</p> <pre><code>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) </code></pre> <p><strong>Rules (derived):</strong></p> <ul> <li>Body: <code>{userId, roles}</code> — <code>@IsMongoId</code> (<code>add-member.dto.ts:6-7</code>), <code>@IsArray</code> <code>@IsString({each})</code> (<code>add-member.dto.ts:9-12</code>).</li> <li>Duplicate pre-check client-side against loaded member list (unique index <code>organization-member.schema.ts:48</code>; server would 500 — OQ-R5) → inline error "Already a member".</li> <li>Success → 200/201 doc (<code>status:"active"</code>, <code>joinedAt</code> now — <code>rbac.service.ts:122-123</code>); sheet closes, row appears top of S4, snackbar "Member added".</li> <li>Edit mode (from S4 menu): same sheet, title "Edit roles", user tile read-only, chips preselect current roles, CTA "Save roles".</li> </ul> <p><strong>A11y:</strong> sheet traps focus; role chips <code>Semantics(selected:)</code>; user results <code>liveRegion</code> count.</p> <hr /> <h2 id="s6--permission-audit-view-settingsaccess-audit"><a class="header" href="#s6--permission-audit-view-settingsaccess-audit">S6 — Permission Audit View <code>/settings/access-audit</code></a></h2> <p><strong>Chrome:</strong> <code>AppBar</code> "Access audit"; filter bar; paginated list.</p> <p><strong>Layout:</strong></p> <pre><code>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) </code></pre> <p><strong>Behaviour:</strong></p> <ul> <li>Query <code>GET /audit-logs?page=&limit=&action=&entityType=&actorId=</code> (<code>audit.controller.ts:17-34</code>); default limit 50 (<code>audit.controller.ts:20</code>); pagination envelope with <code>meta</code> (<code>{page,limit,totalItems,totalPages,hasNext,hasPrevious}</code>, <code>07 §2</code>).</li> <li>RBAC focus: presets in the action dropdown (<code>(proposed)</code> convenience): "Role created", "Role updated", "Role deleted", "Member added", "Member updated", "Member removed" — mapped to whatever <code>action</code> strings the audit module writes (<code>audit-write</code> queue, <code>PLAN.md:189-191</code>; exact strings <code>(planned)</code>, OQ-R8).</li> <li>Row tap → no detail screen (no server detail endpoint) — copies requestId (<code>(proposed)</code>).</li> <li>401/403 handling standard; server currently gates with <code>JwtAuthGuard</code> only (<code>audit.controller.ts:9</code>) — client still requires <code>audit.read</code> (mismatch flagged, Phase-5 <code>(planned)</code>).</li> </ul> <p><strong>Tablet/desktop:</strong> filters in the AppBar row; table view (columns: time, action, entity, actor, requestId) instead of list tiles.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-rbac-module"><a class="header" href="#07--component-library-rbac-module">07 — Component Library (RBAC Module)</a></h1> <ul> <li><a href="rbac/07_Component_Library.html#1-permissionmatrix-hero-component">1. <code>PermissionMatrix</code> (hero component)</a></li> <li><a href="rbac/07_Component_Library.html#2-permissiongroupsection">2. <code>PermissionGroupSection</code></a></li> <li><a href="rbac/07_Component_Library.html#3-permissionchip">3. <code>PermissionChip</code></a></li> <li><a href="rbac/07_Component_Library.html#4-rolerow">4. <code>RoleRow</code></a></li> <li><a href="rbac/07_Component_Library.html#5-rolebadge--systemrolebadge">5. <code>RoleBadge</code> / <code>SystemRoleBadge</code></a></li> <li><a href="rbac/07_Component_Library.html#6-membertile">6. <code>MemberTile</code></a></li> <li><a href="rbac/07_Component_Library.html#7-memberstatusbadge">7. <code>MemberStatusBadge</code></a></li> <li><a href="rbac/07_Component_Library.html#8-rolepickerchips">8. <code>RolePickerChips</code></a></li> <li><a href="rbac/07_Component_Library.html#9-usersearchpicker">9. <code>UserSearchPicker</code></a></li> <li><a href="rbac/07_Component_Library.html#10-auditrow--auditfilterbar">10. <code>AuditRow</code> / <code>AuditFilterBar</code></a></li> <li><a href="rbac/07_Component_Library.html#11-permissionsummary-read-mode-group-list">11. <code>PermissionSummary</code> (read-mode group list)</a></li> <li><a href="rbac/07_Component_Library.html#12-shared-component-reuse-map">12. Shared-component reuse map</a></li> </ul> <blockquote> <p>Module-specific components. All base widgets (cards, lists, chips, dialogs, sheets, menus, skeletons, states) come from <a href="rbac/../00-shared/03_Component_Library.html">00-shared/03</a>; this file adds the RBAC layer. Every component is derived from schema fields (<code>role.schema.ts</code>, <code>organization-member.schema.ts</code>) and the guard model (<code>rbac.guard.ts</code>).</p> </blockquote> <hr /> <h2 id="1-permissionmatrix-hero-component"><a class="header" href="#1-permissionmatrix-hero-component">1. <code>PermissionMatrix</code> (hero component)</a></h2> <ul> <li><strong>Purpose:</strong> render + edit the full permission catalog for one role (S3).</li> <li><strong>Props:</strong> <code>permissions</code> (catalog from <code>GET /rbac/permissions</code>), <code>selected</code> (Set of granted perm strings), <code>onChanged(Set<String>)</code>, <code>searchQuery</code>, <code>readOnly</code>, <code>compact</code>.</li> <li><strong>Structure:</strong> 26 <code>PermissionGroupSection</code>s (grouping table in <a href="rbac/06_Screen_Specifications.html">06 §3</a>), each with tri-state <code>Checkbox</code> (none/some/all) + <code>Wrap</code> of <code>PermissionChip</code>s; optional <code>AppSearchBar</code> (owned by parent); live count <code>AppBadge "{sel} of 95"</code>.</li> <li><strong>Behaviour:</strong> chip tap → toggle; group checkbox → toggle all; search → filter sections (0-match sections collapse); <code>readOnly</code> → chips render as plain labels with <code>Semantics(label: "granted")</code>; no server calls until save (single PATCH, <code>rbac.service.ts:96</code>).</li> <li><strong>Flutter:</strong> stateful widget owning <code>Set<String> selected</code> + <code>Map<String,bool> groupState</code>; sections = <code>ListView.builder</code> children (lazy); chips = <code>FilterChip</code> with <code>visualDensity: compact</code>; <code>RepaintBoundary</code> per section (see <a href="rbac/15_Flutter_Implementation_Guide.html">15 §2</a>).</li> <li><strong>Golden:</strong> phone/tablet/desktop × light/dark × readOnly/editable.</li> </ul> <h2 id="2-permissiongroupsection"><a class="header" href="#2-permissiongroupsection">2. <code>PermissionGroupSection</code></a></h2> <ul> <li>Header row: group label (<code>titleSmall</code>), tri-state <code>Checkbox</code>, <code>"{k}/{n}"</code> counter (<code>labelSmall</code>); body <code>Wrap(spacing:8, runSpacing:8)</code> of chips.</li> <li><code>Semantics(headers:)</code> on header; tri-state announced.</li> </ul> <h2 id="3-permissionchip"><a class="header" href="#3-permissionchip">3. <code>PermissionChip</code></a></h2> <ul> <li><code>FilterChip</code>-like; label = full permission string (<code>bodySmall</code>, <code>mono</code> feel), selected = <code>primaryContainer</code>/<code>onPrimaryContainer</code>; unselected = <code>surfaceVariant</code>.</li> <li>Tooltip: humanized name <code>(proposed)</code>, e.g. <code>payments.reconcile</code> → "Reconcile payments" (i18n key <code>perm.payments.reconcile</code>); falls back to raw string.</li> <li>A11y: <code>Semantics(checked: selected, label: '<perm>')</code>.</li> </ul> <h2 id="4-rolerow"><a class="header" href="#4-rolerow">4. <code>RoleRow</code></a></h2> <ul> <li><code>AppListTile</code>-based list row: <code>AppAvatar</code> (initials, <code>primaryContainer</code>), title = role name, subtitle = slug (<code>mono</code>) + description ellipsized, trailing = <code>AppBadge</code> "System"/"{n} perms" + <code>AppMenu</code> (Edit/Delete for custom; disabled for system).</li> <li>Sort: <code>priority desc</code> (<code>rbac.service.ts:76</code>) — provided pre-sorted by parent.</li> </ul> <h2 id="5-rolebadge--systemrolebadge"><a class="header" href="#5-rolebadge--systemrolebadge">5. <code>RoleBadge</code> / <code>SystemRoleBadge</code></a></h2> <ul> <li><code>AppBadge</code> variants: <code>System</code> (outlined, tooltip "Locked — can't be edited"), <code>Custom</code>, and permission-count badge <code>"{n} perms"</code>. System badge always on <code>isSystem:true</code> roles (<code>role.schema.ts:78-79</code>).</li> </ul> <h2 id="6-membertile"><a class="header" href="#6-membertile">6. <code>MemberTile</code></a></h2> <ul> <li><code>AppListTile</code>: <code>AppAvatar</code> 48 (photo/initials from merged user profile, OQ-R7), title displayName (fallback <code>userId</code> mono), subtitle = email + "Joined {date}" (<code>organization-member.schema.ts:30-31</code>), trailing = role chips + <code>AppMenu</code> (Edit roles / Remove — admin only).</li> <li>Role chips = read-only <code>AppChips</code> (tap no-op, tooltip "Managed in role editor"); unknown slug renders raw.</li> </ul> <h2 id="7-memberstatusbadge"><a class="header" href="#7-memberstatusbadge">7. <code>MemberStatusBadge</code></a></h2> <ul> <li><code>AppBadge</code> from <code>MemberStatus</code> (<code>organization-member.schema.ts:7-11</code>): <code>active</code> → success (<code>secondary</code>), <code>invited</code> → <code>tertiary</code> (info), <code>suspended</code> → <code>error</code>. <strong>Render-only</strong>: server produces only <code>active</code> today (OQ-R3).</li> </ul> <h2 id="8-rolepickerchips"><a class="header" href="#8-rolepickerchips">8. <code>RolePickerChips</code></a></h2> <ul> <li>Multi-select <code>FilterChip</code> wrap from <code>GET /rbac/roles</code> (priority order); used in S5 (add/edit member). Props: <code>roles</code>, <code>selected</code>, <code>onChanged</code>. System roles get a tiny lock glyph (still selectable — e.g. assigning <code>teacher</code>).</li> </ul> <h2 id="9-usersearchpicker"><a class="header" href="#9-usersearchpicker">9. <code>UserSearchPicker</code></a></h2> <ul> <li><code>AppSearchBar</code>-driven result list (<code>user.read</code>), <code>AppListTile</code> rows (avatar, name, email); single-select with check; props: <code>onPicked(User)</code>, <code>preselected?</code>.</li> </ul> <h2 id="10-auditrow--auditfilterbar"><a class="header" href="#10-auditrow--auditfilterbar">10. <code>AuditRow</code> / <code>AuditFilterBar</code></a></h2> <ul> <li><code>AuditRow</code>: action (<code>labelLarge</code>), entityType · actorId (<code>mono</code> bodySmall), timestamp (relative, <code>bodySmall</code>); tap → copies requestId <code>(proposed)</code>.</li> <li><code>AuditFilterBar</code>: three <code>AppDropdown</code>s (action, entityType, actorId) + clear button; map to <code>audit.controller.ts:20-25</code> query params; RBAC presets <code>(proposed)</code> (OQ-R8).</li> </ul> <h2 id="11-permissionsummary-read-mode-group-list"><a class="header" href="#11-permissionsummary-read-mode-group-list">11. <code>PermissionSummary</code> (read-mode group list)</a></h2> <ul> <li>Compact read-only block for S2: group label + granted <code>PermissionChip</code>s (or "None in this group"). Reuses <code>PermissionGroupSection</code> in <code>readOnly</code>.</li> </ul> <h2 id="12-shared-component-reuse-map"><a class="header" href="#12-shared-component-reuse-map">12. Shared-component reuse map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Need</th><th>Shared component (<a href="rbac/../00-shared/03_Component_Library.html">00-shared/03</a>)</th></tr></thead><tbody> <tr><td>Loading</td><td><code>AppSkeleton</code> (list/card), <code>AppProgress</code></td></tr> <tr><td>Empty / error / offline</td><td><code>AppEmptyState</code>, <code>AppErrorState</code>, <code>AppOfflineBanner</code></td></tr> <tr><td>Feedback</td><td><code>AppSnackbar</code>, <code>AppBanner</code> (system-role info, 409 conflict)</td></tr> <tr><td>Inputs</td><td><code>AppTextField</code>, <code>AppDropdown</code>, <code>AppSearchBar</code>, <code>AppCheckbox</code></td></tr> <tr><td>Display</td><td><code>AppCard</code>, <code>AppAvatar</code>, <code>AppBadge</code>, <code>AppSectionHeader</code>, <code>AppInfoRow</code></td></tr> <tr><td>Actions</td><td><code>AppFAB</code> (Roles/Members lists), <code>AppButton</code>, <code>AppDialog</code> (delete/self-removal confirm), <code>AppBottomSheet</code> (S5), <code>AppMenu</code> (row actions)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-rbac-module"><a class="header" href="#08--form-specifications-rbac-module">08 — Form Specifications (RBAC Module)</a></h1> <ul> <li><a href="rbac/08_Form_Specifications.html#1-create--edit-role-form-s3-top-section">1. Create / Edit Role form (S3 top section)</a></li> <li><a href="rbac/08_Form_Specifications.html#2-add-member-form-s5-sheet">2. Add Member form (S5 sheet)</a></li> <li><a href="rbac/08_Form_Specifications.html#3-audit-filter-form-s6-non-mandatory">3. Audit filter form (S6, non-mandatory)</a></li> <li><a href="rbac/08_Form_Specifications.html#4-validation-summary-table">4. Validation summary table</a></li> </ul> <blockquote> <p>Field-by-field specs for the three RBAC forms. Validation mirrors the server DTOs (<code>create-role.dto.ts</code>, <code>add-member.dto.ts</code>) <strong>plus</strong> client-side guards the server does not enforce (flagged per field). Wire contract per <a href="rbac/../00-shared/07_API_Conventions.html">00-shared/07</a>.</p> </blockquote> <hr /> <h2 id="1-create--edit-role-form-s3-top-section"><a class="header" href="#1-create--edit-role-form-s3-top-section">1. Create / Edit Role form (S3 top section)</a></h2> <p>Request shape: <code>CreateRoleDto</code> (<code>create-role.dto.ts:10-38</code>) — used for both <code>POST /rbac/roles</code> and <code>PATCH /rbac/roles/:id</code> (<code>rbac.controller.ts:39-49</code>).</p> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Control</th><th>Server validation (source)</th><th>Client rules</th></tr></thead><tbody> <tr><td>1</td><td>name</td><td><code>AppTextField</code>, autofocus (create)</td><td><code>@IsString</code> required (<code>create-role.dto.ts:12-13</code>)</td><td>required; max 60 chars; trim; unique-ish by display (slug is the real unique)</td></tr> <tr><td>2</td><td>slug</td><td><code>AppTextField</code> <code>mono</code></td><td><code>@IsString</code> required (<code>create-role.dto.ts:15-17</code>)</td><td>required; pattern <code>^[a-z0-9_]{2,40}$</code>; <strong>generated suggestion</strong> from name (snake_case) editable; on 409 <code>DUPLICATE_RESOURCE</code> (<code>rbac.service.ts:86-87</code>) → inline error + focus; helper "Used in API & membership — can't be changed later" (update actually allows <code>$set</code> of slug, <code>rbac.service.ts:96</code> — but changing breaks member role arrays; treat as immutable in UI)</td></tr> <tr><td>3</td><td>description</td><td><code>AppTextField</code> multiline</td><td><code>@IsOptional @IsString</code> (<code>create-role.dto.ts:19-22</code>)</td><td>optional; ≤ 200 chars; helper text; 2 lines</td></tr> <tr><td>4</td><td>priority</td><td><code>AppTextField</code> number</td><td><code>@IsOptional @IsNumber</code> (<code>create-role.dto.ts:29-32</code>); schema default <code>0</code> (<code>role.schema.ts:82</code>)</td><td>integer 0–1000; default <code>0</code>; helper "Higher = listed first (Roles list sorts priority desc, <code>rbac.service.ts:76</code>). Display only — doesn't grant more power."</td></tr> <tr><td>5</td><td>isSystem</td><td>hidden (never rendered)</td><td><code>@IsOptional @IsBoolean</code> (<code>create-role.dto.ts:24-27</code>); schema default <code>true</code> (<code>role.schema.ts:78-79</code>)</td><td><strong>always sends <code>false</code></strong> (OQ-R2) — otherwise the new role locks itself</td></tr> <tr><td>6</td><td>permissions</td><td><code>PermissionMatrix</code> (<a href="rbac/07_Component_Library.html">07 §1</a>)</td><td><code>@IsOptional @IsArray</code> (<code>create-role.dto.ts:34-37</code>) — <strong>no whitelist check</strong></td><td>client-side only: every string must be in the catalog from <code>GET /rbac/permissions</code> (<code>rbac.service.ts:79-81</code>); empty set allowed (warning banner, e.g. <code>student</code> role)</td></tr> </tbody></table> </div> <p><strong>Submit flow (both modes):</strong></p> <ul> <li>Edit: <code>PATCH /rbac/roles/:id</code> with the <strong>full</strong> doc <code>{name, slug?, description?, priority?, isSystem?, permissions}</code> — server <code>$set</code>s wholesale (<code>rbac.service.ts:96</code>); system roles → 400 "Cannot modify system roles." (<code>rbac.service.ts:94-95</code>) — unreachable via UI (editor hidden for system roles).</li> <li>Create: <code>POST /rbac/roles</code>; duplicate slug → 409 (<code>rbac.service.ts:87</code>).</li> <li>Errors: field-level for slug/name (400 <code>VALIDATION_ERROR</code> with <code>details[].field</code>), banner for 403; anti-double-submit; success → snackbar → pop to detail.</li> </ul> <hr /> <h2 id="2-add-member-form-s5-sheet"><a class="header" href="#2-add-member-form-s5-sheet">2. Add Member form (S5 sheet)</a></h2> <p>Request shape: <code>AddMemberDto</code> (<code>add-member.dto.ts:4-12</code>) for <code>POST /rbac/members</code>; same DTO reused by <code>PATCH /rbac/members/:id</code> (edit mode — <code>rbac.controller.ts:71-72</code>).</p> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Control</th><th>Server validation (source)</th><th>Client rules</th></tr></thead><tbody> <tr><td>1</td><td>userId</td><td><code>UserSearchPicker</code> (<a href="rbac/07_Component_Library.html">07 §9</a>), read-only tile once picked</td><td><code>@IsMongoId</code> required (<code>add-member.dto.ts:6-7</code>)</td><td>required; must exist in <code>user.read</code> results; <strong>pre-check</strong> against loaded members → "Already a member" inline (server 500s on E11000, OQ-R5)</td></tr> <tr><td>2</td><td>roles</td><td><code>RolePickerChips</code> (<a href="rbac/07_Component_Library.html">07 §8</a>) multi-select</td><td><code>@IsArray @IsString({each: true})</code> required (<code>add-member.dto.ts:9-12</code>)</td><td>≥ 1 required for submit; ≤ 10 (UI cap, <code>(proposed)</code>); slugs must resolve in <code>GET /rbac/roles</code> (warn + strip unknown); system roles selectable (assign <code>teacher</code> is normal)</td></tr> </tbody></table> </div> <p><strong>Submit flow:</strong></p> <ul> <li>Create: <code>POST /rbac/members</code> → 200/201 doc with <code>status:"active"</code>, <code>joinedAt</code> server-set (<code>rbac.service.ts:122-123</code>); sheet closes; row appears top (list sorts joinedAt desc, <code>rbac.service.ts:110</code>).</li> <li>Edit mode: <code>PATCH /rbac/members/:id</code> with <code>{userId: <row's userId>, roles:[…]}</code> (server ignores <code>userId</code> — <code>rbac.controller.ts:72</code>; client echoes the row's value for DTO compliance); success → tile chips refresh; 404 → "Member removed elsewhere" snackbar + remove row.</li> <li>Remove: <code>AppDialog</code> destructive confirm → <code>DELETE /rbac/members/:id</code> (<code>rbac.service.ts:138-140</code>); silent 200 even if already gone (no 404) → optimistic removal.</li> </ul> <hr /> <h2 id="3-audit-filter-form-s6-non-mandatory"><a class="header" href="#3-audit-filter-form-s6-non-mandatory">3. Audit filter form (S6, non-mandatory)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Control</th><th>Query param (<code>audit.controller.ts:20-25</code>)</th><th>Notes</th></tr></thead><tbody> <tr><td>action</td><td><code>AppDropdown</code> (free-text)</td><td><code>action</code></td><td>suggestions + RBAC presets <code>(proposed)</code> (OQ-R8)</td></tr> <tr><td>entityType</td><td><code>AppDropdown</code> (free-text)</td><td><code>entityType</code></td><td>e.g. <code>roles</code>, <code>organization_members</code></td></tr> <tr><td>actorId</td><td><code>UserSearchPicker</code></td><td><code>actorId</code></td><td>via <code>user.read</code></td></tr> <tr><td>page/limit</td><td>implicit</td><td><code>page</code>, <code>limit</code> (default 50 server, <code>audit.controller.ts:20</code>)</td><td>infinite scroll appends pages</td></tr> </tbody></table> </div> <p>Filters combine (all optional; server builds filter object <code>audit.controller.ts:26-29</code>); "Clear" resets all.</p> <hr /> <h2 id="4-validation-summary-table"><a class="header" href="#4-validation-summary-table">4. Validation summary table</a></h2> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Where enforced</th></tr></thead><tbody> <tr><td>slug unique per tenant</td><td>server 409 + unique index (<code>rbac.service.ts:86-87</code>, <code>role.schema.ts:89</code>) + client inline</td></tr> <tr><td>system role immutable</td><td>server 400 (<code>rbac.service.ts:94-95,104-105</code>) + UI hides editor/delete</td></tr> <tr><td>isSystem forced false on create</td><td>client only (OQ-R2)</td></tr> <tr><td>permission names whitelisted</td><td>client only (server <code>@IsArray</code>, <code>create-role.dto.ts:36</code>)</td></tr> <tr><td>member duplicate</td><td>client pre-check (server 500, OQ-R5)</td></tr> <tr><td>member roles non-empty</td><td>client only (server <code>@IsArray</code> allows <code>[]</code>, <code>add-member.dto.ts:10</code>)</td></tr> <tr><td><code>userId</code> MongoId</td><td>server 400 (<code>add-member.dto.ts:6</code>)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-rbac-module"><a class="header" href="#09--user-behaviour-rbac-module">09 — User Behaviour (RBAC Module)</a></h1> <ul> <li><a href="rbac/09_User_Behaviour.html#1-core-state-machine-all-lists">1. Core state machine (all lists)</a></li> <li><a href="rbac/09_User_Behaviour.html#2-data-freshness-rules">2. Data freshness rules</a></li> <li><a href="rbac/09_User_Behaviour.html#3-permission-propagation-the-critical-behaviour">3. Permission propagation (the critical behaviour)</a></li> <li><a href="rbac/09_User_Behaviour.html#4-destructive-actions">4. Destructive actions</a></li> <li><a href="rbac/09_User_Behaviour.html#5-offline-behaviour">5. Offline behaviour</a></li> <li><a href="rbac/09_User_Behaviour.html#6-concurrency--multi-admin">6. Concurrency / multi-admin</a></li> <li><a href="rbac/09_User_Behaviour.html#7-empty--zero-states">7. Empty / zero states</a></li> <li><a href="rbac/09_User_Behaviour.html#8-rate-limiting">8. Rate limiting</a></li> <li><a href="rbac/09_User_Behaviour.html#9-session-expiry-mid-edit">9. Session expiry mid-edit</a></li> <li><a href="rbac/09_User_Behaviour.html#10-behaviour-by-persona-summary">10. Behaviour by persona (summary)</a></li> </ul> <blockquote> <p>Behavioural rules for the RBAC surface: state machines, refresh, caching, offline, destructive paths, and the permission-change propagation story. Server rules quoted from <code>rbac.service.ts</code> / <code>rbac.guard.ts</code>; client conventions from <a href="rbac/../00-shared/06_State_Management.html">00-shared/06</a>.</p> </blockquote> <hr /> <h2 id="1-core-state-machine-all-lists"><a class="header" href="#1-core-state-machine-all-lists">1. Core state machine (all lists)</a></h2> <pre><code>Initial → Loading → Success(Empty) ─┐ │ ├→ Content └→ Error(ApiException) ←┘ Error → Retry → Loading Success → PullToRefresh → Loading(background, keep content) </code></pre> <p>Per <code>00-shared/06 §3.1</code>; screens never render blank.</p> <h2 id="2-data-freshness-rules"><a class="header" href="#2-data-freshness-rules">2. Data freshness rules</a></h2> <div class="table-wrapper"><table><thead><tr><th>List</th><th>Server cache</th><th>Client cache (proposed, <code>00-shared/06 §3.3</code>)</th><th>Refresh triggers</th></tr></thead><tbody> <tr><td>Roles (<code>GET /rbac/roles</code>)</td><td>none (<code>rbac.service.ts:75-77</code> raw)</td><td>5 min, stale-while-revalidate</td><td>pull-to-refresh; return from editor; after delete</td></tr> <tr><td>Permissions (<code>GET /rbac/permissions</code>)</td><td>none (static constant)</td><td>24 h — catalog only changes with deploys (<code>permissions.constants.ts:1-97</code>)</td><td>app foreground if > 24 h</td></tr> <tr><td>Members (<code>GET /rbac/members</code>)</td><td>none</td><td>5 min</td><td>pull-to-refresh; after add/edit/remove</td></tr> <tr><td>Users (profile merge)</td><td>none</td><td>24 h reference cache (<code>00-shared/06 §3.3</code>)</td><td>—</td></tr> <tr><td>Audit (<code>GET /audit-logs</code>)</td><td>none (paginated)</td><td>none beyond current page</td><td>infinite scroll; pull-to-refresh</td></tr> </tbody></table> </div> <h2 id="3-permission-propagation-the-critical-behaviour"><a class="header" href="#3-permission-propagation-the-critical-behaviour">3. Permission propagation (the critical behaviour)</a></h2> <p>Derived from source — <strong>three different latencies</strong>:</p> <div class="table-wrapper"><table><thead><tr><th>Change</th><th>Takes effect</th><th>Source</th></tr></thead><tbody> <tr><td>Role permission edit</td><td>≤ 300 s (Redis cache TTL <code>sl:{tenantId}:perm:{userId}</code> EX 300)</td><td><code>rbac.service.ts:48,68</code></td></tr> <tr><td>Member roles change</td><td>next login (JWT <code>roles</code> claim minted at login, <code>auth.service.ts:145-154</code>; refresh reuses old claim <code>auth.service.ts:192-196</code>)</td><td>OQ-R4</td></tr> <tr><td>Member removed</td><td>immediate for new requests IF the permission check runs (guard resolves via member doc, <code>rbac.service.ts:56-65</code>); JWT roles claim still valid until re-login — role-checked endpoints (<code>@Roles</code>, e.g. <code>rbac.controller.ts:21</code>) <strong>stay open</strong> until token expiry</td><td><code>rbac.guard.ts:39-41</code></td></tr> </tbody></table> </div> <p><strong>UI behaviour:</strong></p> <ul> <li>After saving a role's permissions, the editor shows: "Permissions apply to holders within 5 minutes; role claims refresh at next login." (info banner).</li> <li>Route-tree rebuild (<code>05 §9</code>) happens on login/permission refresh — <strong>not</strong> on RBAC writes by a different admin (no server push event exists; OQ-R9).</li> <li>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.</li> </ul> <h2 id="4-destructive-actions"><a class="header" href="#4-destructive-actions">4. Destructive actions</a></h2> <div class="table-wrapper"><table><thead><tr><th>Action</th><th>Confirm</th><th>Server behaviour (source)</th></tr></thead><tbody> <tr><td>Delete role</td><td><code>AppDialog</code> — lists holder count <code>(proposed)</code>; copy: "N member(s) hold this role. They lose its permissions."</td><td>soft delete (<code>rbac.service.ts:106</code>); system → 400 (blocked in UI)</td></tr> <tr><td>Remove member</td><td><code>AppDialog</code> destructive</td><td>soft delete, silent 200 even if absent (<code>rbac.service.ts:138-140</code>)</td></tr> <tr><td>Remove own <code>org_admin</code></td><td><strong>typed confirm</strong> (type <code>org_admin</code> slug, <code>05 §5</code>) + "You will lose access" warning</td><td>same as above; JWT keeps access until expiry (see §3)</td></tr> </tbody></table> </div> <p>No optimistic deletes with undo: deletes are irreversible-enough (soft-delete but no restore endpoint — <code>rbac.service.ts:106,139</code>) → server-confirm then remove row (<code>00-shared/03 F</code> rule: irreversible ops are never optimistic).</p> <h2 id="5-offline-behaviour"><a class="header" href="#5-offline-behaviour">5. Offline behaviour</a></h2> <ul> <li>Reads: last-good cache renders + <code>AppOfflineBanner</code>; all write CTAs disabled with tooltip "Connect to retry" (no offline write queues for RBAC — <code>00-shared/12</code> C3).</li> <li>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).</li> </ul> <h2 id="6-concurrency--multi-admin"><a class="header" href="#6-concurrency--multi-admin">6. Concurrency / multi-admin</a></h2> <ul> <li>Two admins editing the same role: last-writer-wins (<code>$set</code> whole doc, <code>rbac.service.ts:96</code>; <code>version</code> field exists in <code>base.schema.ts:30-31</code> but <strong>no optimistic lock is enforced</strong> 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.</li> <li>Duplicate member add across two admins: server 500 (E11000, OQ-R5) — client shows conflict copy + refreshes list.</li> </ul> <h2 id="7-empty--zero-states"><a class="header" href="#7-empty--zero-states">7. Empty / zero states</a></h2> <ul> <li>Roles: system section always renders (7 seeded roles, <code>role.schema.ts:8-65</code>); custom section empty-state.</li> <li>Members: "No members yet — add your first member." (org_admin always exists in practice — created at registration <code>auth.service.ts:88-91</code>).</li> <li>Matrix search: "No permissions match 'xyz'".</li> <li>Audit: "No activity recorded" (empty <code>meta.totalItems === 0</code>).</li> </ul> <h2 id="8-rate-limiting"><a class="header" href="#8-rate-limiting">8. Rate limiting</a></h2> <ul> <li><code>/rbac/*</code> uses the default <code>api</code> tier, 100/min (<code>rate-limit.guard.ts:36-37</code>); audit default too. Enforcement only in production (<code>rate-limit.guard.ts:30</code>).</li> <li>429 → <code>AppBanner</code> countdown + backoff, no auto-retry (<code>00-shared/06 §5</code>).</li> </ul> <h2 id="9-session-expiry-mid-edit"><a class="header" href="#9-session-expiry-mid-edit">9. Session expiry mid-edit</a></h2> <ul> <li>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, <code>13 §2</code>).</li> </ul> <h2 id="10-behaviour-by-persona-summary"><a class="header" href="#10-behaviour-by-persona-summary">10. Behaviour by persona (summary)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Persona</th><th>What they see</th><th>What they can do</th></tr></thead><tbody> <tr><td>org_admin</td><td>full</td><td>everything</td></tr> <tr><td>role manager (planned)</td><td>roles/members/audit</td><td>per OQ-R1 grants; edit matrix</td></tr> <tr><td>member viewer</td><td>members list read-only</td><td>search, view</td></tr> <tr><td>platform_admin</td><td>tenant picker shell (planned)</td><td>cross-tenant reads via repo bypass (<code>base.repository.ts:21</code>) — never inside tenant RBAC UI without org_admin token</td></tr> <tr><td>teacher etc.</td><td>nothing</td><td>403 screen if route reached</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-rbac-module"><a class="header" href="#10--interaction-specification-rbac-module">10 — Interaction Specification (RBAC Module)</a></h1> <ul> <li><a href="rbac/10_Interaction_Specification.html#1-permission-matrix-interactions">1. Permission matrix interactions</a></li> <li><a href="rbac/10_Interaction_Specification.html#2-screen-transitions">2. Screen transitions</a></li> <li><a href="rbac/10_Interaction_Specification.html#3-feedback--haptics">3. Feedback & haptics</a></li> <li><a href="rbac/10_Interaction_Specification.html#4-frustration-controls">4. Frustration controls</a></li> <li><a href="rbac/10_Interaction_Specification.html#5-desktop-keyboard-map">5. Desktop keyboard map</a></li> <li><a href="rbac/10_Interaction_Specification.html#6-swipe--long-press-phone">6. Swipe / long-press (phone)</a></li> <li><a href="rbac/10_Interaction_Specification.html#7-modal-hierarchy-rbac">7. Modal hierarchy (RBAC)</a></li> <li><a href="rbac/10_Interaction_Specification.html#8-motion-budget-notes">8. Motion budget notes</a></li> </ul> <blockquote> <p>Module-specific interaction & motion, layered on <a href="rbac/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>. Focus: the permission matrix (dense, high-interaction) and destructive governance flows. Tokens: <code>Mo</code> = <a href="rbac/../00-shared/02_Design_Tokens.html">00-shared/02 §6</a>.</p> </blockquote> <hr /> <h2 id="1-permission-matrix-interactions"><a class="header" href="#1-permission-matrix-interactions">1. Permission matrix interactions</a></h2> <div class="table-wrapper"><table><thead><tr><th>Gesture / input</th><th>Behaviour</th></tr></thead><tbody> <tr><td>Tap chip</td><td>Toggle perm; selected = <code>primaryContainer</code>, unselected = <code>surfaceVariant</code>; count badge <code>AnimatedSwitcher</code> update <code>Mo.m-instant</code>; <code>HapticFeedback.selectionClick()</code> on mobile</td></tr> <tr><td>Tap group checkbox</td><td>Toggle all perms in group; tri-state renders none/some/all</td></tr> <tr><td>Tap group header label</td><td>Collapse/expand section (<code>AnimatedSize</code>, <code>Mo.m-base</code>, no layout pop)</td></tr> <tr><td>Search typing</td><td>Debounce 300 ms (<a href="rbac/../00-shared/03_Component_Library.html">00-shared/03 B</a>); filters chips + collapses 0-match sections; result count announced (<code>liveRegion</code>)</td></tr> <tr><td>Long-press chip</td><td>Tooltip with humanized name <code>(proposed)</code> + raw string (<a href="rbac/07_Component_Library.html">07 §3</a>)</td></tr> <tr><td>Hover (desktop)</td><td>Chip lifts <code>Mo.m-fast</code>; cursor pointer; tooltip after 500 ms</td></tr> <tr><td><code>Ctrl+F</code> / <code>/</code> (desktop)</td><td>Focus matrix search</td></tr> <tr><td><code>Tab</code></td><td>Field order: name → slug → description → priority → matrix groups (chips are focusable toggles, not tab stops — arrow keys move within a <code>FocusTraversalGroup</code>)</td></tr> <tr><td>Space / Enter on focused chip</td><td>Toggle (native M3 checkbox/chip behaviour)</td></tr> <tr><td>Drag-select</td><td><code>(proposed)</code> — drag across chips to mass-toggle (batched on release; no per-cell writes — server has none)</td></tr> </tbody></table> </div> <p><strong>Density:</strong> chips <code>visualDensity: compact</code>, <code>Sp-8</code> gaps, min touch target 44 dp on phone (dense inline exemption, <a href="rbac/02_User_Personas.html">02 §9</a>), 48 dp on desktop for mouse precision.</p> <h2 id="2-screen-transitions"><a class="header" href="#2-screen-transitions">2. Screen transitions</a></h2> <div class="table-wrapper"><table><thead><tr><th>Transition</th><th>Token</th></tr></thead><tbody> <tr><td>Roles list → detail / editor</td><td>forward slide <code>Mo.m-base</code>; hero: role avatar flies to detail header <code>Mo.m-slow</code></td></tr> <tr><td>List → sheet (S5)</td><td><code>showModalBottomSheet</code> default <code>Mo.m-base</code>, <code>r-xl</code> top corners</td></tr> <tr><td>Section collapse in matrix</td><td><code>AnimatedSize</code> <code>Mo.m-base</code> easeInOutCubic</td></tr> <tr><td>Count badge change</td><td>scale pop <code>Mo.m-entrance</code> (value-only, no shimmer)</td></tr> <tr><td>Skeleton → content</td><td>cross-fade <code>Mo.m-slow</code></td></tr> <tr><td>Reduced motion</td><td>all → <code>Mo.m-instant</code> fades (system setting; no stagger, no parallax)</td></tr> </tbody></table> </div> <h2 id="3-feedback--haptics"><a class="header" href="#3-feedback--haptics">3. Feedback & haptics</a></h2> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Feedback</th></tr></thead><tbody> <tr><td>Toggle perm / select-all</td><td><code>selectionClick</code> haptic; chip ripple <code>Mo.m-fast</code></td></tr> <tr><td>Save success</td><td>snackbar "Role created/updated" (< 3 s) + <code>lightImpact</code></td></tr> <tr><td>Save error</td><td>snackbar error (≥ 4 s) + <code>mediumImpact</code>; 409 inline under slug</td></tr> <tr><td>Delete confirm</td><td><code>heavyImpact</code> on destructive confirm tap</td></tr> <tr><td>Remove member</td><td>optimistic row removal after 200 + snackbar; <code>lightImpact</code></td></tr> <tr><td>Pull-to-refresh complete</td><td><code>lightImpact</code></td></tr> </tbody></table> </div> <h2 id="4-frustration-controls"><a class="header" href="#4-frustration-controls">4. Frustration controls</a></h2> <ul> <li>Save button: loading spinner replaces label; <strong>disabled while pending</strong> (anti-double-submit, <a href="rbac/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §6</a>).</li> <li>Every tap responds ≤ 100 ms (press state visible — chips darken 2% on press).</li> <li>Matrix save is one request; no per-cell pending states.</li> <li>Back with dirty form → <code>AppDialog</code> "Discard changes?" (ESC / barrier = keep editing).</li> </ul> <h2 id="5-desktop-keyboard-map"><a class="header" href="#5-desktop-keyboard-map">5. Desktop keyboard map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Key</th><th>Action</th></tr></thead><tbody> <tr><td><code>Ctrl+F</code>, <code>/</code></td><td>matrix search focus</td></tr> <tr><td><code>N</code></td><td>Roles/Members list → create flow</td></tr> <tr><td><code>↑/↓</code></td><td>list selection (master-detail), matrix group navigation</td></tr> <tr><td><code>Enter</code></td><td>open selected row / submit form</td></tr> <tr><td><code>Esc</code></td><td>close dialog/sheet/menu/search; cancel selection</td></tr> <tr><td><code>Ctrl+Enter</code></td><td>save from matrix search field</td></tr> </tbody></table> </div> <h2 id="6-swipe--long-press-phone"><a class="header" href="#6-swipe--long-press-phone">6. Swipe / long-press (phone)</a></h2> <ul> <li>Row swipe: not used for roles/members (destructive; <a href="rbac/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §1</a> keeps swipe for safe ops only).</li> <li>Long-press <code>RoleRow</code>/<code>MemberTile</code> → <code>AppMenu</code> (same as trailing ⋮).</li> <li>Long-press chip (matrix) → tooltip only.</li> </ul> <h2 id="7-modal-hierarchy-rbac"><a class="header" href="#7-modal-hierarchy-rbac">7. Modal hierarchy (RBAC)</a></h2> <ol> <li>Snackbar (save/remove feedback)</li> <li>Bottom sheet (S5 add member)</li> <li>Dialog (delete confirm, typed self-removal confirm, discard-changes)</li> <li>Full page (editor S3 — >3 fields rule, <a href="rbac/05_Screen_Inventory.html">05 §5</a>)</li> </ol> <h2 id="8-motion-budget-notes"><a class="header" href="#8-motion-budget-notes">8. Motion budget notes</a></h2> <ul> <li>Matrix scroll: no parallax, no sticky-header animation beyond <code>Mo.m-instant</code> elevation shift (<code>El-2</code> sticky group header on desktop).</li> <li>Audit list infinite-scroll: bottom spinner fade <code>Mo.m-instant</code>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-rbac-module"><a class="header" href="#11--design-system-mapping-rbac-module">11 — Design System Mapping (RBAC Module)</a></h1> <ul> <li><a href="rbac/11_Design_System_Mapping.html#1-component--token-map">1. Component → token map</a></li> <li><a href="rbac/11_Design_System_Mapping.html#2-typography-map">2. Typography map</a></li> <li><a href="rbac/11_Design_System_Mapping.html#3-iconography">3. Iconography</a></li> <li><a href="rbac/11_Design_System_Mapping.html#4-spacing--layout">4. Spacing & layout</a></li> <li><a href="rbac/11_Design_System_Mapping.html#5-motion-tokens-02-6">5. Motion tokens (02 §6)</a></li> <li><a href="rbac/11_Design_System_Mapping.html#6-darklight-parity">6. Dark/light parity</a></li> <li><a href="rbac/11_Design_System_Mapping.html#7-theming-hooks-tenant">7. Theming hooks (tenant)</a></li> </ul> <blockquote> <p>Token → widget mapping for the RBAC surface. Base mapping in <a href="rbac/../00-shared/04_Design_System_Mapping.html">00-shared/04</a>; this file extends it. Theme foundation: Material 3, seed <code>#0037B0</code> (<a href="rbac/../00-shared/02_Design_Tokens.html">00-shared/02 §1</a>), tenant branding overrides <code>primary</code> only.</p> </blockquote> <hr /> <h2 id="1-component--token-map"><a class="header" href="#1-component--token-map">1. Component → token map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Component</th><th>Surface</th><th>Text</th><th>Border/Radius</th><th>Elevation</th><th>States</th></tr></thead><tbody> <tr><td>RoleRow / MemberTile</td><td><code>surfaceContainerLow</code></td><td><code>onSurface</code> / <code>onSurfaceVariant</code></td><td><code>r-lg</code>, <code>outlineVariant</code> hairline</td><td><code>e-0</code></td><td>selected <code>primaryContainer</code> (master-detail), hover tint <code>m-fast</code></td></tr> <tr><td>PermissionMatrix container</td><td><code>surfaceContainerLow</code></td><td>—</td><td><code>r-lg</code></td><td><code>e-1</code></td><td>—</td></tr> <tr><td>PermissionGroupSection header</td><td><code>surfaceContainerHigh</code> (sticky, desktop)</td><td><code>titleSmall</code> <code>onSurface</code></td><td><code>r-md</code></td><td><code>e-2</code> sticky</td><td>—</td></tr> <tr><td>PermissionChip selected</td><td><code>primaryContainer</code></td><td><code>onPrimaryContainer</code></td><td><code>r-full</code></td><td><code>e-0</code></td><td>ripple <code>m-fast</code></td></tr> <tr><td>PermissionChip unselected</td><td><code>surfaceVariant</code></td><td><code>onSurfaceVariant</code></td><td><code>r-full</code></td><td><code>e-0</code></td><td>ripple <code>m-fast</code>, hover <code>surfaceVariant</code> + 2%</td></tr> <tr><td>System badge</td><td><code>outline</code> border</td><td><code>onSurfaceVariant</code></td><td><code>r-sm</code></td><td>—</td><td>—</td></tr> <tr><td>Perm-count badge</td><td><code>secondaryContainer</code></td><td><code>onSecondaryContainer</code></td><td><code>r-sm</code></td><td>—</td><td>—</td></tr> <tr><td>Status badge active</td><td><code>secondaryContainer</code></td><td><code>onSecondaryContainer</code></td><td><code>r-sm</code></td><td>—</td><td>—</td></tr> <tr><td>Status badge suspended</td><td><code>errorContainer</code></td><td><code>onErrorContainer</code></td><td><code>r-sm</code></td><td>—</td><td>—</td></tr> <tr><td>Status badge invited</td><td><code>tertiaryContainer</code></td><td><code>onTertiaryContainer</code></td><td><code>r-sm</code></td><td>—</td><td>—</td></tr> <tr><td>Save CTA</td><td><code>primary</code> / <code>onPrimary</code></td><td><code>labelLarge</code></td><td><code>r-md</code></td><td>—</td><td>disabled 40% opacity</td></tr> <tr><td>Delete (destructive)</td><td><code>errorContainer</code></td><td><code>onErrorContainer</code></td><td><code>r-md</code></td><td>—</td><td>—</td></tr> <tr><td>FAB "Create role"/"Add member"</td><td><code>primaryContainer</code></td><td><code>onPrimaryContainer</code></td><td><code>r-xl</code> (56)</td><td><code>e-3</code></td><td>hide on scroll-down</td></tr> <tr><td>Slug field text</td><td><code>mono</code> 14</td><td><code>onSurfaceVariant</code></td><td>—</td><td>—</td><td>—</td></tr> <tr><td>Info banner (system role, propagation delay)</td><td><code>primaryContainer</code></td><td><code>onPrimaryContainer</code></td><td><code>r-md</code></td><td><code>e-0</code></td><td>—</td></tr> </tbody></table> </div> <h2 id="2-typography-map"><a class="header" href="#2-typography-map">2. Typography map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Text</th><th>Token</th></tr></thead><tbody> <tr><td>Screen titles "Roles & Permissions"</td><td><code>headlineMedium</code></td></tr> <tr><td>Section headers (System/Custom roles, groups)</td><td><code>titleSmall</code>/<code>titleMedium</code></td></tr> <tr><td>Role/member names</td><td><code>titleMedium</code> (list), <code>titleLarge</code> (detail)</td></tr> <tr><td>Slug, requestId, userId</td><td><code>mono</code></td></tr> <tr><td>Descriptions, emails, dates</td><td><code>bodyMedium</code>/<code>bodySmall</code></td></tr> <tr><td>Matrix count "6 of 95"</td><td><code>labelMedium</code> <code>onSurfaceVariant</code></td></tr> <tr><td>Chip labels</td><td><code>bodySmall</code></td></tr> <tr><td>Buttons</td><td><code>labelLarge</code></td></tr> </tbody></table> </div> <h2 id="3-iconography-1"><a class="header" href="#3-iconography-1">3. Iconography</a></h2> <div class="table-wrapper"><table><thead><tr><th>Icon (Material Symbols outlined; filled for status)</th><th>Use</th></tr></thead><tbody> <tr><td><code>admin_panel_settings</code></td><td>Roles & Permissions nav entry, role avatar fallback</td></tr> <tr><td><code>shield_person</code></td><td>system role rows</td></tr> <tr><td><code>groups</code> / <code>person_add</code></td><td>members entry / add-member FAB</td></tr> <tr><td><code>lock</code> / <code>lock_open</code></td><td>locked system role / custom role</td></tr> <tr><td><code>manage_search</code> / <code>history</code></td><td>access-audit entry</td></tr> <tr><td><code>check_box</code> tri-state</td><td>group select-all (<code>IndeterminateCheckbox</code>)</td></tr> <tr><td><code>search</code>, <code>close</code>, <code>more_vert</code>, <code>edit</code>, <code>delete</code></td><td>standard chrome</td></tr> </tbody></table> </div> <p>All icons 24 dp default; dense rows 20; status icons filled (<a href="rbac/../00-shared/04_Design_System_Mapping.html">00-shared/04 §4</a>).</p> <h2 id="4-spacing--layout"><a class="header" href="#4-spacing--layout">4. Spacing & layout</a></h2> <ul> <li>List rows 56 dp; matrix chips <code>Sp-8</code> gaps; section gap <code>Sp-24</code>; card padding <code>Sp-16</code>; bottom action bar padding <code>Sp-16</code>, height 64 (<a href="rbac/02_User_Personas.html">02 §3</a>).</li> <li>Matrix on tablet/desktop: 2-column group grid (min group width 320 dp); phone: single column.</li> <li>Safe areas: bottom bar respects nav bar; sheets <code>SafeArea</code> top handle.</li> </ul> <h2 id="5-motion-tokens-02-6"><a class="header" href="#5-motion-tokens-02-6">5. Motion tokens (<a href="rbac/02_User_Personas.html">02 §6</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Use</th><th>Token</th></tr></thead><tbody> <tr><td>Chip toggle, badge count</td><td><code>m-instant</code></td></tr> <tr><td>Ripple, hover</td><td><code>m-fast</code></td></tr> <tr><td>Sheet, dialog, section collapse</td><td><code>m-base</code></td></tr> <tr><td>Hero avatar flight, skeleton fade</td><td><code>m-slow</code></td></tr> <tr><td>List entrance stagger 40 ms</td><td><code>m-entrance</code></td></tr> <tr><td>Reduced motion</td><td>all → <code>m-instant</code> fade</td></tr> </tbody></table> </div> <h2 id="6-darklight-parity"><a class="header" href="#6-darklight-parity">6. Dark/light parity</a></h2> <ul> <li>All colors token-referenced; no literals (<a href="rbac/04_Information_Architecture.html">04 §7</a> enforced).</li> <li>Dark: chips <code>surfaceVariant</code> dark variant; selected <code>primaryContainer</code> dark variant (dark chromatic roles TBD by designer, <a href="rbac/../00-shared/02_Design_Tokens.html">00-shared/02 §1.4</a>); contrast maintained for all badge text (≥ 4.5:1).</li> <li>Golden tests: every screen light + dark at 3 breakpoints (<a href="rbac/10_Interaction_Specification.html">10_QA_Baseline.md §6</a>).</li> </ul> <h2 id="7-theming-hooks-tenant"><a class="header" href="#7-theming-hooks-tenant">7. Theming hooks (tenant)</a></h2> <ul> <li>Org branding <code>primary</code> override propagates to chips/FAB/CTA automatically (<a href="rbac/../00-shared/04_Design_System_Mapping.html">00-shared/04 §7.5</a>); system badges and destructive colours stay token-fixed.</li> <li>No RBAC-specific color semantics beyond status badges (§1).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-rbac-module"><a class="header" href="#12--api-mapping-rbac-module">12 — API Mapping (RBAC Module)</a></h1> <ul> <li><a href="rbac/12_API_Mapping.html#0-module-wide-request-envelope--client-policy">0. Module-wide request envelope & client policy</a></li> <li><a href="rbac/12_API_Mapping.html#1-the-guard-pipeline-how-every-endpoint-is-protected">1. The guard pipeline (how every endpoint is protected)</a></li> <li><a href="rbac/12_API_Mapping.html#2-rbac-endpoints-exact">2. RBAC endpoints (exact)</a> <ul> <li><a href="rbac/12_API_Mapping.html#roles-list-s1-s2">Roles list (S1, S2)</a></li> <li><a href="rbac/12_API_Mapping.html#permission-catalog-s3-matrix">Permission catalog (S3 matrix)</a></li> <li><a href="rbac/12_API_Mapping.html#create-role-s3">Create role (S3)</a></li> <li><a href="rbac/12_API_Mapping.html#update-role-s3-edit">Update role (S3 edit)</a></li> <li><a href="rbac/12_API_Mapping.html#delete-role-s1-menu">Delete role (S1 menu)</a></li> <li><a href="rbac/12_API_Mapping.html#members-list-s4">Members list (S4)</a></li> <li><a href="rbac/12_API_Mapping.html#add-member-s5">Add member (S5)</a></li> <li><a href="rbac/12_API_Mapping.html#update-member-roles-s4-menu-edit-s5-edit-mode">Update member roles (S4 menu edit, S5 edit mode)</a></li> <li><a href="rbac/12_API_Mapping.html#remove-member-s4-menu">Remove member (S4 menu)</a></li> <li><a href="rbac/12_API_Mapping.html#permission-audit-s6--cross-module-read">Permission audit (S6) — cross-module read</a></li> </ul> </li> <li><a href="rbac/12_API_Mapping.html#3-error-code-map-rbac-screens">3. Error-code map (RBAC screens)</a></li> <li><a href="rbac/12_API_Mapping.html#4-endpoint-coverage-audit-forward-looking">4. Endpoint coverage audit (forward-looking)</a></li> </ul> <blockquote> <p>Exact wire contract for every RBAC screen → endpoint. Base <code>/api/v1</code>; envelope per <a href="rbac/../00-shared/07_API_Conventions.html">00-shared/07</a> (<code>{success,message,data,meta?,timestamp,requestId}</code> / <code>{success:false,message,error:{code,details?},timestamp,requestId}</code>). All endpoints from <code>src/modules/rbac/rbac.controller.ts</code>; rules from <code>rbac.service.ts</code>. Tenant identity comes from the JWT claim — <strong>never</strong> from the body.</p> </blockquote> <hr /> <h2 id="0-module-wide-request-envelope--client-policy-11"><a class="header" href="#0-module-wide-request-envelope--client-policy-11">0. Module-wide request envelope & client policy</a></h2> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Contract</th></tr></thead><tbody> <tr><td>Base</td><td><code>https://api.<domain>/api/v1</code></td></tr> <tr><td>Headers</td><td><code>Authorization: Bearer <accessToken></code>; <code>x-request-id</code> client UUID; <code>Content-Type: application/json</code></td></tr> <tr><td>Tenancy</td><td><code>tenantId</code> from JWT (<code>jwt-payload.interface.ts:2</code>); body never carries it (RBAC DTOs have no tenant field)</td></tr> <tr><td>Rate tier</td><td>default <code>api</code> 100/min (<code>rate-limit.guard.ts:36-37</code>); <strong>production-only enforcement</strong> (<code>rate-limit.guard.ts:30</code>)</td></tr> <tr><td>Server caching</td><td>permissions-of-user: Redis <code>sl:{tenantId}:perm:{userId}</code> EX 300 (<code>rbac.service.ts:48,68</code>) — client must tolerate ≤ 5 min propagation</td></tr> <tr><td>Client caching</td><td>roles/members 5 min stale-while-revalidate; permissions catalog 24 h (<a href="rbac/09_User_Behaviour.html">09 §2</a>)</td></tr> <tr><td>Offline</td><td>reads from cache + banner; writes blocked</td></tr> <tr><td>Retry</td><td>backoff on 5xx/network; <strong>no auto-retry</strong> on 429</td></tr> </tbody></table> </div> <h2 id="1-the-guard-pipeline-how-every-endpoint-is-protected"><a class="header" href="#1-the-guard-pipeline-how-every-endpoint-is-protected">1. The guard pipeline (how every endpoint is protected)</a></h2> <p>Global order, <code>app.module.ts:129-133</code>:</p> <pre><code>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`) </code></pre> <p><code>RbacGuard</code> detail (<code>rbac.guard.ts:20-52</code>):</p> <ul> <li>no <code>@Roles</code>/<code>@Permissions</code> metadata → <code>true</code> (line 29) — route is authed-only.</li> <li>role match: <code>requiredRoles.some(r => req.user.roles.includes(r))</code> (lines 38-42) → else 403 <code>ForbiddenException</code> "Insufficient role."</li> <li>perm match: <code>getPermissionsForUser(userId, tenantId)</code> (line 44) then <code>requiredPerms.every(p => perms.includes(p))</code> (line 48) → else 403 "Insufficient permission."</li> <li>Decorators: <code>Roles</code>/<code>ROLES_KEY</code> (<code>roles.decorator.ts:3-4</code>), <code>Permissions</code>/<code>PERMISSIONS_KEY</code> (<code>permissions.decorator.ts:3-4</code>), re-exported by <code>guards/decorators.ts:1-5</code>.</li> </ul> <p><strong>Today's coverage (derived, <code>(planned)</code> for completion):</strong> only <code>webhooks</code> (<code>webhooks.controller.ts:22-86</code>), <code>files</code> (<code>files.controller.ts:30-67</code>), <code>scheduler</code> (<code>scheduler.controller.ts:24-38</code>), <code>search</code> (<code>search.controller.ts:16</code>) controllers carry <code>@Permissions</code>. Phase-5 "Permissions audit (all endpoints)" (<code>docs/IMPLEMENTATION_PLAN.md:241</code>) is <code>(planned)</code>; the RBAC controller itself is role-gated (<code>rbac.controller.ts:21</code>). Client must be built to the <strong>intended</strong> model (perm-gated everywhere) while living with the current role-gated reality (OQ-R1).</p> <h2 id="2-rbac-endpoints-exact"><a class="header" href="#2-rbac-endpoints-exact">2. RBAC endpoints (exact)</a></h2> <p>All under class gate <code>@Roles('org_admin')</code> + <code>@UseGuards(JwtAuthGuard, RbacGuard)</code> (<code>rbac.controller.ts:21-22</code>).</p> <h3 id="roles-list-s1-s2"><a class="header" href="#roles-list-s1-s2">Roles list (S1, S2)</a></h3> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>GET /rbac/roles</code> (<code>rbac.controller.ts:27-31</code>)</td></tr> <tr><td>Success</td><td>200 <code>data: Role[]</code> — array, <strong>no meta</strong> (not paginated, <code>rbac.service.ts:75-77</code>)</td></tr> <tr><td>Sort</td><td><code>priority: -1</code> desc (<code>rbac.service.ts:76</code>)</td></tr> <tr><td>Role doc shape</td><td><code>{_id, tenantId, name, slug, description?, isSystem, priority, permissions[], createdAt, updatedAt, version}</code> (<code>role.schema.ts:67-86</code>, <code>base.schema.ts</code>)</td></tr> <tr><td>Errors</td><td>401 <code>UNAUTHENTICATED</code>; 403 <code>PERMISSION_DENIED</code> (non-org_admin); 429 <code>RATE_LIMITED</code>; 5xx</td></tr> </tbody></table> </div> <h3 id="permission-catalog-s3-matrix"><a class="header" href="#permission-catalog-s3-matrix">Permission catalog (S3 matrix)</a></h3> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>GET /rbac/permissions</code> (<code>rbac.controller.ts:33-37</code>)</td></tr> <tr><td>Success</td><td>200 <code>data: string[]</code> — the 95 names (<code>rbac.service.ts:79-81</code>; <code>permissions.constants.ts:1-97</code>)</td></tr> <tr><td>Errors</td><td>same as above</td></tr> </tbody></table> </div> <h3 id="create-role-s3"><a class="header" href="#create-role-s3">Create role (S3)</a></h3> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>POST /rbac/roles</code> (<code>rbac.controller.ts:39-43</code>)</td></tr> <tr><td>Request</td><td><code>CreateRoleDto</code>: <code>{name, slug, description?, isSystem?, priority?, permissions?}</code> (<code>create-role.dto.ts:10-38</code>)</td></tr> <tr><td>Success</td><td>201/200 <code>data: Role</code> (created with tenant from context, <code>role.repository.ts:17-22</code>)</td></tr> <tr><td>Errors</td><td>400 <code>VALIDATION_ERROR</code> (field details); <strong>409 <code>DUPLICATE_RESOURCE</code></strong> — slug exists (<code>rbac.service.ts:86-87</code>, index <code>role.schema.ts:89</code>); 403; 429</td></tr> <tr><td>Client note</td><td>always send <code>isSystem:false</code> (OQ-R2); whitelist <code>permissions</code> client-side</td></tr> </tbody></table> </div> <h3 id="update-role-s3-edit"><a class="header" href="#update-role-s3-edit">Update role (S3 edit)</a></h3> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>PATCH /rbac/roles/:id</code> (<code>rbac.controller.ts:45-49</code>)</td></tr> <tr><td>Request</td><td>same <code>CreateRoleDto</code> (full-doc <code>$set</code>, <code>rbac.service.ts:96</code>)</td></tr> <tr><td>Errors</td><td>400 "Cannot modify system roles." (<code>rbac.service.ts:94-95</code>); <strong>404 <code>RESOURCE_NOT_FOUND</code></strong> (<code>rbac.service.ts:93,97</code>); 409 on slug clash; 403</td></tr> <tr><td>Success</td><td>200 <code>data: Role</code> (updated doc)</td></tr> </tbody></table> </div> <h3 id="delete-role-s1-menu"><a class="header" href="#delete-role-s1-menu">Delete role (S1 menu)</a></h3> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>DELETE /rbac/roles/:id</code> (<code>rbac.controller.ts:51-55</code>)</td></tr> <tr><td>Behaviour</td><td>soft delete (<code>rbac.service.ts:106</code>); 400 system role (<code>rbac.service.ts:104-105</code>); 404 unknown (<code>rbac.service.ts:103</code>)</td></tr> <tr><td>Success</td><td>200 <code>data: <void></code> (empty)</td></tr> </tbody></table> </div> <h3 id="members-list-s4"><a class="header" href="#members-list-s4">Members list (S4)</a></h3> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>GET /rbac/members</code> (<code>rbac.controller.ts:57-61</code>)</td></tr> <tr><td>Success</td><td>200 <code>data: Member[]</code> — array, no meta (<code>rbac.service.ts:109-111</code>)</td></tr> <tr><td>Sort</td><td><code>joinedAt: -1</code> desc (<code>rbac.service.ts:110</code>)</td></tr> <tr><td>Member doc shape</td><td><code>{_id, tenantId, userId, organizationId?, roles[], permissions[], status, joinedAt, invitedBy?, acceptedAt?, lastActiveAt?, metadata?, createdAt, updatedAt, version}</code> (<code>organization-member.schema.ts:14-44</code>)</td></tr> <tr><td>Note</td><td>no user-profile join (OQ-R7); <code>permissions[]</code> field exists but is never written by the service (derived perms only, <code>rbac.service.ts:63-65</code>)</td></tr> </tbody></table> </div> <h3 id="add-member-s5"><a class="header" href="#add-member-s5">Add member (S5)</a></h3> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>POST /rbac/members</code> (<code>rbac.controller.ts:63-67</code>)</td></tr> <tr><td>Request</td><td><code>AddMemberDto</code>: <code>{userId (MongoId), roles: string[]}</code> (<code>add-member.dto.ts:4-12</code>)</td></tr> <tr><td>Behaviour</td><td>creates <code>status:"active"</code>, <code>joinedAt: now</code>, tenant-scoped (<code>rbac.service.ts:113-127</code>)</td></tr> <tr><td>Errors</td><td>400 validation; 403; <strong>500 on duplicate</strong> (E11000 on <code>{tenantId,userId}</code> index <code>organization-member.schema.ts:48</code> — unmapped, OQ-R5) → client pre-checks</td></tr> <tr><td>Success</td><td>200/201 <code>data: Member</code></td></tr> </tbody></table> </div> <h3 id="update-member-roles-s4-menu-edit-s5-edit-mode"><a class="header" href="#update-member-roles-s4-menu-edit-s5-edit-mode">Update member roles (S4 menu edit, S5 edit mode)</a></h3> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>PATCH /rbac/members/:id</code> (<code>rbac.controller.ts:69-73</code>)</td></tr> <tr><td>Request</td><td><code>AddMemberDto</code> — <strong><code>userId</code> ignored</strong> by service (<code>rbac.controller.ts:72</code>; <code>rbac.service.ts:129-136</code> <code>$set:{roles}</code>)</td></tr> <tr><td>Errors</td><td>404 <code>RESOURCE_NOT_FOUND</code> (<code>rbac.service.ts:134</code>); 400; 403</td></tr> <tr><td>Success</td><td>200 <code>data: Member</code></td></tr> </tbody></table> </div> <h3 id="remove-member-s4-menu"><a class="header" href="#remove-member-s4-menu">Remove member (S4 menu)</a></h3> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>DELETE /rbac/members/:id</code> (<code>rbac.controller.ts:75-79</code>)</td></tr> <tr><td>Behaviour</td><td>soft delete; <strong>silent 200 even if not found</strong> (<code>rbac.service.ts:138-140</code>)</td></tr> <tr><td>Success</td><td>200 <code>data: <void></code></td></tr> </tbody></table> </div> <h3 id="permission-audit-s6--cross-module-read"><a class="header" href="#permission-audit-s6--cross-module-read">Permission audit (S6) — cross-module read</a></h3> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>GET /audit-logs?page=&limit=&action=&entityType=&actorId=</code> (<code>audit.controller.ts:17-34</code>)</td></tr> <tr><td>Guard</td><td><code>JwtAuthGuard</code> only today (<code>audit.controller.ts:9</code>) — <code>audit.read</code> not enforced (Phase-5 <code>(planned)</code>; client gates anyway)</td></tr> <tr><td>Success</td><td>200 paginated: <code>data: AuditLog[]</code> + <code>meta: {page, limit, totalItems, totalPages, hasNext, hasPrevious}</code> (<code>07 §2</code>; default limit 50, <code>audit.controller.ts:20</code>)</td></tr> <tr><td>Filter</td><td>action / entityType / actorId combined (<code>audit.controller.ts:26-29</code>)</td></tr> </tbody></table> </div> <h2 id="3-error-code-map-rbac-screens"><a class="header" href="#3-error-code-map-rbac-screens">3. Error-code map (RBAC screens)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>code</th><th>UI</th></tr></thead><tbody> <tr><td>any list</td><td>401</td><td>silent refresh → fail → sessionExpired</td></tr> <tr><td>any</td><td>403 <code>PERMISSION_DENIED</code></td><td>403 screen w/ cause copy; hide offending action</td></tr> <tr><td>role save</td><td>409 <code>DUPLICATE_RESOURCE</code></td><td>inline under slug + focus</td></tr> <tr><td>role save/delete</td><td>400 (system role)</td><td>unreachable via UI; banner fallback</td></tr> <tr><td>role update/delete</td><td>404</td><td>treat as removed → pop to list + snackbar</td></tr> <tr><td>member add</td><td>500 (dup, E11000)</td><td>pre-checked; fallback conflict banner + list refresh</td></tr> <tr><td>member update</td><td>404</td><td>row removed snackbar</td></tr> <tr><td>any write</td><td>429</td><td>countdown banner, no auto-retry</td></tr> <tr><td>any</td><td>5xx</td><td>generic + requestId (<code>http-exception.filter.ts:60-65</code>)</td></tr> </tbody></table> </div> <h2 id="4-endpoint-coverage-audit-forward-looking"><a class="header" href="#4-endpoint-coverage-audit-forward-looking">4. Endpoint coverage audit (forward-looking)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Controller group</th><th>Status today</th><th>Source</th></tr></thead><tbody> <tr><td>webhooks (11)</td><td><code>@Permissions('webhook.*')</code> on all</td><td><code>webhooks.controller.ts:22-86</code></td></tr> <tr><td>files (5)</td><td><code>@Permissions('file.*')</code> on all</td><td><code>files.controller.ts:30-67</code></td></tr> <tr><td>scheduler (3)</td><td><code>@Permissions('scheduler.*')</code> on all</td><td><code>scheduler.controller.ts:24-38</code></td></tr> <tr><td>search (1)</td><td><code>@Permissions('search')</code></td><td><code>search.controller.ts:16</code></td></tr> <tr><td>rbac (8)</td><td><code>@Roles('org_admin')</code> class-level</td><td><code>rbac.controller.ts:21</code></td></tr> <tr><td>all other modules</td><td>global guards only (authed)</td><td><code>app.module.ts:129-133</code></td></tr> <tr><td>full permission audit</td><td><code>(planned)</code> Phase 5</td><td><code>docs/IMPLEMENTATION_PLAN.md:241</code></td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-rbac-module"><a class="header" href="#13--state-management-rbac-module">13 — State Management (RBAC Module)</a></h1> <ul> <li><a href="rbac/13_State_Management.html#1-cubits">1. Cubits</a></li> <li><a href="rbac/13_State_Management.html#2-permissionmirror--client-side-permission-state-core">2. <code>PermissionMirror</code> — client-side permission state (core)</a></li> <li><a href="rbac/13_State_Management.html#3-cross-screen-data-flow">3. Cross-screen data flow</a></li> <li><a href="rbac/13_State_Management.html#4-guard-composition-client-routes">4. Guard composition (client routes)</a></li> <li><a href="rbac/13_State_Management.html#5-editor-state-persistence">5. Editor state persistence</a></li> <li><a href="rbac/13_State_Management.html#6-member-merge-strategy">6. Member merge strategy</a></li> <li><a href="rbac/13_State_Management.html#7-optimistic-updates--allowed-list">7. Optimistic updates — allowed list</a></li> <li><a href="rbac/13_State_Management.html#8-realtime--refresh">8. Realtime & refresh</a></li> <li><a href="rbac/13_State_Management.html#9-testing-hooks">9. Testing hooks</a></li> </ul> <blockquote> <p>Cubit architecture for the RBAC surface, on <a href="rbac/../00-shared/06_State_Management.html">00-shared/06</a>. Server model recap: JWT carries <code>roles</code> claim (minted at login, <code>auth.service.ts:145-154</code>); effective permissions resolve via Redis-cached <code>getPermissionsForUser</code> (<code>rbac.service.ts:44-73</code>); the client mirrors this with its own permission cache.</p> </blockquote> <hr /> <h2 id="1-cubits-2"><a class="header" href="#1-cubits-2">1. Cubits</a></h2> <div class="table-wrapper"><table><thead><tr><th>Cubit</th><th>State</th><th>API</th></tr></thead><tbody> <tr><td><code>RoleListCubit</code></td><td><code>LoadState</code> + <code>List<Role></code> + <code>searchQuery</code></td><td><code>GET /rbac/roles</code></td></tr> <tr><td><code>RoleEditorCubit</code></td><td><code>LoadState</code> + <code>Role?</code> + <code>Set<String> selected</code> + <code>List<String> catalog</code> + <code>formFields</code> + <code>saving</code></td><td><code>GET /rbac/permissions</code>, <code>POST /rbac/roles</code>, <code>PATCH /rbac/roles/:id</code></td></tr> <tr><td><code>MemberListCubit</code></td><td><code>LoadState</code> + <code>List<MemberView></code> + <code>searchQuery</code></td><td><code>GET /rbac/members</code> + <code>user.read</code> merge</td></tr> <tr><td><code>MemberSheetCubit</code></td><td><code>LoadState</code> + <code>User? selected</code> + <code>Set<String> roles</code> + <code>saving</code> + <code>editMode</code></td><td><code>POST /rbac/members</code>, <code>PATCH /rbac/members/:id</code>, <code>GET /rbac/roles</code> (picker)</td></tr> <tr><td><code>AuditCubit</code></td><td><code>LoadState</code> + <code>List<AuditRow></code> + filters + <code>PaginatedListMixin</code></td><td><code>GET /audit-logs</code></td></tr> </tbody></table> </div> <p>All follow <code>LoadState</code> (<a href="rbac/../00-shared/06_State_Management.html">00-shared/06 §3.1</a>); audit uses <code>PaginatedListMixin</code> (§3.2).</p> <p><strong>Events:</strong> <code>Load</code>, <code>Refresh</code>, <code>Retry</code>, <code>ChangeSearch</code>, <code>TogglePermission</code>, <code>ToggleGroup</code>, <code>Save</code>, <code>ClearFilters</code> — naming per <a href="rbac/../00-shared/06_State_Management.html">00-shared/06 §4</a>.</p> <h2 id="2-permissionmirror--client-side-permission-state-core"><a class="header" href="#2-permissionmirror--client-side-permission-state-core">2. <code>PermissionMirror</code> — client-side permission state (core)</a></h2> <p>The client's analog of <code>rbac.service.ts:44-73</code>:</p> <pre><code class="language-dart">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 } </code></pre> <p>Rules:</p> <ul> <li><strong>Server is authoritative.</strong> The mirror is a cache, never a decision-maker — every critical action still surfaces server 403s.</li> <li>TTL mirrors the server: ≤ 300 s freshness; <code>invalidates</code> on own writes (create/ update/delete role, add/update/remove member) and on login.</li> <li><strong>Route rebuild:</strong> when the mirror resolves a changed permission set for the current user (login, manual refresh, own RBAC write, session resume), emit <code>permissionsChanged</code> → <code>AppRouter.refresh()</code> → hidden/unroutable destinations update (<code>05 §9</code>, <code>11_Flutter_App_Architecture.md §6</code>).</li> <li><strong>JWT roles claim is separate</strong> (<code>jwt-payload.interface.ts:3</code>): <code>AuthCubit</code> holds it; role-claim changes require re-login (OQ-R4) — the client offers "Refresh session" after self-role edits (<code>09 §3</code>).</li> </ul> <h2 id="3-cross-screen-data-flow"><a class="header" href="#3-cross-screen-data-flow">3. Cross-screen data flow</a></h2> <pre><code>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 </code></pre> <ul> <li>Shared selectors: <code>currentUser</code>, <code>currentTenant</code> (<code>00-shared/06 §4</code>).</li> <li>No global singleton holds screen state; cubits are <code>get_it</code> lazy factories.</li> </ul> <h2 id="4-guard-composition-client-routes"><a class="header" href="#4-guard-composition-client-routes">4. Guard composition (client routes)</a></h2> <p>Current server reality (role-gated <code>/rbac/*</code>, <code>rbac.controller.ts:21</code>) vs intended perm-gated model → combined guard (see <code>04 §3</code>):</p> <pre><code class="language-dart">roleOrPermissionGuard(roles: ['org_admin'], permissions: ['rbac.role.read']) </code></pre> <p>Resolves true if the JWT claims <code>org_admin</code> <strong>or</strong> the mirror grants <code>rbac.role.read</code> — 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: <code>permissionGuard('audit.read')</code> (server lags, <code>audit.controller.ts:9</code> — <code>(planned)</code>).</p> <h2 id="5-editor-state-persistence"><a class="header" href="#5-editor-state-persistence">5. Editor state persistence</a></h2> <ul> <li><code>RoleEditorCubit</code> holds <code>selected</code> (Set<String>) across backgrounding/session expiry (in-memory; survives re-login per <code>09 §9</code>).</li> <li>Dirty tracking: <code>formFields != original</code> or <code>selected != role.permissions</code> → back confirm (<code>10 §4</code>).</li> <li>Save: one PATCH with the full array (<code>rbac.service.ts:96</code>); no per-cell writes.</li> </ul> <h2 id="6-member-merge-strategy"><a class="header" href="#6-member-merge-strategy">6. Member merge strategy</a></h2> <p><code>MemberListCubit</code> fetches <code>GET /rbac/members</code> + <code>GET /api/v1/users</code> (user.read, cached 24 h) → <code>MemberView {member, user?}</code>; missing user → fallback tile (userId mono, <code>06 §S4</code>). Merge is display-only; writes send <code>member.userId</code> unchanged (OQ-R7).</p> <h2 id="7-optimistic-updates--allowed-list"><a class="header" href="#7-optimistic-updates--allowed-list">7. Optimistic updates — allowed list</a></h2> <div class="table-wrapper"><table><thead><tr><th>Action</th><th>Optimistic?</th><th>Rule</th></tr></thead><tbody> <tr><td>Toggle permission / group</td><td>yes (local set)</td><td>single PATCH on Save; rollback on error</td></tr> <tr><td>Add member</td><td>no</td><td>server-confirm, then insert row</td></tr> <tr><td>Remove member / delete role</td><td>no</td><td>server-confirm, then remove row (irreversible-ish, <code>09 §4</code>)</td></tr> <tr><td>Role perm count badge</td><td>yes</td><td>derived from editor state</td></tr> </tbody></table> </div> <h2 id="8-realtime--refresh"><a class="header" href="#8-realtime--refresh">8. Realtime & refresh</a></h2> <ul> <li>No WS topic for RBAC changes exists (server <code>WsModule</code> topics list has none — <code>00-shared/07 §8</code>; OQ-R9). Cross-admin freshness = pull-to-refresh + stale-while- revalidate only.</li> <li>On app resume: refetch roles/members lists if stale > 5 min; re-validate mirror.</li> </ul> <h2 id="9-testing-hooks"><a class="header" href="#9-testing-hooks">9. Testing hooks</a></h2> <ul> <li>Cubits pure-Dart with mocked repositories; <code>PermissionMirror</code> unit-tested for SWR/TTL/invalidate semantics; widget tests for 3-state screens (<code>00-shared/06 §6</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-rbac-module"><a class="header" href="#14--qa-checklist-rbac-module">14 — QA Checklist (RBAC Module)</a></h1> <ul> <li><a href="rbac/14_QA_Checklist.html#1-permission-matrix--edge-cases">1. Permission matrix — edge cases</a></li> <li><a href="rbac/14_QA_Checklist.html#2-roles-crud--custom-role-persistence">2. Roles CRUD & custom-role persistence</a></li> <li><a href="rbac/14_QA_Checklist.html#3-members--403-behaviour">3. Members & 403 behaviour</a></li> <li><a href="rbac/14_QA_Checklist.html#4-tenant-isolation">4. Tenant isolation</a></li> <li><a href="rbac/14_QA_Checklist.html#5-privilege-escalation-scenarios-security-focus">5. Privilege-escalation scenarios (security focus)</a></li> <li><a href="rbac/14_QA_Checklist.html#6-wire-contract-checks">6. Wire-contract checks</a></li> <li><a href="rbac/14_QA_Checklist.html#7-performance--a11y-from-00-shared10-rbac-specific">7. Performance & a11y (from 00-shared/10, RBAC-specific)</a></li> <li><a href="rbac/14_QA_Checklist.html#8-regression-hooks-test-plan-linkage">8. Regression hooks (test plan linkage)</a></li> </ul> <blockquote> <p>Module-specific QA on top of <a href="rbac/../00-shared/10_QA_Baseline.html">00-shared/10</a>. Every scenario below traces to a server behaviour (<code>rbac.service.ts</code>, <code>rbac.guard.ts</code>, <code>role.schema.ts</code>, <code>organization-member.schema.ts</code>) or a wire-contract rule. Cross-ref: <code>PLAN.md</code> rows 1.5 (<code>PLAN.md:17</code>), 10.1-10.5 (<code>PLAN.md:106-110</code>), 12.x (<code>PLAN.md:127-132</code>), 14.x (<code>PLAN.md:146-148</code>), 19.x (<code>PLAN.md:189-191</code>).</p> </blockquote> <hr /> <h2 id="1-permission-matrix--edge-cases"><a class="header" href="#1-permission-matrix--edge-cases">1. Permission matrix — edge cases</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expected</th><th>Source</th></tr></thead><tbody> <tr><td>M1</td><td>Catalog has exactly 95 perms; matrix renders all 26 groups; count badge = 95</td><td>no truncation, scrollable</td><td><code>permissions.constants.ts:1-97</code></td></tr> <tr><td>M2</td><td>Toggle all in group → group checkbox checked; toggle one → indeterminate</td><td>tri-state correct</td><td><a href="rbac/06_Screen_Specifications.html">06 §3</a></td></tr> <tr><td>M3</td><td>Search "pay" → only payments/receipts group + perms; 0-match groups collapse</td><td>filter correct; liveRegion count</td><td><a href="rbac/07_Component_Library.html">07 §1-2</a></td></tr> <tr><td>M4</td><td>Save with empty <code>permissions</code> → warning shown, POST succeeds (role like <code>student</code>, <code>role.schema.ts:63</code>)</td><td>empty allowed</td><td><a href="rbac/08_Form_Specifications.html">08 §1</a></td></tr> <tr><td>M5</td><td>Permission string not in catalog (client injection attempt) → blocked client-side</td><td>whitelist enforced in UI</td><td>server <code>@IsArray</code> only, <code>create-role.dto.ts:36</code></td></tr> <tr><td>M6</td><td>Role with 95 perms (clone org_admin) → save round-trip < 1.5 s p95</td><td>single PATCH</td><td><code>rbac.service.ts:96</code></td></tr> <tr><td>M7</td><td>Rapid toggling while saving → anti-double-submit; state not corrupted</td><td>disabled during save</td><td><a href="rbac/10_Interaction_Specification.html">10 §4</a></td></tr> <tr><td>M8</td><td>95-perm matrix at text scale 2× → no clipping; chips wrap</td><td><a href="rbac/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §4</a></td><td>—</td></tr> <tr><td>M9</td><td>Matrix in dark mode — chip contrast ≥ 4.5:1</td><td>token check</td><td><a href="rbac/11_Design_System_Mapping.html">11 §6</a></td></tr> </tbody></table> </div> <h2 id="2-roles-crud--custom-role-persistence"><a class="header" href="#2-roles-crud--custom-role-persistence">2. Roles CRUD & custom-role persistence</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expected</th><th>Source</th></tr></thead><tbody> <tr><td>R1</td><td>Create role → appears in Roles list under Custom section; persists across app restart (server fetch)</td><td>persistence</td><td><code>rbac.service.ts:83-89</code></td></tr> <tr><td>R2</td><td>Create with existing slug → 409 inline, no state loss (form preserved)</td><td>error mapping</td><td><code>rbac.service.ts:86-87</code></td></tr> <tr><td>R3</td><td>Create without forcing <code>isSystem:false</code> (bug path) → server would lock; UI always sends <code>false</code></td><td>regression guard (OQ-R2)</td><td><code>role.schema.ts:78-79</code></td></tr> <tr><td>R4</td><td>Edit custom role perms → PATCH 200 → detail refreshed; holders see change ≤ 5 min (cache)</td><td>propagation banner</td><td><code>rbac.service.ts:68,96</code></td></tr> <tr><td>R5</td><td>System role edit/delete → UI blocks (no editor, no delete); API 400 fallback shown if reached</td><td>locked</td><td><code>rbac.service.ts:94-95,104-105</code></td></tr> <tr><td>R6</td><td>Delete custom role → soft-deleted; gone from list; members keep stale slug chip (render-only)</td><td><code>rbac.service.ts:106</code>; <a href="rbac/09_User_Behaviour.html">09 §2</a></td><td></td></tr> <tr><td>R7</td><td>Role slug immutability communicated; PATCH slug would orphan member arrays — UI treats as immutable</td><td><a href="rbac/08_Form_Specifications.html">08 §1</a></td><td>—</td></tr> <tr><td>R8</td><td>Duplicate slug on <strong>update</strong> → 409 handled identically to create</td><td><code>rbac.service.ts:86-87</code> (create path only — update path 409 via unique index; OQ-R11)</td><td>—</td></tr> </tbody></table> </div> <h2 id="3-members--403-behaviour"><a class="header" href="#3-members--403-behaviour">3. Members & 403 behaviour</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expected</th><th>Source</th></tr></thead><tbody> <tr><td>B1</td><td>Non-org_admin (e.g. teacher JWT) calls <code>/rbac/roles</code> → 403 <code>PERMISSION_DENIED</code></td><td>envelope; no data</td><td><code>rbac.controller.ts:21</code>, <code>rbac.guard.ts:38-42</code></td></tr> <tr><td>B2</td><td>Client routes hidden for teacher; direct deep link → 403 screen with cause copy + requestId</td><td><code>PLAN.md:17</code> (1.5)</td><td><a href="rbac/04_Information_Architecture.html">04 §3</a>, <a href="rbac/06_Screen_Specifications.html">06 §S6</a></td></tr> <tr><td>B3</td><td>Member with perm <code>rbac.member.read</code> but not org_admin → today 403 (OQ-R1) — UI must not promise access</td><td>flagged, not silent</td><td><code>rbac.controller.ts:21</code></td></tr> <tr><td>B4</td><td>Add duplicate member → client pre-check blocks; if server 500 slips through → conflict banner + refresh (OQ-R5)</td><td>no silent error</td><td><code>organization-member.schema.ts:48</code></td></tr> <tr><td>B5</td><td>Remove member → row gone; re-fetch confirms <code>isDeleted</code> filtered (<code>base.repository.ts:20-30</code>)</td><td><code>rbac.service.ts:138-140</code></td><td>—</td></tr> <tr><td>B6</td><td>Update member roles → PATCH 200; chips update; <code>userId</code> sent but ignored (DTO compat)</td><td><code>rbac.controller.ts:71-72</code></td><td>—</td></tr> <tr><td>B7</td><td>Remove own <code>org_admin</code> → typed confirm; after removal JWT still grants access until expiry — banner explains re-login</td><td><a href="rbac/09_User_Behaviour.html">09 §4</a></td><td>—</td></tr> </tbody></table> </div> <h2 id="4-tenant-isolation"><a class="header" href="#4-tenant-isolation">4. Tenant isolation</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expected</th><th>Source</th></tr></thead><tbody> <tr><td>T1</td><td>Tenant A admin lists roles/members → zero Tenant B docs (repo scoping)</td><td><code>base.repository.ts:20-30</code>; <code>PLAN.md:106</code> (10.1)</td><td>—</td></tr> <tr><td>T2</td><td>Tenant B user uses Tenant A JWT → 401/403 by token claims</td><td><code>jwt-auth.guard.ts:40-55</code>; <code>PLAN.md:107</code> (10.2)</td><td>—</td></tr> <tr><td>T3</td><td>Role slug collision across tenants → both create successfully (unique index is <code>{tenantId, slug}</code>)</td><td><code>role.schema.ts:89</code></td><td>—</td></tr> <tr><td>T4</td><td>Member <code>{tenantId,userId}</code> unique per tenant — same user in 2 tenants = 2 memberships (supported model, <code>COLLECTIONS.md:554-570</code>)</td><td><code>organization-member.schema.ts:48</code></td><td>—</td></tr> <tr><td>T5</td><td>Permission cache keys namespaced <code>sl:{tenantId}:perm:{userId}</code> — no cross-tenant cache leak</td><td><code>rbac.service.ts:48</code>; <code>PLAN.md:109</code> (10.4)</td><td>—</td></tr> <tr><td>T6</td><td>platform_admin reads across tenants via repo bypass — <strong>UI must not render tenant RBAC data cross-tenant without an org_admin token</strong></td><td><code>base.repository.ts:21</code>; <a href="rbac/02_User_Personas.html">02 §4</a></td><td>—</td></tr> <tr><td>T7</td><td><code>seedDefaults(org.slug)</code> vs <code>seedDefaults(user.tenantId)</code> inconsistency — verify tenantId == slug after org-created flow (OQ-R6)</td><td><code>organizations.service.ts:58</code>, <code>auth.service.ts:93</code></td><td>—</td></tr> </tbody></table> </div> <h2 id="5-privilege-escalation-scenarios-security-focus"><a class="header" href="#5-privilege-escalation-scenarios-security-focus">5. Privilege-escalation scenarios (security focus)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Attack / scenario</th><th>Defence</th><th>Source</th></tr></thead><tbody> <tr><td>P1</td><td>Client sets <code>permissions: ['organization.delete']</code> on own role via PATCH</td><td>Server: org_admin-role gate only; once perms are enforced (Phase-5 planned) the guard rejects. Client: matrix whitelist. <strong>Today: any org_admin can craft any body — inherent to role-gated admin; documented.</strong></td><td><code>rbac.guard.ts:43-49</code>, <code>docs/IMPLEMENTATION_PLAN.md:241</code></td></tr> <tr><td>P2</td><td>Client sets <code>isSystem:true</code> on create to lock role (no privilege gain — lock is restrictive)</td><td>no vector; still forced <code>false</code> in UI</td><td><code>create-role.dto.ts:27</code>, OQ-R2</td></tr> <tr><td>P3</td><td>Create role with slug <code>org_admin</code> → 409 (unique) — cannot shadow system role</td><td><code>role.schema.ts:89</code></td><td>—</td></tr> <tr><td>P4</td><td>Crafted perm string <code>'*'</code> or unknown <code>'a.b.c'</code> in roles → server stores it; guard <code>every()</code> fails for real endpoints (no grant) — matrix whitelist prevents UI path</td><td>whitelist</td><td><code>rbac.guard.ts:48</code></td></tr> <tr><td>P5</td><td>Member with removed role still calls role-checked endpoint → JWT claim valid until token expiry (≤ 15 m access) — document in security review (OQ-R4)</td><td>token TTL</td><td><code>env.ts</code> (15 m access), <code>auth.service.ts:192-196</code></td></tr> <tr><td>P6</td><td>platform_admin tries <code>/rbac/*</code> → 403 (not org_admin) — no tenant cross-read via RBAC UI</td><td><code>rbac.controller.ts:21</code></td><td>—</td></tr> <tr><td>P7</td><td>Audit: RBAC writes must appear in <code>audit-logs</code> — verify action strings recorded for role/member CRUD (exact strings OQ-R8)</td><td><code>PLAN.md:189-191</code> (19.x)</td><td>—</td></tr> </tbody></table> </div> <h2 id="6-wire-contract-checks"><a class="header" href="#6-wire-contract-checks">6. Wire-contract checks</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th></tr></thead><tbody> <tr><td>W1</td><td>Success envelope: <code>{success:true, message:"OK", data, meta?, timestamp, requestId}</code> — arrays without <code>meta</code> for roles/members; with <code>meta</code> for audit</td></tr> <tr><td>W2</td><td>Error codes: 400 <code>VALIDATION_ERROR</code> (field details), 403 <code>PERMISSION_DENIED</code>, 404 <code>RESOURCE_NOT_FOUND</code>, 409 <code>DUPLICATE_RESOURCE</code>, 429 <code>RATE_LIMITED</code>, 5xx <code>INTERNAL_SERVER_ERROR</code></td></tr> <tr><td>W3</td><td>requestId echoed; client-generated <code>x-request-id</code> honored</td></tr> <tr><td>W4</td><td>CastError (bad <code>:id</code>) → 400 "Invalid resource identifier."</td></tr> <tr><td>W5</td><td>429 on RBAC in production only; countdown copy; no auto-retry</td></tr> </tbody></table> </div> <h2 id="7-performance--a11y-from-00-shared10-rbac-specific"><a class="header" href="#7-performance--a11y-from-00-shared10-rbac-specific">7. Performance & a11y (from <a href="rbac/../00-shared/10_QA_Baseline.html">00-shared/10</a>, RBAC-specific)</a></h2> <ul> <li>Matrix first frame < 300 ms (cached catalog), scroll 60 fps on mid-range device; <code>RepaintBoundary</code> per group (<a href="rbac/15_Flutter_Implementation_Guide.html">15 §2</a>).</li> <li>TalkBack: matrix chip toggle announces group + checked state; audit table headers announced; typed self-removal confirm readable.</li> <li>Text scale 2× on matrix/roles/members; dynamic type no clipping (<code>M8</code>).</li> <li>Dark/light golden per screen (S1–S6).</li> </ul> <h2 id="8-regression-hooks-test-plan-linkage"><a class="header" href="#8-regression-hooks-test-plan-linkage">8. Regression hooks (test plan linkage)</a></h2> <ul> <li>Unit: <code>PermissionMirror</code> TTL/invalidate; matrix tri-state logic; slug validator.</li> <li>Widget: S1–S6 × {loading, error, empty, content}; 403 screen.</li> <li>Integration: J1 (create role), J3 (add member), J4 (accountant/HR custom role), J5 (guard-denied) — mapped to <code>PLAN.md:17</code> (1.5), <code>PLAN.md:107-110</code> (10.x).</li> <li>E2E: cross-tenant isolation (T1–T6) with two seeded tenants.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-rbac-module"><a class="header" href="#15--flutter-implementation-guide-rbac-module">15 — Flutter Implementation Guide (RBAC Module)</a></h1> <ul> <li><a href="rbac/15_Flutter_Implementation_Guide.html#1-folder-structure">1. Folder structure</a></li> <li><a href="rbac/15_Flutter_Implementation_Guide.html#2-permissionmatrix--performance-spec-hero">2. PermissionMatrix — performance spec (hero)</a></li> <li><a href="rbac/15_Flutter_Implementation_Guide.html#3-permission-mirror-caching-13-2">3. Permission-mirror caching (<code>13 §2</code>)</a></li> <li><a href="rbac/15_Flutter_Implementation_Guide.html#4-route-guards-go_router">4. Route guards (<code>go_router</code>)</a></li> <li><a href="rbac/15_Flutter_Implementation_Guide.html#5-repository--networking">5. Repository & networking</a></li> <li><a href="rbac/15_Flutter_Implementation_Guide.html#6-i18n--error-copy">6. i18n & error copy</a></li> <li><a href="rbac/15_Flutter_Implementation_Guide.html#7-tests">7. Tests</a></li> <li><a href="rbac/15_Flutter_Implementation_Guide.html#8-performance-checklist-rbac-specific">8. Performance checklist (RBAC-specific)</a></li> </ul> <blockquote> <p>Implementation guidance on top of <a href="rbac/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a>. Forward-looking: no client repo yet. Focus areas: <strong>matrix widget performance</strong>, <strong>permission-mirror caching</strong>, <strong>route guards</strong>, plus module folder structure, DTOs, and tests.</p> </blockquote> <hr /> <h2 id="1-folder-structure-9"><a class="header" href="#1-folder-structure-9">1. Folder structure</a></h2> <pre><code>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 </code></pre> <p>DTOs map envelope payloads (<code>00-shared/11 §4</code>); widgets consume models only.</p> <h2 id="2-permissionmatrix--performance-spec-hero"><a class="header" href="#2-permissionmatrix--performance-spec-hero">2. PermissionMatrix — performance spec (hero)</a></h2> <p><strong>The requirement:</strong> 95 chips in 26 groups, instant toggle, 60 fps scroll on a mid-range phone (<code>00-shared/10 §1</code>).</p> <ul> <li><strong>Lazy build:</strong> one <code>ListView.builder</code> of group sections — never a <code>Column</code> of 95 chips (build cost would blow the 8 ms frame budget on scroll).</li> <li><strong><code>RepaintBoundary</code> per <code>PermissionGroupSection</code></strong> — toggling a chip repaints one section, not the whole matrix.</li> <li><strong>Const everywhere:</strong> chips are <code>const</code>-constructible (label, selection state as <code>Set<String></code> membership check) → <code>FilterChip</code> inside <code>Wrap</code>; <code>Wrap</code> is fine per group (≤ 13 chips, <code>transport.*</code> max) but the <em>matrix</em> must not be one giant <code>Wrap</code>.</li> <li><strong>Search:</strong> precomputed group→matches index; filter collapses sections without touching the list (no rebuild of chips).</li> <li><strong>State:</strong> <code>selected</code> as <code>Set<String></code> (hash membership O(1)); group tri-state computed in <code>didUpdateWidget</code>-style recalculation, memoized per group.</li> <li><strong>Save payload:</strong> single <code>List<String></code> — <code>jsonEncode</code> of the set at Save only.</li> <li><strong>Golden tests:</strong> 3 sizes × light/dark × editable/readOnly with the full 95-perm fixture; profile frame-build < 16 ms on reference device.</li> <li><code>(planned)</code> coaching perms (<code>docs/IMPLEMENTATION_PLAN.md:751-761</code>) render only when the server catalog includes them — group list must tolerate unknown prefixes (fallback group "Other").</li> </ul> <h2 id="3-permission-mirror-caching-13-2"><a class="header" href="#3-permission-mirror-caching-13-2">3. Permission-mirror caching (<code>13 §2</code>)</a></h2> <ul> <li><strong>In-memory <code>Map<String, List<String>></code></strong> keyed <code>'sl:{tenantId}:perm:{userId}'</code> (mirrors <code>rbac.service.ts:48</code>) + <strong>Hive</strong> persistence per tenant (key <code>perm_mirror:{tenantId}</code>), <code>shared_preferences</code> too small for 95-string lists.</li> <li><strong>Stale-while-revalidate:</strong> serve cached instantly; background refetch; update on success; TTL ≤ 300 s aligned to server EX 300 (<code>rbac.service.ts:68</code>).</li> <li><strong>Invalidate</strong> on: login/logout, own RBAC writes (role save/delete, member add/edit/remove), manual "Refresh session".</li> <li><strong>No mirror → gate open?</strong> 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 (<code>00-shared/06 §5</code>) and the mirror re-syncs.</li> <li>Never ship the 95 names as a hardcoded client constant — catalog must come from <code>GET /rbac/permissions</code> (<code>rbac.service.ts:79-81</code>); mirror is cache, catalog is data.</li> </ul> <h2 id="4-route-guards-go_router"><a class="header" href="#4-route-guards-go_router">4. Route guards (<code>go_router</code>)</a></h2> <pre><code class="language-dart">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'; }; </code></pre> <ul> <li>Role-or-permission composition per <code>04 §3</code> / <code>13 §4</code> — covers both the current role-gated server and the perm-gated future without a client rewrite.</li> <li><strong>Route rebuild on change:</strong> <code>PermissionMirror.permissionsChanged</code> stream → <code>AppRouter.refresh()</code> (05 §9); rebuild also on <code>AuthCubit</code> auth change (re-login with new roles claim).</li> <li>Deep-link to removed route → 403 screen (never blank); session-expiry overlay wins over 403 (401 takes precedence, <code>00-shared/06 §3.6</code>).</li> </ul> <h2 id="5-repository--networking"><a class="header" href="#5-repository--networking">5. Repository & networking</a></h2> <ul> <li><code>RbacRepository</code> methods: <code>listRoles()</code>, <code>listPermissions()</code>, <code>createRole(dto)</code>, <code>updateRole(id, dto)</code>, <code>deleteRole(id)</code>, <code>listMembers()</code>, <code>addMember(dto)</code>, <code>updateMemberRoles(id, dto)</code>, plus <code>queryAudit(filters)</code> (audit-logs via <code>00-shared/11 §5</code> <code>AppDio</code>).</li> <li>Envelope mapping → typed <code>ApiException(code, status, message)</code>; 409 → <code>Duplicate</code> surfaced inline; 500-on-duplicate-member pre-checked (<code>08 §2</code>).</li> <li><code>Idempotency-Key</code> header on <code>POST /rbac/members</code> (write-once op, <code>00-shared/07 §9</code>).</li> </ul> <h2 id="6-i18n--error-copy"><a class="header" href="#6-i18n--error-copy">6. i18n & error copy</a></h2> <ul> <li>Keys: <code>rbac.title</code>, <code>rbac.role.create</code>, <code>rbac.matrix.count("{sel}","{total}")</code>, <code>rbac.system.role.locked</code>, <code>rbac.propagation.note</code>, <code>rbac.self.remove.confirm</code>, <code>perm.{group}.{name}</code> (humanized labels, fallback raw string), 403 screen copy.</li> <li>Server messages (e.g. "Cannot modify system roles.") rendered only as fallback for business 4xx (<code>00-shared/07 §11</code>).</li> </ul> <h2 id="7-tests-1"><a class="header" href="#7-tests-1">7. Tests</a></h2> <ul> <li><strong>Unit:</strong> mirror TTL/invalidate; matrix tri-state; slug validator <code>^[a-z0-9_]{2,40}$</code>; group mapping of all 95 perms (fixture from <code>permissions.constants.ts:1-97</code>).</li> <li><strong>Widget:</strong> S1–S6 × {loading, error, empty, content}; matrix golden (3 sizes, dark/light); 403 page.</li> <li><strong>Integration:</strong> J1 create-role → assign → member add → re-login (J4); guard-denied (J5) with mocked 403 envelope.</li> <li><strong>E2E:</strong> tenant isolation with two tenants (T1–T6); permission propagation ≤ 5 min (M4/R4).</li> </ul> <h2 id="8-performance-checklist-rbac-specific"><a class="header" href="#8-performance-checklist-rbac-specific">8. Performance checklist (RBAC-specific)</a></h2> <ul> <li><input disabled="" type="checkbox"/> Matrix first frame < 300 ms with cached catalog; scroll 60 fps profiled</li> <li><input disabled="" type="checkbox"/> No rebuild of all 95 chips on single toggle (RepaintBoundary verified)</li> <li><input disabled="" type="checkbox"/> Mirror reads synchronous from memory (no disk read on hot path)</li> <li><input disabled="" type="checkbox"/> 95-perm PATCH serialization < 10 ms</li> <li><input disabled="" type="checkbox"/> Memory: no growth across 20 min matrix session (<code>00-shared/10 §1</code>)</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-reports-module"><a class="header" href="#01--product-overview-reports-module">01 — Product Overview (Reports Module)</a></h1> <ul> <li><a href="reports/01_Product_Overview.html#1-purpose">1. Purpose</a></li> <li><a href="reports/01_Product_Overview.html#2-business-goals">2. Business goals</a></li> <li><a href="reports/01_Product_Overview.html#3-scope--implemented-today">3. Scope — implemented today</a></li> <li><a href="reports/01_Product_Overview.html#4-out-of-scope--not-yet-implemented">4. Out of scope / not yet implemented</a></li> <li><a href="reports/01_Product_Overview.html#5-platform-context">5. Platform context</a></li> <li><a href="reports/01_Product_Overview.html#6-prd-native-app-exclusion-flagged">6. PRD native-app exclusion (flagged)</a></li> <li><a href="reports/01_Product_Overview.html#7-success-metrics">7. Success metrics</a></li> <li><a href="reports/01_Product_Overview.html#8-glossary-module-specific">8. Glossary (module-specific)</a></li> </ul> <blockquote> <p>Async report generation and export. Client-facing definition of the Reports module against the NestJS backend. Source of truth: <code>studylyon-blueprint/04-Modules/Reports.md</code> and <code>src/modules/reports/**</code>. Shared platform contract: <a href="reports/../00-shared/01_Product_Overview.html">00-shared/01</a>.</p> </blockquote> <hr /> <h2 id="1-purpose-19"><a class="header" href="#1-purpose-19">1. Purpose</a></h2> <p>Reports lets institution users request heavy operational/academic data extractions (report cards, attendance summaries, fee summaries) that are generated <strong>asynchronously</strong> — the request returns a <code>jobId</code> immediately and the client polls until the job completes, then downloads or views the result.</p> <p>Key rule from blueprint (<code>studylyon-blueprint/04-Modules/Reports.md:48</code>): "All generation is async (202 Accepted + job polling)."</p> <h2 id="2-business-goals-17"><a class="header" href="#2-business-goals-17">2. Business goals</a></h2> <div class="table-wrapper"><table><thead><tr><th>Goal</th><th>Measure</th></tr></thead><tbody> <tr><td>Long-running generation never blocks the API</td><td>POST returns in < 500 ms with <code>jobId</code></td></tr> <tr><td>Users see progress, not blank screens</td><td>job status surfaced: queued → processing → completed/failed</td></tr> <tr><td>Results are repeatable</td><td>job doc keeps <code>params</code> + <code>result</code>; re-request is cheap</td></tr> <tr><td>Tenant isolation</td><td>every job doc carries <code>tenantId</code>; scoped via <code>BaseRepository</code></td></tr> <tr><td>No cross-role data leaks</td><td><code>report.generate</code> / <code>report.read</code> permissions defined (<code>permissions.constants.ts:39-40</code>)</td></tr> </tbody></table> </div> <h2 id="3-scope--implemented-today"><a class="header" href="#3-scope--implemented-today">3. Scope — implemented today</a></h2> <ul> <li><strong>3 report types</strong> (<code>report-job.schema.ts:7-11</code>): <code>report_card</code>, <code>attendance_summary</code>, <code>fee_summary</code>.</li> <li><strong>Job lifecycle</strong> (<code>report-job.schema.ts:13-18</code>): <code>queued</code> → <code>processing</code> → <code>completed</code> | <code>failed</code>, with <code>error</code> message and <code>completedAt</code> timestamp.</li> <li><strong>2 endpoints</strong> (<code>reports.controller.ts:14-24</code>): <code>POST /reports/generate</code>, <code>GET /reports/:jobId</code> (status + JSON result polling).</li> <li><strong>BullMQ worker</strong> on <code>report-generate</code> queue (<code>report.worker.ts:7</code>), tenant context restored per job.</li> <li><strong>Scheduled generation</strong>: <code>AttendanceReportJob</code> enqueues daily/weekly attendance reports into the same <code>report-generate</code> queue (<code>scheduler/jobs/attendance-report.job.ts:13-24</code>).</li> </ul> <h2 id="4-out-of-scope--not-yet-implemented"><a class="header" href="#4-out-of-scope--not-yet-implemented">4. Out of scope / not yet implemented</a></h2> <ul> <li><code>GET /reports/:jobId/download</code> exists <strong>only in the blueprint</strong> (<code>04-Modules/Reports.md:26</code>) — no controller route, no PDF/CSV serialization, no storage integration in code. Marked <code>(planned)</code> everywhere in this package.</li> <li>PDF / Excel / CSV export (<code>04-Modules/Reports.md:14</code>), streamed downloads (<code>:49</code>), file storage under <code>sl/{tenantId}/reports/{uuid}</code> with TTL cleanup (<code>:50</code>) — <code>(planned)</code>.</li> <li><code>ReportGenerated</code> domain event and completion notifications (<code>04-Modules/Reports.md:33,42</code>) — <code>(planned)</code>; no report route exists in <code>event-queue-map.ts</code>.</li> <li>Coaching report types <code>BATCH_PERFORMANCE</code>, <code>TEST_SERIES_ANALYSIS</code>, <code>DPP_COMPLETION</code> (<code>docs/IMPLEMENTATION_PLAN.md:646</code>) — <code>(planned)</code>.</li> <li>Templates, scheduling UI, email delivery, custom SQL (<code>docs/IMPLEMENTATION_PLAN.md:233</code>) — <code>(planned)</code>.</li> </ul> <h2 id="5-platform-context"><a class="header" href="#5-platform-context">5. Platform context</a></h2> <ul> <li><strong>Data sources (read-only)</strong>: Students, Attendance, Fees/Invoices, Results — wired in <code>reports.module.ts:20-23</code>.</li> <li><strong>Queue</strong>: <code>QUEUE.REPORT_GENERATE = 'report-generate'</code> (<code>infrastructure/bullmq/queue.constants.ts:10</code>).</li> <li><strong>Files module</strong> exists (<code>GET /files/:id/download</code>, <code>files.controller.ts:55-64</code>) and is the intended delivery path for generated files once PDF export lands — today report results are <strong>JSON stored on the job doc</strong> (<code>report-job.schema.ts:36</code>).</li> </ul> <h2 id="6-prd-native-app-exclusion-flagged"><a class="header" href="#6-prd-native-app-exclusion-flagged">6. PRD native-app exclusion (flagged)</a></h2> <p><a href="reports/../00-shared/01_Product_Overview.html">00-shared/01 §9</a>: <code>PRODUCT_REQUIREMENTS_DOCUMENT.md:144</code> keeps native mobile apps out of Phase 1; roadmap Phase 3 plans a read-only companion. <strong>Decision made with the product owner: these docs specify a full-featured Flutter client now, to the complete API surface.</strong> This package follows that decision; screens assume an authenticated, permission-gated mobile/tablet client.</p> <h2 id="7-success-metrics-12"><a class="header" href="#7-success-metrics-12">7. Success metrics</a></h2> <ul> <li>POST → <code>jobId</code> round trip < 500 ms (excluding queue time).</li> <li>Job status poll → visible state within one poll interval (default 2 s).</li> <li>P95 time-to-complete: report_card < 5 s, attendance/fee summaries < 2 s on reference dataset (measured per tenant size class).</li> <li>0% cross-tenant reads; <code>report.read</code> enforced on every result read.</li> </ul> <h2 id="8-glossary-module-specific"><a class="header" href="#8-glossary-module-specific">8. Glossary (module-specific)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Term</th><th>Meaning</th></tr></thead><tbody> <tr><td>Report job</td><td><code>report_jobs</code> document: <code>type, status, params, result, error, completedAt</code></td></tr> <tr><td>ReportType</td><td><code>report_card</code>, <code>attendance_summary</code>, <code>fee_summary</code></td></tr> <tr><td>Job status</td><td><code>queued</code> / <code>processing</code> / <code>completed</code> / <code>failed</code></td></tr> <tr><td>Polling contract</td><td><code>GET /reports/:jobId</code> until <code>completed</code>/<code>failed</code>, then render/download</td></tr> <tr><td>DLQ</td><td>Dead-letter queue for failed BullMQ jobs (global contract, <a href="reports/../00-shared/01_Product_Overview.html">00-shared/01 §10</a>)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-reports-module"><a class="header" href="#02--user-personas-reports-module">02 — User Personas (Reports Module)</a></h1> <ul> <li><a href="reports/02_User_Personas.html#p1--org-admin-schoolcoaching-owner">P1 — Org Admin (school/coaching owner)</a></li> <li><a href="reports/02_User_Personas.html#p2--principal--head-of-academics">P2 — Principal / Head of Academics</a></li> <li><a href="reports/02_User_Personas.html#p3--teacher--class-teacher">P3 — Teacher / Class Teacher</a></li> <li><a href="reports/02_User_Personas.html#p4--accountant--finance-staff">P4 — Accountant / Finance staff</a></li> <li><a href="reports/02_User_Personas.html#p5--super-admin-platform">P5 — Super Admin (platform)</a></li> <li><a href="reports/02_User_Personas.html#permission--screen-map-client-side-enforcement">Permission → screen map (client-side enforcement)</a></li> </ul> <blockquote> <p>Who uses Reports, with what permissions, for which report types. Permissions from <code>permissions.constants.ts:39-40</code> (<code>report.generate</code>, <code>report.read</code>). Roles from <a href="reports/../00-shared/01_Product_Overview.html">00-shared/01 §3</a>. All three report types (<code>report-job.schema.ts:7-11</code>) map to read-only consumers.</p> </blockquote> <hr /> <h2 id="p1--org-admin-schoolcoaching-owner"><a class="header" href="#p1--org-admin-schoolcoaching-owner">P1 — Org Admin (school/coaching owner)</a></h2> <ul> <li><strong>Goals</strong>: institution-wide visibility; run fee summaries at month end; verify report cards before publishing; delegate.</li> <li><strong>Permissions</strong>: <code>report.generate</code> + <code>report.read</code> (<code>permissions.constants.ts:39-40</code>).</li> <li><strong>Reports used</strong>: <code>fee_summary</code> (primary), <code>attendance_summary</code> (school-wide or per class), <code>report_card</code> (spot checks).</li> <li><strong>Pain points</strong>: spreadsheets and manual counting; long wait times with no feedback.</li> <li><strong>Key jobs-to-be-done</strong>: "Show me what's uncollected", "Is attendance healthy this term?", "Sanity-check a report card before results go out".</li> <li><strong>Devices</strong>: tablet/desktop mostly; mobile for status checks.</li> <li><strong>Behavior</strong>: requests reports from dashboard quick actions; checks job list periodically; downloads for archival.</li> </ul> <h2 id="p2--principal--head-of-academics"><a class="header" href="#p2--principal--head-of-academics">P2 — Principal / Head of Academics</a></h2> <ul> <li><strong>Goals</strong>: academic quality monitoring; term-end report cards; attendance trends.</li> <li><strong>Permissions</strong>: <code>report.read</code> (view) — <code>report.generate</code> optional <code>(forward-looking)</code> until RBAC roles are seeded per permission set.</li> <li><strong>Reports used</strong>: <code>report_card</code> (term-end batches), <code>attendance_summary</code>.</li> <li><strong>Key jobs-to-be-done</strong>: "Verify marks/grades before parent communication", "Spot classes with deteriorating attendance".</li> <li><strong>Behavior</strong>: reviews job list daily at term end; views results in-app rather than downloading.</li> </ul> <h2 id="p3--teacher--class-teacher"><a class="header" href="#p3--teacher--class-teacher">P3 — Teacher / Class Teacher</a></h2> <ul> <li><strong>Goals</strong>: per-class attendance summaries and individual report cards; quick pull for parents' meetings.</li> <li><strong>Permissions</strong>: <code>report.generate</code> + <code>report.read</code> per role grants <code>(forward-looking)</code> — today the API is JWT-gated only (<code>reports.controller.ts:9</code>), so the client must enforce menu visibility by permission list until RBAC guards land.</li> <li><strong>Reports used</strong>: <code>attendance_summary</code> (with <code>classId</code>), <code>report_card</code> (<code>studentId</code> + <code>examId</code>).</li> <li><strong>Pain points</strong>: queueing time for term-end batch cards; wants progress, not a spinner.</li> <li><strong>Key jobs-to-be-done</strong>: "Attendance for my class this month", "This student's report card for the mid-term exam".</li> <li><strong>Devices</strong>: mobile-first.</li> </ul> <h2 id="p4--accountant--finance-staff"><a class="header" href="#p4--accountant--finance-staff">P4 — Accountant / Finance staff</a></h2> <ul> <li><strong>Goals</strong>: collections visibility; overdue tracking; monthly reconciliation input.</li> <li><strong>Permissions</strong>: <code>report.read</code> minimum; <code>report.generate</code> where granted.</li> <li><strong>Reports used</strong>: <code>fee_summary</code> — <code>totalInvoices, totalCollected, totalPending, totalOverdue</code> (<code>reports.service.ts:154-158</code>).</li> <li><strong>Key jobs-to-be-done</strong>: "How much is outstanding?", "Track monthly collection".</li> <li><strong>Behavior</strong>: schedules weekly fee summaries (scheduler <code>(planned)</code> for admin UI; today <code>AttendanceReportJob</code> is daily/weekly attendance only, <code>attendance-report.job.ts:6</code>).</li> </ul> <h2 id="p5--super-admin-platform"><a class="header" href="#p5--super-admin-platform">P5 — Super Admin (platform)</a></h2> <ul> <li><strong>Goals</strong>: platform health; never touch tenant data (<a href="reports/../00-shared/01_Product_Overview.html">00-shared/01 §3</a>).</li> <li><strong>Permission</strong>: platform bypass of tenant scope (<code>base.repository.ts:21-23</code> via <code>isPlatformAdmin</code>) — <strong>out of product scope</strong>; no report screens.</li> </ul> <hr /> <h2 id="permission--screen-map-client-side-enforcement"><a class="header" href="#permission--screen-map-client-side-enforcement">Permission → screen map (client-side enforcement)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Permission</th><th>Screens</th></tr></thead><tbody> <tr><td><code>report.read</code></td><td>Catalog (view list), Job list, Job detail, Result view/download</td></tr> <tr><td><code>report.generate</code></td><td>Generate form, "New report" entry points</td></tr> <tr><td>neither</td><td>Reports module hidden (<code>(forward-looking)</code> — guard gap, <a href="reports/12_API_Mapping.html">12_API_Mapping §9</a>)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journey-reports-module"><a class="header" href="#03--user-journey-reports-module">03 — User Journey (Reports Module)</a></h1> <ul> <li><a href="reports/03_User_Journey.html#j1--generate-a-fee-summary-org-admin--accountant">J1 — Generate a fee summary (Org Admin / Accountant)</a></li> <li><a href="reports/03_User_Journey.html#j2--generate-an-attendance-summary-for-a-class-teacher">J2 — Generate an attendance summary for a class (Teacher)</a></li> <li><a href="reports/03_User_Journey.html#j3--generate-a-report-card-teacher--principal">J3 — Generate a report card (Teacher / Principal)</a></li> <li><a href="reports/03_User_Journey.html#j4--scheduled-report-lands-in-job-list-any-role-with-reportread">J4 — Scheduled report lands in job list (any role with <code>report.read</code>)</a></li> <li><a href="reports/03_User_Journey.html#journey-map">Journey map</a></li> <li><a href="reports/03_User_Journey.html#common-failure-paths">Common failure paths</a></li> </ul> <blockquote> <p>End-to-end journeys through the async generate → poll → view/download flow. All three journeys share the same server contract: <code>POST /reports/generate</code> → <code>{jobId, status:'queued'}</code> (<code>reports.service.ts:28-44</code>), then poll <code>GET /reports/:jobId</code> (<code>reports.service.ts:46-50</code>) until <code>completed</code>/<code>failed</code> (<code>report-job.schema.ts:13-18</code>).</p> </blockquote> <hr /> <h2 id="j1--generate-a-fee-summary-org-admin--accountant"><a class="header" href="#j1--generate-a-fee-summary-org-admin--accountant">J1 — Generate a fee summary (Org Admin / Accountant)</a></h2> <ol> <li><strong>Entry</strong>: Dashboard → "Reports" → "New report".</li> <li><strong>Form</strong>: select type <code>fee_summary</code>; no required params (<code>generate-report.dto.ts:6-33</code> — all params optional).</li> <li><strong>Submit</strong> → <code>POST /reports/generate</code> (<code>reports.controller.ts:14-18</code>). <ul> <li>Success: navigate to Job detail with <code>jobId</code>, status <code>queued</code>.</li> <li>Error 400/429/5xx: inline error, form preserved.</li> </ul> </li> <li><strong>Poll</strong>: client polls <code>GET /reports/:jobId</code> every 2 s (<code>reports.service.ts:46-50</code>).</li> <li><strong>Completed</strong>: result card with <code>totalInvoices, totalCollected, totalPending, totalOverdue</code> (<code>reports.service.ts:154-158</code>); CTA "Download CSV" <code>(planned)</code>.</li> <li><strong>Failed</strong>: status <code>failed</code> with <code>error</code> (<code>report-job.schema.ts:39</code>); CTA "Retry" = re-POST same params (new job) — <code>reports.service.ts:31-41</code>.</li> <li><strong>Exit</strong>: back to job list; job persists for later reads.</li> </ol> <p><strong>Happiness metric</strong>: request → visible progress < 1 s; result < 5 s.</p> <h2 id="j2--generate-an-attendance-summary-for-a-class-teacher"><a class="header" href="#j2--generate-an-attendance-summary-for-a-class-teacher">J2 — Generate an attendance summary for a class (Teacher)</a></h2> <ol> <li><strong>Entry</strong>: Reports catalog → <code>attendance_summary</code> template.</li> <li><strong>Form</strong>: <code>classId</code> optional, <code>startDate</code>/<code>endDate</code> optional (<code>generate-report.dto.ts:17-33</code>); no dates = all records (<code>reports.service.ts:117-123</code>).</li> <li><strong>Submit/poll</strong> as J1.</li> <li><strong>Completed</strong>: summary of counts <strong>per attendance status</strong> (<code>reports.service.ts:126-129</code>) + <code>total</code> + period (<code>reports.service.ts:131-136</code>).</li> <li><strong>View</strong>: status-pie (present/late/absent/excused) + totals.</li> <li><strong>Edge</strong>: empty range → <code>{total: 0, summary: {}}</code> — render empty state, no error.</li> </ol> <h2 id="j3--generate-a-report-card-teacher--principal"><a class="header" href="#j3--generate-a-report-card-teacher--principal">J3 — Generate a report card (Teacher / Principal)</a></h2> <ol> <li><strong>Entry</strong>: Student profile → "Report card" → prefill <code>studentId</code>.</li> <li><strong>Form</strong>: <code>studentId</code> + <code>examId</code> required <strong>by the service</strong>, not the DTO — missing → job <code>failed</code> with <code>error: "studentId and examId required"</code> (<code>reports.service.ts:91</code>). DTO only enforces Mongo-Id format (<code>generate-report.dto.ts:11-13,22-23</code>). Client MUST validate presence before POST (gap flagged in <a href="reports/08_Form_Specifications.html">08</a>).</li> <li><strong>Submit/poll</strong> as J1.</li> <li><strong>Completed</strong>: card data — <code>studentName</code> (note: currently the <strong>admission number</strong>, <code>reports.service.ts:98</code>), <code>subjects[]</code>, <code>totalMarksObtained</code>, <code>totalMaximumMarks</code>, <code>percentage</code>, <code>grade</code> (<code>reports.service.ts:96-107</code>). Grade bands A+…D from <code>result.service.ts:130-136</code>.</li> <li><strong>View</strong>: subject table + grade badge; "Share as PDF" <code>(planned)</code> — the blueprint's PDF export does not exist yet.</li> <li><strong>Student not found</strong> → <code>failed</code> with <code>error: "Student not found"</code> (<code>reports.service.ts:94</code>); UI shows retry.</li> </ol> <h2 id="j4--scheduled-report-lands-in-job-list-any-role-with-reportread"><a class="header" href="#j4--scheduled-report-lands-in-job-list-any-role-with-reportread">J4 — Scheduled report lands in job list (any role with <code>report.read</code>)</a></h2> <ol> <li><strong>Entry</strong>: scheduler fires daily/weekly attendance report (<code>attendance-report.job.ts:13-24</code>) — <code>actorId: 'scheduler'</code>.</li> <li>Job appears in the shared job list with source badge "Scheduled".</li> <li>User polls/opens as J1 — no difference in contract (<code>reports.service.ts:52-83</code>).</li> <li><code>(forward-looking)</code>: notification on completion via <code>ReportGenerated</code> event (<code>04-Modules/Reports.md:33</code>) — no queue mapping today (<code>event-queue-map.ts</code> has no report route).</li> </ol> <hr /> <h2 id="journey-map-1"><a class="header" href="#journey-map-1">Journey map</a></h2> <pre class="mermaid">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 </pre> <h2 id="common-failure-paths"><a class="header" href="#common-failure-paths">Common failure paths</a></h2> <div class="table-wrapper"><table><thead><tr><th>Path</th><th>Server behavior</th><th>Client UX</th></tr></thead><tbody> <tr><td>Invalid <code>type</code></td><td>400 (enum validation, <code>generate-report.dto.ts:6-8</code>)</td><td>inline field error</td></tr> <tr><td>Unknown <code>jobId</code></td><td>404 <code>Report job not found.</code> (<code>reports.service.ts:48</code>)</td><td>"not found" state, back to list</td></tr> <tr><td>Worker crash mid-job</td><td>job stays <code>processing</code> (no timeout/requeue in code)</td><td>stale spinner; QA item in <code>14</code></td></tr> <tr><td>Duplicate submit</td><td>new job per POST (no idempotency key)</td><td>disable submit while in flight</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-reports-module"><a class="header" href="#04--information-architecture-reports-module">04 — Information Architecture (Reports Module)</a></h1> <ul> <li><a href="reports/04_Information_Architecture.html#1-module-subtree">1. Module subtree</a></li> <li><a href="reports/04_Information_Architecture.html#2-core-objects">2. Core objects</a> <ul> <li><a href="reports/04_Information_Architecture.html#report-type-template">Report type (template)</a></li> <li><a href="reports/04_Information_Architecture.html#report-job">Report job</a></li> </ul> </li> <li><a href="reports/04_Information_Architecture.html#3-state-model-per-object">3. State model per object</a></li> <li><a href="reports/04_Information_Architecture.html#4-navigation-rules">4. Navigation rules</a></li> <li><a href="reports/04_Information_Architecture.html#5-module-boundaries-what-reports-does-not-own">5. Module boundaries (what Reports does NOT own)</a></li> </ul> <blockquote> <p>How report content is organized in the app. Global structure per <a href="reports/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a>; this file defines the Reports subtree, its objects and states.</p> </blockquote> <hr /> <h2 id="1-module-subtree-1"><a class="header" href="#1-module-subtree-1">1. Module subtree</a></h2> <pre><code>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)` </code></pre> <p>Routes (Flutter, <code>(recommended)</code> — see <code>15</code>): <code>/reports</code>, <code>/reports/new</code>, <code>/reports/jobs</code>, <code>/reports/jobs/:jobId</code>, <code>/reports/jobs/:jobId/result</code>.</p> <h2 id="2-core-objects"><a class="header" href="#2-core-objects">2. Core objects</a></h2> <h3 id="report-type-template"><a class="header" href="#report-type-template">Report type (template)</a></h3> <p>Static client catalog mirroring the server enum (<code>report-job.schema.ts:7-11</code>):</p> <div class="table-wrapper"><table><thead><tr><th>Type</th><th>Label</th><th>Params</th><th>Result shape</th></tr></thead><tbody> <tr><td><code>report_card</code></td><td>Report card</td><td><code>studentId</code>, <code>examId</code> (both required by service — <code>reports.service.ts:91</code>)</td><td><code>{studentName, studentId, examId, generatedAt, subjects[], totalMarksObtained, totalMaximumMarks, percentage, grade}</code> (<code>reports.service.ts:96-107</code>)</td></tr> <tr><td><code>attendance_summary</code></td><td>Attendance summary</td><td><code>classId?</code>, <code>startDate?</code>, <code>endDate?</code> (<code>generate-report.dto.ts:17-33</code>)</td><td><code>{total, summary:{status→count}, classId, period:{startDate,endDate}}</code> (<code>reports.service.ts:131-136</code>)</td></tr> <tr><td><code>fee_summary</code></td><td>Fee summary</td><td>none (<code>reports.service.ts:140</code>)</td><td><code>{totalInvoices, totalCollected, totalPending, totalOverdue}</code> (<code>reports.service.ts:154-158</code>)</td></tr> </tbody></table> </div> <p><code>(planned)</code> types from <code>docs/IMPLEMENTATION_PLAN.md:646</code>: <code>BATCH_PERFORMANCE</code>, <code>TEST_SERIES_ANALYSIS</code>, <code>DPP_COMPLETION</code> (coaching).</p> <h3 id="report-job"><a class="header" href="#report-job">Report job</a></h3> <p>Server document (<code>report-job.schema.ts:21-43</code>), collection <code>report_jobs</code>:</p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody> <tr><td><code>type</code></td><td>enum</td><td>required (<code>:22-24</code>)</td></tr> <tr><td><code>status</code></td><td>enum</td><td>default <code>queued</code> (<code>:25-30</code>)</td></tr> <tr><td><code>params</code></td><td>object</td><td>echo of request (<code>:32-33</code>)</td></tr> <tr><td><code>result</code></td><td>object</td><td>set on completion (<code>:35-36</code>)</td></tr> <tr><td><code>error</code></td><td>string</td><td>set on failure (<code>:38-39</code>)</td></tr> <tr><td><code>completedAt</code></td><td>date</td><td>set on completion (<code>:41-42</code>)</td></tr> <tr><td>+ BaseSchema</td><td></td><td><code>tenantId, createdAt, updatedAt, version, …</code> (<code>base.schema.ts</code>)</td></tr> </tbody></table> </div> <p>Indexes: <code>{tenantId, status}</code> and <code>{tenantId, createdAt:-1}</code> (<code>report-job.schema.ts:47-48</code>) → job list is sorted by recency.</p> <h2 id="3-state-model-per-object"><a class="header" href="#3-state-model-per-object">3. State model per object</a></h2> <pre><code>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 </code></pre> <h2 id="4-navigation-rules-1"><a class="header" href="#4-navigation-rules-1">4. Navigation rules</a></h2> <ul> <li>Job detail is the <strong>single hub</strong> — reached from job list, from post-submit redirect, and (future) from notification deep link <code>(forward-looking)</code>.</li> <li>Result view opens from job detail only when <code>status == completed</code>.</li> <li>Catalog → Generate is the only forward path that creates a job.</li> <li>No cross-module entry today; students module "report card" shortcut <code>(forward-looking)</code> — prefill comes from student profile context.</li> </ul> <h2 id="5-module-boundaries-what-reports-does-not-own"><a class="header" href="#5-module-boundaries-what-reports-does-not-own">5. Module boundaries (what Reports does NOT own)</a></h2> <ul> <li>Files/attachment storage → <code>files</code> module (<code>GET /files/:id/download</code>, <code>files.controller.ts:55-64</code>) once PDF export lands <code>(planned)</code>.</li> <li>Scheduler definitions → <code>scheduler</code> module (<code>AttendanceReportJob</code>, <code>attendance-report.job.ts:4-7</code>).</li> <li>Notifications → <code>ReportGenerated</code> event <code>(planned)</code>; queue map has no report route today (<code>event-queue-map.ts</code>).</li> <li>Data sources (students/attendance/fees/results) — read-only, injected via modules (<code>reports.module.ts:20-23</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-reports-module"><a class="header" href="#05--screen-inventory-reports-module">05 — Screen Inventory (Reports Module)</a></h1> <ul> <li><a href="reports/05_Screen_Inventory.html#1-report-catalog-reports">1. Report Catalog (<code>/reports</code>)</a></li> <li><a href="reports/05_Screen_Inventory.html#2-generate-report-form-reportsnew">2. Generate Report Form (<code>/reports/new</code>)</a></li> <li><a href="reports/05_Screen_Inventory.html#3-job-list-reportsjobs">3. Job List (<code>/reports/jobs</code>)</a></li> <li><a href="reports/05_Screen_Inventory.html#4-job-detail--progress-reportsjobsjobid">4. Job Detail / Progress (<code>/reports/jobs/:jobId</code>)</a></li> <li><a href="reports/05_Screen_Inventory.html#5-result-view--download-reportsjobsjobidresult">5. Result View / Download (<code>/reports/jobs/:jobId/result</code>)</a></li> <li><a href="reports/05_Screen_Inventory.html#6-scheduled-reports-entry-reportsscheduled-planned">6. Scheduled Reports entry (<code>/reports/scheduled</code>) <code>(planned)</code></a></li> <li><a href="reports/05_Screen_Inventory.html#shared-components-used">Shared components used</a></li> <li><a href="reports/05_Screen_Inventory.html#analytics-events-proposed">Analytics events (proposed)</a></li> <li><a href="reports/05_Screen_Inventory.html#keyboard--adaptive">Keyboard / adaptive</a></li> </ul> <blockquote> <p>Every screen of the Reports module: intent, route, composition, states, permissions, behavior. Authoritative shared components in <a href="reports/../00-shared/03_Component_Library.html">00-shared/03</a>; this file enumerates which each screen uses with module specifics. Format mirrors <code>design-docs/auth/05_Screen_Inventory.md</code>. Analytics = <code>{module}.{screen}.{action}</code> <code>(proposed)</code>.</p> </blockquote> <hr /> <h2 id="1-report-catalog-reports"><a class="header" href="#1-report-catalog-reports">1. Report Catalog (<code>/reports</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Pick a report type to generate; see what exists</td></tr> <tr><td>Entry</td><td>nav "Reports"; dashboard quick action</td></tr> <tr><td>Exit</td><td>→ <code>/reports/new</code> (select template)</td></tr> <tr><td>Content</td><td>Static list of 3 templates (report card / attendance summary / fee summary) with description + required-param hints; <code>(planned)</code> rows for coaching types (BATCH_PERFORMANCE, TEST_SERIES_ANALYSIS, DPP_COMPLETION — <code>IMPLEMENTATION_PLAN.md:646</code>)</td></tr> <tr><td>Permission</td><td><code>report.read</code> (<code>permissions.constants.ts:40</code>) — hide module without it <code>(forward-looking)</code></td></tr> <tr><td>Widgets</td><td>AppListTile per template, AppBadge "Scheduled" on summary types, FAB? none — list only</td></tr> <tr><td>States</td><td>idle; loading skeleton; empty (never: list is static)</td></tr> <tr><td>Analytics</td><td><code>reports.catalog.open</code>, <code>reports.catalog.select(type)</code> <code>(proposed)</code></td></tr> </tbody></table> </div> <h2 id="2-generate-report-form-reportsnew"><a class="header" href="#2-generate-report-form-reportsnew">2. Generate Report Form (<code>/reports/new</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Enter params for one template, POST generate</td></tr> <tr><td>Entry</td><td>catalog select</td></tr> <tr><td>Exit</td><td>submit → <code>/reports/jobs/:jobId</code>; back → catalog</td></tr> <tr><td>Form</td><td>type (fixed, from selection), conditional params per <a href="reports/08_Form_Specifications.html">08</a></td></tr> <tr><td>Primary CTA</td><td><code>Generate report</code> (disabled while in flight)</td></tr> <tr><td>States</td><td>validation (client-side presence for report_card!), submitting (button spinner), error 400 inline, 429 countdown, offline banner</td></tr> <tr><td>Permission</td><td><code>report.generate</code> (<code>permissions.constants.ts:39</code>) — submit blocked without it <code>(forward-looking)</code></td></tr> <tr><td>Analytics</td><td><code>reports.generate.submit(type)</code>, <code>reports.generate.success(jobId)</code>, <code>reports.generate.failure(code)</code> <code>(proposed)</code></td></tr> </tbody></table> </div> <h2 id="3-job-list-reportsjobs"><a class="header" href="#3-job-list-reportsjobs">3. Job List (<code>/reports/jobs</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Recent report jobs for the tenant, newest first</td></tr> <tr><td>Source</td><td>No list endpoint today — <strong>client gap</strong>. Server has <code>{tenantId, createdAt:-1}</code> index (<code>report-job.schema.ts:48</code>) but no <code>GET /reports</code> route; client keeps a local history of submitted jobIds (limit ~50, persisted) <code>(forward-looking)</code>: add <code>GET /reports?status=</code> server-side</td></tr> <tr><td>Row</td><td>type label, status chip (queued/processing/completed/failed — <code>report-job.schema.ts:13-18</code>), createdAt, source badge (User/Scheduled)</td></tr> <tr><td>Actions</td><td>tap → job detail; swipe/pull-to-refresh re-polls rows in view</td></tr> <tr><td>Empty</td><td>"No reports yet — create one" + CTA to catalog</td></tr> <tr><td>Widgets</td><td>AppListTile, AppBadge (status), AppSkeleton, RefreshIndicator, AppEmptyState</td></tr> </tbody></table> </div> <h2 id="4-job-detail--progress-reportsjobsjobid"><a class="header" href="#4-job-detail--progress-reportsjobsjobid">4. Job Detail / Progress (<code>/reports/jobs/:jobId</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Live status + result of one job; the polling hub</td></tr> <tr><td>Source</td><td><code>GET /reports/:jobId</code> (<code>reports.controller.ts:20-24</code>)</td></tr> <tr><td>Content</td><td>header (type, jobId short, createdAt), status area, param summary chips, result section</td></tr> <tr><td>Status areas</td><td>queued: "Waiting in queue…" icon; processing: progress indicator (indeterminate — no % from server); completed: result card; failed: error message + Retry</td></tr> <tr><td>Poll</td><td>auto-poll 2 s while queued/processing (see <a href="reports/10_Interaction_Specification.html">10</a>); stop on terminal</td></tr> <tr><td>Permission</td><td><code>report.read</code> <code>(forward-looking)</code></td></tr> <tr><td>Errors</td><td>404 → "Report not found" state + back; network → banner, poll paused, manual retry</td></tr> <tr><td>Analytics</td><td><code>reports.job.status(jobId,status)</code>, <code>reports.job.retry(jobId)</code> <code>(proposed)</code></td></tr> </tbody></table> </div> <h2 id="5-result-view--download-reportsjobsjobidresult"><a class="header" href="#5-result-view--download-reportsjobsjobidresult">5. Result View / Download (<code>/reports/jobs/:jobId/result</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Render completed result; export/share <code>(planned)</code></td></tr> <tr><td>Entry</td><td>job detail when <code>completed</code></td></tr> <tr><td>Result renderers</td><td>report card: subject table + grade badge; attendance: status count cards + totals; fee: 4 metric cards (<code>reports.service.ts:154-158</code>)</td></tr> <tr><td>CTAs</td><td>"Download CSV" / "Share PDF" <code>(planned)</code> — no export endpoint in code; "New report from these params" (re-POST)</td></tr> <tr><td>Permission</td><td><code>report.read</code></td></tr> <tr><td>Empty</td><td><code>completed</code> with empty result → empty state (e.g. zero attendance records)</td></tr> <tr><td>Analytics</td><td><code>reports.result.open(jobId)</code>, <code>reports.result.download(jobId)</code> <code>(proposed)</code></td></tr> </tbody></table> </div> <h2 id="6-scheduled-reports-entry-reportsscheduled-planned"><a class="header" href="#6-scheduled-reports-entry-reportsscheduled-planned">6. Scheduled Reports entry (<code>/reports/scheduled</code>) <code>(planned)</code></a></h2> <ul> <li>Server side exists: scheduler enqueues <code>generate-attendance-report</code> (<code>attendance-report.job.ts:15-23</code>), <code>actorId: 'scheduler'</code>. Admin UI to schedule/configure is <code>(planned)</code>; today jobs just appear via job list (no UI today — see screen 3 gap).</li> </ul> <hr /> <h2 id="shared-components-used-17"><a class="header" href="#shared-components-used-17">Shared components used</a></h2> <p><code>AppListTile</code>, <code>AppBadge</code>, <code>AppButton</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppSnackbar</code>, <code>AppBottomSheet</code> (filter), <code>AppOfflineBanner</code>, <code>AppChips</code> (params), <code>AppMetricCard</code> (module: fee summary), <code>AppProgressRow</code> (module: job status).</p> <h2 id="analytics-events-proposed-14"><a class="header" href="#analytics-events-proposed-14">Analytics events (proposed)</a></h2> <p><code>reports.catalog.{open,select}</code>, <code>reports.generate.{submit,success,failure}</code>, <code>reports.job.{status,retry}</code>, <code>reports.result.{open,download}</code>.</p> <h2 id="keyboard--adaptive"><a class="header" href="#keyboard--adaptive">Keyboard / adaptive</a></h2> <ul> <li>Forms: portrait-first, keyboard-avoid; tablet two-column (form | live job preview).</li> <li>Job detail: tablet master-detail with result inline; desktop max-width card.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-reports-module"><a class="header" href="#06--screen-specifications-reports-module">06 — Screen Specifications (Reports Module)</a></h1> <ul> <li><a href="reports/06_Screen_Specifications.html#s1--report-catalog-reports">S1 — Report Catalog (<code>/reports</code>)</a> <ul> <li><a href="reports/06_Screen_Specifications.html#purpose">Purpose</a></li> <li><a href="reports/06_Screen_Specifications.html#layout-mobile--tablet">Layout (mobile → tablet)</a></li> <li><a href="reports/06_Screen_Specifications.html#data">Data</a></li> <li><a href="reports/06_Screen_Specifications.html#states">States</a></li> <li><a href="reports/06_Screen_Specifications.html#permissions--events">Permissions / events</a></li> <li><a href="reports/06_Screen_Specifications.html#accessibility--motion">Accessibility / motion</a></li> </ul> </li> <li><a href="reports/06_Screen_Specifications.html#s2--generate-report-form-reportsnew">S2 — Generate Report Form (<code>/reports/new</code>)</a> <ul> <li><a href="reports/06_Screen_Specifications.html#purpose-1">Purpose</a></li> <li><a href="reports/06_Screen_Specifications.html#layout">Layout</a></li> <li><a href="reports/06_Screen_Specifications.html#submission-contract">Submission contract</a></li> <li><a href="reports/06_Screen_Specifications.html#states-1">States</a></li> <li><a href="reports/06_Screen_Specifications.html#behavior-rules">Behavior rules</a></li> <li><a href="reports/06_Screen_Specifications.html#permissions--analytics">Permissions / analytics</a></li> </ul> </li> <li><a href="reports/06_Screen_Specifications.html#s3--job-list-reportsjobs">S3 — Job List (<code>/reports/jobs</code>)</a> <ul> <li><a href="reports/06_Screen_Specifications.html#purpose-2">Purpose</a></li> <li><a href="reports/06_Screen_Specifications.html#data--known-client-gap">Data — known client gap</a></li> <li><a href="reports/06_Screen_Specifications.html#layout-1">Layout</a></li> <li><a href="reports/06_Screen_Specifications.html#states-2">States</a></li> <li><a href="reports/06_Screen_Specifications.html#behavior">Behavior</a></li> <li><a href="reports/06_Screen_Specifications.html#permissions--analytics-1">Permissions / analytics</a></li> </ul> </li> <li><a href="reports/06_Screen_Specifications.html#s4--job-detail--progress-reportsjobsjobid">S4 — Job Detail / Progress (<code>/reports/jobs/:jobId</code>)</a> <ul> <li><a href="reports/06_Screen_Specifications.html#purpose-3">Purpose</a></li> <li><a href="reports/06_Screen_Specifications.html#data-1">Data</a></li> <li><a href="reports/06_Screen_Specifications.html#layout-2">Layout</a></li> <li><a href="reports/06_Screen_Specifications.html#status-dependent-ui">Status-dependent UI</a></li> <li><a href="reports/06_Screen_Specifications.html#states-3">States</a></li> <li><a href="reports/06_Screen_Specifications.html#permissions--analytics-2">Permissions / analytics</a></li> </ul> </li> <li><a href="reports/06_Screen_Specifications.html#s5--result-view-reportsjobsjobidresult">S5 — Result View (<code>/reports/jobs/:jobId/result</code>)</a> <ul> <li><a href="reports/06_Screen_Specifications.html#purpose-4">Purpose</a></li> <li><a href="reports/06_Screen_Specifications.html#result-renderers-from-reportsservicets96-160">Result renderers (from <code>reports.service.ts:96-160</code>)</a></li> <li><a href="reports/06_Screen_Specifications.html#states-4">States</a></li> <li><a href="reports/06_Screen_Specifications.html#export-planned">Export <code>(planned)</code></a></li> <li><a href="reports/06_Screen_Specifications.html#permissions--analytics-3">Permissions / analytics</a></li> </ul> </li> <li><a href="reports/06_Screen_Specifications.html#s6--scheduled-reports-admin-planned">S6 — Scheduled Reports Admin <code>(planned)</code></a></li> </ul> <blockquote> <p>Detailed, per-state specifications for the five screens. Wire contracts in <a href="reports/12_API_Mapping.html">12_API_Mapping.md</a>; shared components in <a href="reports/../00-shared/03_Component_Library.html">00-shared/03</a>; tokens in <a href="reports/../00-shared/02_Design_Tokens.html">00-shared/02</a> (M3, proposed). All server quotes from <code>src/modules/reports/**</code> unless noted.</p> </blockquote> <hr /> <h2 id="s1--report-catalog-reports"><a class="header" href="#s1--report-catalog-reports">S1 — Report Catalog (<code>/reports</code>)</a></h2> <h3 id="purpose-9"><a class="header" href="#purpose-9">Purpose</a></h3> <p>Pick a report template. Static content derived from the server enum (<code>report-job.schema.ts:7-11</code>).</p> <h3 id="layout-mobile--tablet"><a class="header" href="#layout-mobile--tablet">Layout (mobile → tablet)</a></h3> <ul> <li>AppBar: "Reports" (title), optional filter menu <code>(forward-looking)</code>.</li> <li>List (AppListTile × 3): <ol> <li><strong>Report card</strong> — "Per-student marksheet with grades (A+…D)". Badge: needs student + exam.</li> <li><strong>Attendance summary</strong> — "Counts per status for a class/period". Badge: can be scheduled daily/weekly.</li> <li><strong>Fee summary</strong> — "Collections: paid / pending / overdue". Badge: no params.</li> </ol> </li> <li>Below list: muted row "Result delivery: PDF/CSV coming soon" <code>(planned)</code>.</li> </ul> <h3 id="data"><a class="header" href="#data">Data</a></h3> <p>Static table (no API). Sources: <code>report-job.schema.ts:7-11</code>, <code>generate-report.dto.ts:6-33</code>, <code>reports.service.ts:85-160</code>.</p> <h3 id="states-10"><a class="header" href="#states-10">States</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Render</th></tr></thead><tbody> <tr><td>idle</td><td>3 tiles</td></tr> <tr><td>permission-gap</td><td>module hidden (no <code>report.read</code>) <code>(forward-looking)</code></td></tr> <tr><td>offline</td><td><code>AppOfflineBanner</code>; tiles still render (static)</td></tr> </tbody></table> </div> <h3 id="permissions--events"><a class="header" href="#permissions--events">Permissions / events</a></h3> <ul> <li>View: <code>report.read</code> (<code>permissions.constants.ts:40</code>).</li> <li><code>reports.catalog.select(type)</code> <code>(proposed)</code>.</li> </ul> <h3 id="accessibility--motion"><a class="header" href="#accessibility--motion">Accessibility / motion</a></h3> <ul> <li>Tiles: min 48 dp, semantics label per tile; <code>m-fast</code> press ripple; page enter <code>m-base</code> fade-slide (<a href="reports/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>).</li> </ul> <hr /> <h2 id="s2--generate-report-form-reportsnew"><a class="header" href="#s2--generate-report-form-reportsnew">S2 — Generate Report Form (<code>/reports/new</code>)</a></h2> <h3 id="purpose-10"><a class="header" href="#purpose-10">Purpose</a></h3> <p>Collect params per <a href="reports/08_Form_Specifications.html">08_Form_Specifications.md</a>; POST and hand off to job detail.</p> <h3 id="layout-15"><a class="header" href="#layout-15">Layout</a></h3> <ul> <li>AppBar: "New report" + back.</li> <li>Fixed select (non-editable): report type from catalog selection.</li> <li>Param fields per type (<a href="reports/08_Form_Specifications.html">08</a>): <ul> <li><code>report_card</code>: student picker + exam picker (both required by service — <code>reports.service.ts:91</code>).</li> <li><code>attendance_summary</code>: class picker (optional), start date, end date (optional; <code>generate-report.dto.ts:17-33</code>).</li> <li><code>fee_summary</code>: no fields; info card "No parameters needed".</li> </ul> </li> <li>Summary row: params → chips (<code>reports.service.ts:31-33</code> echoes <code>params</code>).</li> <li>Primary CTA: "Generate report" full-width <code>AppButton</code>.</li> </ul> <h3 id="submission-contract"><a class="header" href="#submission-contract">Submission contract</a></h3> <p><code>POST /reports/generate</code> body = <code>GenerateReportDto</code> (<code>generate-report.dto.ts:5-34</code>):</p> <pre><code class="language-json">{ "type": "attendance_summary", "classId": "…", "startDate": "2026-08-01", "endDate": "2026-08-31" } </code></pre> <p>Response: <code>{jobId, status: "queued"}</code> (<code>reports.service.ts:43</code>).</p> <h3 id="states-11"><a class="header" href="#states-11">States</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Render</th></tr></thead><tbody> <tr><td>idle</td><td>form</td></tr> <tr><td>validating</td><td>inline errors (presence for report_card — DTO does NOT enforce presence, only Mongo-Id format: <code>generate-report.dto.ts:11-13,22-23</code>)</td></tr> <tr><td>submitting</td><td>button spinner, fields disabled (prevents duplicate job — server has no idempotency key)</td></tr> <tr><td>success</td><td>navigate <code>S4</code> with jobId</td></tr> <tr><td>error 400</td><td>inline per field (invalid type enum, malformed MongoId)</td></tr> <tr><td>error 429</td><td>countdown, no auto-retry (<code>00-shared/10</code>)</td></tr> <tr><td>error 5xx</td><td><code>AppSnackbar</code> + form preserved (re-POST is safe — new job per POST)</td></tr> <tr><td>offline</td><td><code>AppOfflineBanner</code>; submit blocked</td></tr> </tbody></table> </div> <h3 id="behavior-rules"><a class="header" href="#behavior-rules">Behavior rules</a></h3> <ol> <li>Presence validation for <code>studentId</code>/<code>examId</code> on <code>report_card</code> BEFORE POST — the server fails the <strong>job</strong> (async), not the request: <code>"studentId and examId required"</code> (<code>reports.service.ts:91</code>). Client must not ship that UX.</li> <li>No double-submit while in flight.</li> <li>Date fields sent as strings (<code>generate-report.dto.ts:27-33</code>), no format validation server-side — client sends ISO <code>YYYY-MM-DD</code>; server builds Mongo <code>$gte/$lte</code> (<code>reports.service.ts:120-122</code>).</li> </ol> <h3 id="permissions--analytics"><a class="header" href="#permissions--analytics">Permissions / analytics</a></h3> <ul> <li>Submit: <code>report.generate</code> (<code>permissions.constants.ts:39</code>) — server does not enforce today (<code>reports.controller.ts:9</code> only JWT) <code>(forward-looking)</code>.</li> <li><code>reports.generate.submit|success|failure</code> <code>(proposed)</code>.</li> </ul> <hr /> <h2 id="s3--job-list-reportsjobs"><a class="header" href="#s3--job-list-reportsjobs">S3 — Job List (<code>/reports/jobs</code>)</a></h2> <h3 id="purpose-11"><a class="header" href="#purpose-11">Purpose</a></h3> <p>Recent jobs, newest first.</p> <h3 id="data--known-client-gap"><a class="header" href="#data--known-client-gap">Data — <strong>known client gap</strong></a></h3> <p>No <code>GET /reports</code> endpoint exists. Server only has <code>GET /reports/:jobId</code> (<code>reports.controller.ts:20-24</code>). The <code>{tenantId, createdAt:-1}</code> index (<code>report-job.schema.ts:48</code>) implies list queries are intended. Client strategy:</p> <ul> <li>Local persisted history of submitted jobIds (max 50, oldest evicted);</li> <li>Poll only rows currently visible;</li> <li><code>(forward-looking)</code>: add <code>GET /reports?status=queued|processing|completed|failed</code> for full history + scheduled jobs visibility.</li> </ul> <h3 id="layout-16"><a class="header" href="#layout-16">Layout</a></h3> <ul> <li>AppBar "Report jobs".</li> <li>Filter chips <code>(forward-looking)</code>: All / Running / Completed / Failed.</li> <li>List rows (AppListTile): <ul> <li>Leading: status icon (queue/gear/check/cross).</li> <li>Title: type label.</li> <li>Subtitle: <code>createdAt</code> (relative), <code>jobId</code> short.</li> <li>Trailing: status <code>AppBadge</code> (<code>queued</code> gray, <code>processing</code> amber, <code>completed</code> green, <code>failed</code> red — enum <code>report-job.schema.ts:13-18</code>); source badge "Scheduled" when from scheduler (local heuristic <code>(planned)</code> — no actorId stored on job doc).</li> </ul> </li> <li>Pull-to-refresh: re-poll visible rows.</li> </ul> <h3 id="states-12"><a class="header" href="#states-12">States</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Render</th></tr></thead><tbody> <tr><td>loading</td><td>AppSkeleton rows</td></tr> <tr><td>empty</td><td>AppEmptyState "No reports yet" + CTA → catalog</td></tr> <tr><td>error</td><td>banner + retry (poll failures do not clear list)</td></tr> </tbody></table> </div> <h3 id="behavior-8"><a class="header" href="#behavior-8">Behavior</a></h3> <ul> <li>Tap row → S4.</li> <li>Rows in terminal state are never re-polled in-session.</li> <li>Retry action on failed rows: re-POST original <code>params</code> from job doc (<code>report-job.schema.ts:33</code>) → new jobId, row prepended.</li> </ul> <h3 id="permissions--analytics-1"><a class="header" href="#permissions--analytics-1">Permissions / analytics</a></h3> <ul> <li><code>report.read</code> <code>(forward-looking)</code>; <code>reports.job.list</code> <code>(proposed)</code>.</li> </ul> <hr /> <h2 id="s4--job-detail--progress-reportsjobsjobid"><a class="header" href="#s4--job-detail--progress-reportsjobsjobid">S4 — Job Detail / Progress (<code>/reports/jobs/:jobId</code>)</a></h2> <h3 id="purpose-12"><a class="header" href="#purpose-12">Purpose</a></h3> <p>Live lifecycle view + result entry. The polling hub (<code>10_Interaction_Specification</code>).</p> <h3 id="data-1"><a class="header" href="#data-1">Data</a></h3> <p><code>GET /reports/:jobId</code> (<code>reports.controller.ts:20-24</code>) → full job doc (<code>report-job.schema.ts:21-43</code>):</p> <pre><code class="language-json">{ "_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 } </code></pre> <h3 id="layout-17"><a class="header" href="#layout-17">Layout</a></h3> <ol> <li>Header card: type label + status badge + createdAt + short jobId.</li> <li>Params card: <code>params</code> as chips (empty → "No parameters").</li> <li>Status card (status-dependent, see table).</li> <li>Result card (completed only).</li> <li>Actions: Retry (failed), "New report from these params" (completed), Download/Share <code>(planned)</code>.</li> </ol> <h3 id="status-dependent-ui"><a class="header" href="#status-dependent-ui">Status-dependent UI</a></h3> <div class="table-wrapper"><table><thead><tr><th>Status</th><th>UI</th><th>Polling</th></tr></thead><tbody> <tr><td><code>queued</code></td><td>"Waiting in queue" + indeterminate progress row</td><td>every 2 s</td></tr> <tr><td><code>processing</code></td><td>"Generating…" + indeterminate progress row + elapsed timer</td><td>every 2 s</td></tr> <tr><td><code>completed</code></td><td>success icon; result card; CTAs</td><td>stop</td></tr> <tr><td><code>failed</code></td><td>error icon; error text from <code>error</code> field (<code>report-job.schema.ts:39</code>); Retry</td><td>stop</td></tr> </tbody></table> </div> <p>Notes:</p> <ul> <li>No progress % — server has none; indeterminate only.</li> <li>Elapsed timer computed client-side from <code>createdAt</code>.</li> <li>Terminal state is sticky: no re-poll, cached doc until screen exit.</li> </ul> <h3 id="states-13"><a class="header" href="#states-13">States</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Render</th></tr></thead><tbody> <tr><td>loading</td><td>full skeleton</td></tr> <tr><td>404</td><td>"Report not found" empty state + back (<code>reports.service.ts:48</code>)</td></tr> <tr><td>network fail</td><td>banner; polling paused; manual "Retry check"</td></tr> <tr><td>offline</td><td>banner + last-good doc</td></tr> <tr><td>cross-tenant 404</td><td>same "not found" (server scopes by tenant via BaseRepository — no leak, <code>base.repository.ts:21-23</code>)</td></tr> </tbody></table> </div> <h3 id="permissions--analytics-2"><a class="header" href="#permissions--analytics-2">Permissions / analytics</a></h3> <ul> <li><code>report.read</code> <code>(forward-looking)</code>.</li> <li><code>reports.job.status(jobId,status)</code>, <code>reports.job.retry(jobId)</code> <code>(proposed)</code>.</li> </ul> <hr /> <h2 id="s5--result-view-reportsjobsjobidresult"><a class="header" href="#s5--result-view-reportsjobsjobidresult">S5 — Result View (<code>/reports/jobs/:jobId/result</code>)</a></h2> <h3 id="purpose-13"><a class="header" href="#purpose-13">Purpose</a></h3> <p>Render completed result per type; export <code>(planned)</code>.</p> <h3 id="result-renderers-from-reportsservicets96-160"><a class="header" href="#result-renderers-from-reportsservicets96-160">Result renderers (from <code>reports.service.ts:96-160</code>)</a></h3> <p><strong>Report card</strong> (<code>reports.service.ts:96-107</code>):</p> <ul> <li>Header: <code>studentName</code> (⚠ currently admission number, <code>:98</code>), <code>examId</code>, <code>generatedAt</code>.</li> <li>Subject table: per row <code>subjects[]</code> (name, marks, max, grade — shape from <code>result.service.ts:68-91</code>).</li> <li>Footer metrics: <code>totalMarksObtained</code> / <code>totalMaximumMarks</code>, <code>percentage</code>, <code>grade</code> badge (bands A+…D, <code>result.service.ts:130-136</code>).</li> </ul> <p><strong>Attendance summary</strong> (<code>reports.service.ts:131-136</code>):</p> <ul> <li>Metric cards: <code>total</code> + per-status counts (<code>summary</code> keyed by status — present/late/absent/etc. as stored by attendance module).</li> <li>Period line: <code>classId</code>, <code>period.startDate</code>/<code>endDate</code> (nulls → "All time").</li> </ul> <p><strong>Fee summary</strong> (<code>reports.service.ts:154-158</code>):</p> <ul> <li>4 metric cards: <code>totalInvoices</code>, <code>totalCollected</code>, <code>totalPending</code>, <code>totalOverdue</code> (numbers from <code>invoiceRepo.find({})</code> + paid/due math <code>:142-152</code>).</li> <li>Derived: collection rate = collected ÷ totalInvoices <code>(client-derived)</code>.</li> </ul> <h3 id="states-14"><a class="header" href="#states-14">States</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Render</th></tr></thead><tbody> <tr><td>ready</td><td>renderer above</td></tr> <tr><td>completed but result missing</td><td>empty state (e.g. zero attendance) — <code>result</code> may be <code>{}</code> (zero-record summaries still complete, <code>:131-136</code>)</td></tr> <tr><td>not completed</td><td>guard: redirect to S4 (result only reachable when <code>status == completed</code>)</td></tr> <tr><td>offline</td><td>last-good cached result + banner</td></tr> </tbody></table> </div> <h3 id="export-planned"><a class="header" href="#export-planned">Export <code>(planned)</code></a></h3> <ul> <li>"Download CSV"/"Share PDF" — NO endpoint today. Blueprint: <code>GET /reports/:jobId/download</code> (<code>04-Modules/Reports.md:26</code>), streamed (<code>:49</code>), files under <code>sl/{tenantId}/reports/{uuid}</code> TTL (<code>:50</code>).</li> <li>Until then: hide export CTAs or show "coming soon" tooltip.</li> </ul> <h3 id="permissions--analytics-3"><a class="header" href="#permissions--analytics-3">Permissions / analytics</a></h3> <ul> <li><code>report.read</code>; <code>reports.result.open|download</code> <code>(proposed)</code>.</li> </ul> <hr /> <h2 id="s6--scheduled-reports-admin-planned"><a class="header" href="#s6--scheduled-reports-admin-planned">S6 — Scheduled Reports Admin <code>(planned)</code></a></h2> <ul> <li>Scheduler enqueues attendance reports daily/weekly today (<code>attendance-report.job.ts:13-24</code>), but there is no UI to configure.</li> <li>Future screen: schedule list + CRUD (perm <code>scheduler.read/create/delete</code>, <code>permissions.constants.ts:94-96</code>); result surfaced through job list.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-reports-module"><a class="header" href="#07--component-library-reports-module">07 — Component Library (Reports Module)</a></h1> <ul> <li><a href="reports/07_Component_Library.html#c1--reporttypetile-s1-catalog-row">C1 — <code>ReportTypeTile</code> (S1 catalog row)</a></li> <li><a href="reports/07_Component_Library.html#c2--reportstatusbadge-s3s4">C2 — <code>ReportStatusBadge</code> (S3/S4)</a></li> <li><a href="reports/07_Component_Library.html#c3--jobprogresscard-s4-status-card">C3 — <code>JobProgressCard</code> (S4 status card)</a></li> <li><a href="reports/07_Component_Library.html#c4--jobparamchips-s4-params-card">C4 — <code>JobParamChips</code> (S4 params card)</a></li> <li><a href="reports/07_Component_Library.html#c5--metriccard-s5-result-metrics">C5 — <code>MetricCard</code> (S5 result metrics)</a></li> <li><a href="reports/07_Component_Library.html#c6--reportcardresult-s5-report-card">C6 — <code>ReportCardResult</code> (S5 report card)</a></li> <li><a href="reports/07_Component_Library.html#c7--joblistrow-s3">C7 — <code>JobListRow</code> (S3)</a></li> <li><a href="reports/07_Component_Library.html#c8--pollingretrybutton-s4">C8 — <code>PollingRetryButton</code> (S4)</a></li> <li><a href="reports/07_Component_Library.html#shared-components-consumed">Shared components consumed</a></li> <li><a href="reports/07_Component_Library.html#rules">Rules</a></li> </ul> <blockquote> <p>Module-specific widgets on top of the shared library (<a href="reports/../00-shared/03_Component_Library.html">00-shared/03</a>). Reused shared components are referenced, not redefined. All widgets: Material 3, tokens <a href="reports/../00-shared/02_Design_Tokens.html">00-shared/02</a> (proposed), <code>m-*</code> motion <a href="reports/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>.</p> </blockquote> <hr /> <h2 id="c1--reporttypetile-s1-catalog-row"><a class="header" href="#c1--reporttypetile-s1-catalog-row">C1 — <code>ReportTypeTile</code> (S1 catalog row)</a></h2> <ul> <li>Extends <code>AppListTile</code>. Props: <code>label</code>, <code>description</code>, <code>badgeText?</code>, <code>requiredParams: String[]</code>, <code>onTap</code>.</li> <li>Semantics: "Generate <label>" + required-param hint.</li> <li>Data mapping: static table <a href="reports/04_Information_Architecture.html">04 §2</a>; enum source <code>report-job.schema.ts:7-11</code>.</li> </ul> <h2 id="c2--reportstatusbadge-s3s4"><a class="header" href="#c2--reportstatusbadge-s3s4">C2 — <code>ReportStatusBadge</code> (S3/S4)</a></h2> <ul> <li>Wraps <code>AppBadge</code>; maps <code>ReportJobStatus</code> (<code>report-job.schema.ts:13-18</code>) → label/color/icon:</li> </ul> <div class="table-wrapper"><table><thead><tr><th>Status</th><th>Label</th><th>Color</th><th>Icon</th></tr></thead><tbody> <tr><td><code>queued</code></td><td>Queued</td><td>neutral</td><td>schedule</td></tr> <tr><td><code>processing</code></td><td>Processing</td><td>amber</td><td>hourglass</td></tr> <tr><td><code>completed</code></td><td>Completed</td><td>green</td><td>check_circle</td></tr> <tr><td><code>failed</code></td><td>Failed</td><td>red</td><td>error</td></tr> </tbody></table> </div> <ul> <li>Semantics: <code><status> <type></code> live-region on change (S4).</li> </ul> <h2 id="c3--jobprogresscard-s4-status-card"><a class="header" href="#c3--jobprogresscard-s4-status-card">C3 — <code>JobProgressCard</code> (S4 status card)</a></h2> <ul> <li>Indeterminate <code>LinearProgressIndicator</code> + status copy + optional elapsed timer (client-side from <code>createdAt</code>).</li> <li>Props: <code>status</code>, <code>createdAt</code>, <code>error?</code>.</li> <li>No % bar — server exposes no progress; do NOT fake one.</li> <li>Failed variant: error icon + <code>error</code> text (<code>report-job.schema.ts:39</code>) + Retry <code>AppButton</code>.</li> </ul> <h2 id="c4--jobparamchips-s4-params-card"><a class="header" href="#c4--jobparamchips-s4-params-card">C4 — <code>JobParamChips</code> (S4 params card)</a></h2> <ul> <li>Renders <code>params</code> (<code>report-job.schema.ts:33</code>) as <code>AppChips</code>; empty → muted "No parameters". Keys: type, studentId, classId, examId, startDate, endDate (<code>generate-report.dto.ts:6-33</code>).</li> </ul> <h2 id="c5--metriccard-s5-result-metrics"><a class="header" href="#c5--metriccard-s5-result-metrics">C5 — <code>MetricCard</code> (S5 result metrics)</a></h2> <ul> <li>Number + label card. Used by fee summary (<code>totalInvoices</code>, <code>totalCollected</code>, <code>totalPending</code>, <code>totalOverdue</code> — <code>reports.service.ts:154-158</code>) and attendance (<code>total</code> + per-status counts — <code>reports.service.ts:131-136</code>).</li> <li>Currency variant for money values; integer variant otherwise.</li> <li><code>MetricCardGrid</code> wrapper (2 cols mobile, 4 tablet).</li> </ul> <h2 id="c6--reportcardresult-s5-report-card"><a class="header" href="#c6--reportcardresult-s5-report-card">C6 — <code>ReportCardResult</code> (S5 report card)</a></h2> <ul> <li>Header (studentName — note admission number today, <code>reports.service.ts:98</code>, examId, generatedAt), subject table (<code>subjects[]</code> from <code>result.service.ts:68-91</code>), footer metrics + <code>grade</code> badge (bands <code>result.service.ts:130-136</code>).</li> <li>Long subject lists → scrollable table (landscape hint).</li> </ul> <h2 id="c7--joblistrow-s3"><a class="header" href="#c7--joblistrow-s3">C7 — <code>JobListRow</code> (S3)</a></h2> <ul> <li>Extends <code>AppListTile</code>: status leading icon, type label, relative createdAt, <code>ReportStatusBadge</code> trailing, optional "Scheduled" chip.</li> <li>Pull-to-refresh via <code>RefreshIndicator</code> (re-poll visible rows).</li> </ul> <h2 id="c8--pollingretrybutton-s4"><a class="header" href="#c8--pollingretrybutton-s4">C8 — <code>PollingRetryButton</code> (S4)</a></h2> <ul> <li>Manual "Retry check" shown when a poll attempt fails (network); resumes the auto-poll cycle (<a href="reports/10_Interaction_Specification.html">10</a>).</li> </ul> <hr /> <h2 id="shared-components-consumed"><a class="header" href="#shared-components-consumed">Shared components consumed</a></h2> <p><code>AppListTile</code>, <code>AppBadge</code>, <code>AppButton</code>, <code>AppChips</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppSnackbar</code>, <code>AppOfflineBanner</code>, <code>AppBottomSheet</code>, <code>AppTextField</code>, <code>AppDatePicker</code> (<a href="reports/../00-shared/03_Component_Library.html">00-shared/03</a>; pickers for S2).</p> <h2 id="rules"><a class="header" href="#rules">Rules</a></h2> <ul> <li>No widget may hardcode a status/type string — always map from the server enums so future types (<code>IMPLEMENTATION_PLAN.md:646</code>) degrade to a generic row.</li> <li>Fallback: unknown type label → raw enum string.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-reports-module"><a class="header" href="#08--form-specifications-reports-module">08 — Form Specifications (Reports Module)</a></h1> <ul> <li><a href="reports/08_Form_Specifications.html#1-generate-report--field-table">1. Generate report — field table</a></li> <li><a href="reports/08_Form_Specifications.html#2-conditional-visibility">2. Conditional visibility</a></li> <li><a href="reports/08_Form_Specifications.html#3-client-side-validation-rules">3. Client-side validation rules</a></li> <li><a href="reports/08_Form_Specifications.html#4-submit-payloads-examples">4. Submit payloads (examples)</a></li> <li><a href="reports/08_Form_Specifications.html#5-server-side-error-mapping">5. Server-side error mapping</a></li> <li><a href="reports/08_Form_Specifications.html#6-accessibility--behavior">6. Accessibility / behavior</a></li> </ul> <blockquote> <p>The single form: Generate Report (S2). Fields mirror <code>GenerateReportDto</code> (<code>generate-report.dto.ts:5-34</code>). Field-level contract, validation, and conditional visibility per report type.</p> </blockquote> <hr /> <h2 id="1-generate-report--field-table"><a class="header" href="#1-generate-report--field-table">1. Generate report — field table</a></h2> <p>Server DTO: <code>generate-report.dto.ts:5-34</code>. All params <code>@IsOptional</code>; only <code>type</code> is required and enum-validated (<code>:6-8</code>).</p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Control</th><th>Type</th><th>Required (DTO)</th><th>Required (service)</th><th>Notes</th></tr></thead><tbody> <tr><td><code>type</code></td><td>fixed selector (from catalog)</td><td>enum</td><td>✅</td><td>—</td><td>3 values (<code>report-job.schema.ts:7-11</code>)</td></tr> <tr><td><code>studentId</code></td><td>student picker</td><td>MongoId</td><td>❌</td><td>✅ for <code>report_card</code></td><td><code>reports.service.ts:91</code> — <strong>client must require</strong></td></tr> <tr><td><code>classId</code></td><td>class picker</td><td>MongoId</td><td>❌</td><td>❌</td><td>attendance filter <code>reports.service.ts:118</code></td></tr> <tr><td><code>examId</code></td><td>exam picker</td><td>MongoId</td><td>❌</td><td>✅ for <code>report_card</code></td><td><code>reports.service.ts:91</code></td></tr> <tr><td><code>startDate</code></td><td>date picker</td><td>string</td><td>❌</td><td>❌</td><td><code>$gte</code> filter <code>reports.service.ts:120-121</code></td></tr> <tr><td><code>endDate</code></td><td>date picker</td><td>string</td><td>❌</td><td>❌</td><td><code>$lte</code> filter <code>reports.service.ts:122</code></td></tr> </tbody></table> </div> <p>DTO format checks: <code>@IsMongoId</code> on id fields (<code>:11-13,17-19,22-23</code>), <code>@IsString</code> on dates (<code>:27-33</code>) — <strong>no ISO/date-format validation server-side</strong>.</p> <h2 id="2-conditional-visibility"><a class="header" href="#2-conditional-visibility">2. Conditional visibility</a></h2> <div class="table-wrapper"><table><thead><tr><th>Type</th><th>Visible fields</th><th>Hidden</th></tr></thead><tbody> <tr><td><code>report_card</code></td><td>studentId, examId</td><td>classId, dates</td></tr> <tr><td><code>attendance_summary</code></td><td>classId (optional), startDate, endDate (optional)</td><td>studentId, examId</td></tr> <tr><td><code>fee_summary</code></td><td>none — info card</td><td>all</td></tr> </tbody></table> </div> <p>Hidden fields must NOT be sent (server echoes <code>params</code> blindly: <code>reports.service.ts:31-33</code>; sending junk pollutes the job doc).</p> <h2 id="3-client-side-validation-rules"><a class="header" href="#3-client-side-validation-rules">3. Client-side validation rules</a></h2> <ol> <li><code>report_card</code>: <code>studentId</code> + <code>examId</code> <strong>required before submit</strong> — the service fails the job asynchronously otherwise: <code>"studentId and examId required"</code> (<code>reports.service.ts:91</code>). This is the only real required rule.</li> <li><code>startDate ≤ endDate</code> (client check; server does no comparison — <code>reports.service.ts:119-123</code> builds a range regardless).</li> <li>Dates serialized ISO <code>YYYY-MM-DD</code> (server treats as strings, <code>reports.service.ts:120-122</code>).</li> <li>No duplicate submit while in flight (server has no idempotency key — each POST = new job, <code>reports.service.ts:31-43</code>).</li> </ol> <h2 id="4-submit-payloads-examples"><a class="header" href="#4-submit-payloads-examples">4. Submit payloads (examples)</a></h2> <pre><code class="language-json">{ "type": "report_card", "studentId": "64f…", "examId": "65a…" } </code></pre> <pre><code class="language-json">{ "type": "attendance_summary", "classId": "64e…", "startDate": "2026-08-01" } </code></pre> <pre><code class="language-json">{ "type": "fee_summary" } </code></pre> <h2 id="5-server-side-error-mapping"><a class="header" href="#5-server-side-error-mapping">5. Server-side error mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Failure</th><th>Source</th><th>Client handling</th></tr></thead><tbody> <tr><td>400 invalid <code>type</code></td><td>DTO <code>@IsEnum</code> (<code>:6-8</code>)</td><td>inline, catalog shouldn't produce it</td></tr> <tr><td>400 malformed id</td><td><code>@IsMongoId</code> (<code>:11-13</code>)</td><td>inline field error</td></tr> <tr><td>429 rate limit</td><td>global (<code>00-shared/07</code>)</td><td>countdown, no auto-retry</td></tr> <tr><td>401 expired token</td><td>JWT guard (<code>reports.controller.ts:9</code>)</td><td>silent refresh → resubmit</td></tr> <tr><td>5xx</td><td>filter (<code>http-exception.filter.ts</code>)</td><td>snackbar + form kept (re-POST safe)</td></tr> <tr><td>job <code>failed</code> (async)</td><td><code>reports.service.ts:79-82</code></td><td>surfaced on S4, NOT the form</td></tr> </tbody></table> </div> <h2 id="6-accessibility--behavior"><a class="header" href="#6-accessibility--behavior">6. Accessibility / behavior</a></h2> <ul> <li>Picker fields: searchable dropdowns; semantics "select <label>".</li> <li>Dates via <code>AppDatePicker</code> (<code>00-shared/03</code>), range constraint (2).</li> <li>Keyboard: <code>TextInputAction.next</code> chain; submit on last field <code>done</code> (mobile).</li> <li>Errors: inline + live-region announcement, focus first invalid field.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-reports-module"><a class="header" href="#09--user-behaviour-reports-module">09 — User Behaviour (Reports Module)</a></h1> <ul> <li><a href="reports/09_User_Behaviour.html#1-behavioural-patterns">1. Behavioural patterns</a> <ul> <li><a href="reports/09_User_Behaviour.html#b1--submit-and-watch-dominant">B1 — Submit-and-watch (dominant)</a></li> <li><a href="reports/09_User_Behaviour.html#b2--submit-and-come-back">B2 — Submit-and-come-back</a></li> <li><a href="reports/09_User_Behaviour.html#b3--re-run-with-tweaks">B3 — Re-run with tweaks</a></li> <li><a href="reports/09_User_Behaviour.html#b4--failure--retry">B4 — Failure → retry</a></li> <li><a href="reports/09_User_Behaviour.html#b5--scheduled-report-consumption">B5 — Scheduled report consumption</a></li> </ul> </li> <li><a href="reports/09_User_Behaviour.html#2-behavioural-anti-patterns-to-design-out">2. Behavioural anti-patterns to design out</a></li> <li><a href="reports/09_User_Behaviour.html#3-behavioural-metrics-proposed">3. Behavioural metrics (proposed)</a></li> </ul> <blockquote> <p>Expected behaviour patterns and the server facts that shape them. Behavior analytics <code>(proposed)</code> throughout; no analytics SDK in repo (<a href="reports/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a>).</p> </blockquote> <hr /> <h2 id="1-behavioural-patterns"><a class="header" href="#1-behavioural-patterns">1. Behavioural patterns</a></h2> <h3 id="b1--submit-and-watch-dominant"><a class="header" href="#b1--submit-and-watch-dominant">B1 — Submit-and-watch (dominant)</a></h3> <p>User submits → lands on S4 → watches status go queued → processing → completed. Average wait: seconds (attendance/fee) to minutes (term-end card batches). <strong>Server fact</strong>: no WebSocket topic, no progress % — polling is the only mechanism (<code>reports.controller.ts:20-24</code>).</p> <p>Implications:</p> <ul> <li>Default poll 2 s (<a href="reports/10_Interaction_Specification.html">10</a>); show elapsed time so the wait feels productive.</li> <li>Do not block navigation — user can leave S4; job continues (server-owned).</li> </ul> <h3 id="b2--submit-and-come-back"><a class="header" href="#b2--submit-and-come-back">B2 — Submit-and-come-back</a></h3> <p>User submits then leaves; returns later via job list. <strong>Server fact</strong>: job doc persists with <code>params</code> + <code>result</code> (<code>report-job.schema.ts:33-36</code>) — result is re-readable indefinitely (no TTL in schema; blueprint TTL cleanup is for files, <code>04-Modules/Reports.md:50</code>).</p> <p>Implication: S4 must render a terminal job from a single GET — no re-poll.</p> <h3 id="b3--re-run-with-tweaks"><a class="header" href="#b3--re-run-with-tweaks">B3 — Re-run with tweaks</a></h3> <p>Completed fee summary → "New report from these params" (S4 CTA) — users regenerate monthly with same/edited params. Server: cheap, new job per POST (<code>reports.service.ts:31-43</code>). Form prefills from <code>params</code> (<code>:33</code>).</p> <h3 id="b4--failure--retry"><a class="header" href="#b4--failure--retry">B4 — Failure → retry</a></h3> <p>Job <code>failed</code> → user retries. Server facts: error string stored (<code>report-job.schema.ts:39</code>, <code>reports.service.ts:79-82</code>); retry = new POST; known async failures: missing student/exam (<code>:91</code>), student not found (<code>:94</code>), unknown type (<code>:75</code>).</p> <p>Implication: surface the exact error; if it says "studentId and examId required", send user to the form, not retry.</p> <h3 id="b5--scheduled-report-consumption"><a class="header" href="#b5--scheduled-report-consumption">B5 — Scheduled report consumption</a></h3> <p>Daily/weekly attendance jobs (<code>attendance-report.job.ts:13-24</code>) appear in job list. Today there is no list API (S3 gap) and no completion notification (<code>ReportGenerated</code> unmapped, <code>event-queue-map.ts</code>) — users discover via history <code>(forward-looking)</code>.</p> <h2 id="2-behavioural-anti-patterns-to-design-out"><a class="header" href="#2-behavioural-anti-patterns-to-design-out">2. Behavioural anti-patterns to design out</a></h2> <div class="table-wrapper"><table><thead><tr><th>Anti-pattern</th><th>Design response</th></tr></thead><tbody> <tr><td>Double-submit → duplicate jobs</td><td>disable CTA while in flight (B1)</td></tr> <tr><td>Staring at an eternal spinner after network loss</td><td>banner + pause polling + manual retry (S4)</td></tr> <tr><td>Confusing <code>failed</code> with a server error</td><td>distinct failed state + error text + Retry vs form CTA</td></tr> <tr><td>Expecting a PDF that doesn't exist</td><td>hide export CTAs until <code>(planned)</code> download endpoint lands</td></tr> <tr><td>Filtering a job list that has no API</td><td>local history only; label clearly; no fake "all jobs" count</td></tr> </tbody></table> </div> <h2 id="3-behavioural-metrics-proposed"><a class="header" href="#3-behavioural-metrics-proposed">3. Behavioural metrics (proposed)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Metric</th><th>Event</th><th>Target</th></tr></thead><tbody> <tr><td>Generate → completed seen</td><td><code>reports.job.status</code> terminal</td><td>≥ 90% of polls reach terminal</td></tr> <tr><td>Time-to-complete (per type)</td><td><code>reports.job.status(completed)</code> + createdAt</td><td>fee/attendance < 5 s p95</td></tr> <tr><td>Retry rate</td><td><code>reports.job.retry</code></td><td>< 5%</td></tr> <tr><td>Repeat generation</td><td>"new from params"</td><td>≥ 20% monthly of fee summaries</td></tr> <tr><td>Export adoption</td><td><code>reports.result.download</code></td><td>n/a until <code>(planned)</code></td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-reports-module"><a class="header" href="#10--interaction-specification-reports-module">10 — Interaction Specification (Reports Module)</a></h1> <ul> <li><a href="reports/10_Interaction_Specification.html#1-the-polling-engine-core-interaction">1. The polling engine (core interaction)</a> <ul> <li><a href="reports/10_Interaction_Specification.html#algorithm">Algorithm</a></li> <li><a href="reports/10_Interaction_Specification.html#parameters">Parameters</a></li> <li><a href="reports/10_Interaction_Specification.html#jitter--life">Jitter & life</a></li> </ul> </li> <li><a href="reports/10_Interaction_Specification.html#2-status-transitions-ui">2. Status transitions (UI)</a></li> <li><a href="reports/10_Interaction_Specification.html#3-screen-level-interactions">3. Screen-level interactions</a> <ul> <li><a href="reports/10_Interaction_Specification.html#s2-generate-form">S2 Generate form</a></li> <li><a href="reports/10_Interaction_Specification.html#s3-job-list">S3 Job list</a></li> <li><a href="reports/10_Interaction_Specification.html#s4-job-detail">S4 Job detail</a></li> <li><a href="reports/10_Interaction_Specification.html#s5-result">S5 Result</a></li> </ul> </li> <li><a href="reports/10_Interaction_Specification.html#4-error-taxonomy-client">4. Error taxonomy (client)</a></li> <li><a href="reports/10_Interaction_Specification.html#5-motion--haptics">5. Motion & haptics</a></li> <li><a href="reports/10_Interaction_Specification.html#6-keyboard--adaptive-s2">6. Keyboard / adaptive (S2)</a></li> </ul> <blockquote> <p>Interaction contracts: polling engine, transitions, error handling, motion. Motion tokens <code>m-fast</code> / <code>m-base</code> from <a href="reports/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>; shared interaction baseline <a href="reports/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>.</p> </blockquote> <hr /> <h2 id="1-the-polling-engine-core-interaction"><a class="header" href="#1-the-polling-engine-core-interaction">1. The polling engine (core interaction)</a></h2> <p>Server contract: <code>GET /reports/:jobId</code> returns the full job doc (<code>reports.controller.ts:20-24</code>, <code>reports.service.ts:46-50</code>); statuses <code>queued | processing | completed | failed</code> (<code>report-job.schema.ts:13-18</code>).</p> <h3 id="algorithm"><a class="header" href="#algorithm">Algorithm</a></h3> <pre><code>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 </code></pre> <h3 id="parameters"><a class="header" href="#parameters">Parameters</a></h3> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Value</th><th>Rationale</th></tr></thead><tbody> <tr><td>Interval</td><td>2 s</td><td>fast enough for UX; harmless load (1 doc query, indexed <code>{tenantId,status}</code>, <code>report-job.schema.ts:47</code>)</td></tr> <tr><td>Max consecutive failures</td><td>2</td><td>then pause; exponential backoff for manual retry (1 s → 2 s → 4 s)</td></tr> <tr><td>Timeout per request</td><td>10 s</td><td>global client HTTP timeout (<code>00-shared/07</code>)</td></tr> <tr><td>Max overall wait</td><td>none</td><td>server has no job TTL; user can leave and return (B2)</td></tr> </tbody></table> </div> <h3 id="jitter--life"><a class="header" href="#jitter--life">Jitter & life</a></h3> <ul> <li>Poll times: 2 s ± 400 ms jitter (thundering-herd avoidance at term end when a school generates N cards).</li> <li>Poll cycle lifecycle: starts on S4 <code>initState</code>; cancelled on dispose; a <code>Timer</code> (not a loop) so cancellation is clean.</li> </ul> <h2 id="2-status-transitions-ui"><a class="header" href="#2-status-transitions-ui">2. Status transitions (UI)</a></h2> <div class="table-wrapper"><table><thead><tr><th>From</th><th>To</th><th>Trigger</th><th>Transition</th></tr></thead><tbody> <tr><td>queued</td><td>processing</td><td>poll</td><td>badge swap (C2), progress row gains elapsed timer — <code>m-fast</code> crossfade</td></tr> <tr><td>processing</td><td>completed</td><td>poll</td><td>success reveal; result card slides in <code>m-base</code>; haptic light (mobile)</td></tr> <tr><td>processing</td><td>failed</td><td>poll</td><td>error card + error text; <code>m-fast</code> fade</td></tr> <tr><td>any non-terminal</td><td>offline</td><td>network fail</td><td>banner slide-in; cycle pauses</td></tr> <tr><td>offline</td><td>resumed</td><td>manual retry</td><td>banner clears; cycle resumes</td></tr> </tbody></table> </div> <p>No transition animation for the badge itself beyond color change — keep perceptual noise low at 2 s cadence.</p> <h2 id="3-screen-level-interactions"><a class="header" href="#3-screen-level-interactions">3. Screen-level interactions</a></h2> <h3 id="s2-generate-form"><a class="header" href="#s2-generate-form">S2 Generate form</a></h3> <ul> <li>Submit → button <code>loading</code>; on success: navigate pushReplacement to S4 (form is dead after submission; back returns to catalog).</li> <li>Failure: inline errors, form intact.</li> <li><code>report_card</code> without required ids: inline error BEFORE network (<code>08 §3.1</code>).</li> </ul> <h3 id="s3-job-list"><a class="header" href="#s3-job-list">S3 Job list</a></h3> <ul> <li>Pull-to-refresh: re-poll visible rows (only non-terminal ones).</li> <li>Row tap → S4. Failed row menu: Retry (re-POST <code>params</code> → new jobId, <code>reports.service.ts:31-43</code>).</li> <li>Filter chips (All/Running/Completed/Failed) filter <strong>locally</strong> — no query API.</li> </ul> <h3 id="s4-job-detail"><a class="header" href="#s4-job-detail">S4 Job detail</a></h3> <ul> <li>Auto-poll (1). Elapsed timer ticks 1 s while processing.</li> <li>Retry (failed): confirm dialog → new POST → pushReplacement to new job detail.</li> <li>"New report from these params": prefill S2 from <code>params</code> (<code>:33</code>).</li> </ul> <h3 id="s5-result"><a class="header" href="#s5-result">S5 Result</a></h3> <ul> <li>Scrollable content; export CTAs disabled w/ tooltip "coming soon" <code>(planned)</code>.</li> <li>Share sheet <code>(forward-looking)</code> once PDF exists.</li> </ul> <h2 id="4-error-taxonomy-client"><a class="header" href="#4-error-taxonomy-client">4. Error taxonomy (client)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Error</th><th>Detection</th><th>UX</th></tr></thead><tbody> <tr><td>404</td><td>poll GET</td><td>not-found state; back to list</td></tr> <tr><td>401</td><td>interceptor</td><td>silent refresh, resume poll</td></tr> <tr><td>429</td><td>interceptor</td><td>pause poll 30 s; countdown snackbar; no auto-retry (<code>00-shared/10</code>)</td></tr> <tr><td>network</td><td>timeout/connection</td><td>banner + pause (1)</td></tr> <tr><td>job failed</td><td>poll GET status</td><td>terminal UI (2)</td></tr> </tbody></table> </div> <h2 id="5-motion--haptics"><a class="header" href="#5-motion--haptics">5. Motion & haptics</a></h2> <ul> <li>Status badge changes: <code>m-fast</code> (150 ms) color/opacity crossfade.</li> <li>Result reveal: <code>m-base</code> (250 ms) slide-up + fade.</li> <li>Screen push: shared push transition (<code>00-shared/08</code>).</li> <li>Haptics: <code>HapticFeedback.mediumImpact</code> on completed reveal; light on failed.</li> </ul> <h2 id="6-keyboard--adaptive-s2"><a class="header" href="#6-keyboard--adaptive-s2">6. Keyboard / adaptive (S2)</a></h2> <ul> <li><code>resizeToAvoidBottomInset</code>; next-chain; tablet: form left, live job preview right (<code>05 §Keyboard</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-reports-module"><a class="header" href="#11--design-system-mapping-reports-module">11 — Design System Mapping (Reports Module)</a></h1> <ul> <li><a href="reports/11_Design_System_Mapping.html#1-screens--design-tokens">1. Screens → design tokens</a></li> <li><a href="reports/11_Design_System_Mapping.html#2-component-mapping">2. Component mapping</a></li> <li><a href="reports/11_Design_System_Mapping.html#3-icons">3. Icons</a></li> <li><a href="reports/11_Design_System_Mapping.html#4-typography--density">4. Typography & density</a></li> <li><a href="reports/11_Design_System_Mapping.html#5-dark-mode">5. Dark mode</a></li> <li><a href="reports/11_Design_System_Mapping.html#6-accessibility-mapping-00-shared09">6. Accessibility mapping (00-shared/09)</a></li> <li><a href="reports/11_Design_System_Mapping.html#7-deviations--notes">7. Deviations & notes</a></li> </ul> <blockquote> <p>Maps every Reports surface to the shared design system. Tokens: <a href="reports/../00-shared/02_Design_Tokens.html">00-shared/02</a> (Material 3, <strong>proposed</strong> defaults); components: <a href="reports/../00-shared/03_Component_Library.html">00-shared/03</a>; motion: <a href="reports/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>; a11y: <a href="reports/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>. Module components <a href="reports/07_Component_Library.html">07_Component_Library.md</a>.</p> </blockquote> <hr /> <h2 id="1-screens--design-tokens"><a class="header" href="#1-screens--design-tokens">1. Screens → design tokens</a></h2> <div class="table-wrapper"><table><thead><tr><th>Surface</th><th>Color</th><th>Type scale</th><th>Spacing</th><th>Shape</th></tr></thead><tbody> <tr><td>S1 catalog</td><td><code>surface</code> bg; <code>primary</code> icons</td><td><code>titleLarge</code> (list titles), <code>bodyMedium</code> (descriptions)</td><td>list item 16-8-16; section gap 24</td><td><code>shapeMedium</code> tiles</td></tr> <tr><td>S2 form</td><td><code>surface</code>; <code>primary</code> CTA; <code>error</code> inline</td><td><code>headlineSmall</code> (title), <code>bodyLarge</code> (fields), <code>labelMedium</code> (chips)</td><td>field gap 16; bottom padding 24</td><td><code>shapeMedium</code> inputs, <code>shapeSmall</code> chips</td></tr> <tr><td>S3 job list</td><td><code>surface</code>; status colors (C2)</td><td><code>titleMedium</code> rows, <code>bodySmall</code> meta</td><td>row 12; chip gap 8</td><td><code>shapeSmall</code> badges</td></tr> <tr><td>S4 detail</td><td><code>surfaceContainerLow</code> cards on <code>surface</code></td><td><code>headlineSmall</code> type label, <code>bodyMedium</code> meta</td><td>card padding 16; card gap 12</td><td><code>shapeLarge</code> status card</td></tr> <tr><td>S5 result</td><td><code>surface</code>; <code>secondaryContainer</code> metric cards; <code>error</code> failed</td><td><code>displaySmall</code> metrics (fee), <code>titleMedium</code> labels, <code>bodyMedium</code> table</td><td>metric grid gap 12; table cell 8</td><td><code>shapeMedium</code> cards; grade badge <code>shapeSmall</code></td></tr> <tr><td>Status palette</td><td>queued: <code>surfaceVariant</code>; processing: <code>tertiary</code>; completed: <code>primary</code>; failed: <code>error</code></td><td>—</td><td>—</td><td>—</td></tr> </tbody></table> </div> <h2 id="2-component-mapping"><a class="header" href="#2-component-mapping">2. Component mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Shared (<a href="reports/../00-shared/03_Component_Library.html">00-shared/03</a>)</th><th>Module (07)</th></tr></thead><tbody> <tr><td>S1</td><td>AppListTile, AppBadge</td><td>ReportTypeTile</td></tr> <tr><td>S2</td><td>AppTextField, AppDatePicker, AppButton, AppSnackbar, AppChips</td><td>(none)</td></tr> <tr><td>S3</td><td>AppListTile, AppSkeleton, AppEmptyState, RefreshIndicator, AppMenu</td><td>ReportStatusBadge, JobListRow</td></tr> <tr><td>S4</td><td>AppCard, AppButton, AppSnackbar, AppOfflineBanner</td><td>JobProgressCard, JobParamChips, PollingRetryButton</td></tr> <tr><td>S5</td><td>AppCard</td><td>MetricCard/Grid, ReportCardResult</td></tr> </tbody></table> </div> <h2 id="3-icons"><a class="header" href="#3-icons">3. Icons</a></h2> <p><code>schedule</code> (queued), <code>hourglass_top</code> (processing), <code>check_circle</code> (completed), <code>error_circle</code> (failed) — outlined set; 20 dp grid, <code>onSurfaceVariant</code>.</p> <h2 id="4-typography--density"><a class="header" href="#4-typography--density">4. Typography & density</a></h2> <ul> <li>Numbers (fee metrics) use tabular figures (<code>fontFeatures: tabularFigures</code>) — alignment-critical.</li> <li>Mobile density 4, tablet 0 (<a href="reports/../00-shared/02_Design_Tokens.html">00-shared/02</a>).</li> </ul> <h2 id="5-dark-mode"><a class="header" href="#5-dark-mode">5. Dark mode</a></h2> <ul> <li>All tokens dark-adaptive; status palette keeps hue, lightens <code>on-</code> colors.</li> <li>Indeterminate progress: <code>tertiaryContainer</code> track.</li> </ul> <h2 id="6-accessibility-mapping-00-shared09-1"><a class="header" href="#6-accessibility-mapping-00-shared09-1">6. Accessibility mapping (<a href="reports/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Surface</th><th>Requirement</th></tr></thead><tbody> <tr><td>all</td><td>4.5:1 text contrast; touch targets ≥ 48 dp</td></tr> <tr><td>S4</td><td>status change announced via live region (badge swap must not re-announce whole card)</td></tr> <tr><td>S5</td><td>result table: proper <code>Semantics</code> header mapping; grade badge label includes band</td></tr> <tr><td>S2</td><td>errors linked to fields (<code>errorText</code> semantics)</td></tr> <tr><td>all</td><td><code>ReduceMotion</code>: poll unaffected; replace slide/fade with instant swap</td></tr> </tbody></table> </div> <h2 id="7-deviations--notes"><a class="header" href="#7-deviations--notes">7. Deviations & notes</a></h2> <ul> <li>No brand illustration; report icons are the only imagery — matches shared baseline.</li> <li>Progress: indeterminate only (server constraint) — do not restyle into a fake percentage (<a href="reports/07_Component_Library.html">07 C3</a>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-reports-module"><a class="header" href="#12--api-mapping-reports-module">12 — API Mapping (Reports Module)</a></h1> <ul> <li><a href="reports/12_API_Mapping.html#0-module-wide-request-envelope--client-policy">0. Module-wide request envelope & client policy</a></li> <li><a href="reports/12_API_Mapping.html#e1--generate-a-report">E1 — Generate a report</a></li> <li><a href="reports/12_API_Mapping.html#e2--get-job-status-and-result-polling-contract">E2 — Get job status and result (polling contract)</a></li> <li><a href="reports/12_API_Mapping.html#e3--download-result-planned">E3 — Download result <code>(planned)</code></a></li> <li><a href="reports/12_API_Mapping.html#e4--list-jobs-needed-by-s3">E4 — List jobs (needed by S3)</a></li> <li><a href="reports/12_API_Mapping.html#e5--files-delivery-path-for-future-exports-planned">E5 — Files (delivery path for future exports) <code>(planned)</code></a></li> <li><a href="reports/12_API_Mapping.html#e6--scheduler-scheduled-reports">E6 — Scheduler (scheduled reports)</a></li> <li><a href="reports/12_API_Mapping.html#worker-contract-server-side-context">Worker contract (server-side context)</a></li> <li><a href="reports/12_API_Mapping.html#client-side-error-mapping-table">Client-side error mapping table</a></li> <li><a href="reports/12_API_Mapping.html#optimistic--undo">Optimistic / undo</a></li> </ul> <blockquote> <p>Exact wire contract for every screen → endpoint. Base <code>/api/v1</code>; envelope per <a href="reports/../00-shared/07_API_Conventions.html">00-shared/07</a> and <code>response-envelope.interceptor.ts</code> / <code>http-exception.filter.ts</code>. Endpoints from <code>reports.controller.ts</code>; business rules from <code>reports.service.ts</code>, <code>report-job.repository.ts</code>, <code>report-job.schema.ts</code>. Controller guard chain: <code>JwtAuthGuard</code> only (<code>reports.controller.ts:9</code>).</p> </blockquote> <hr /> <h2 id="0-module-wide-request-envelope--client-policy-12"><a class="header" href="#0-module-wide-request-envelope--client-policy-12">0. Module-wide request envelope & client policy</a></h2> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Contract</th></tr></thead><tbody> <tr><td>Base</td><td><code>https://api.<domain>/api/v1</code></td></tr> <tr><td>Headers</td><td><code>Authorization: Bearer <accessToken></code>; <code>x-request-id</code>; <code>Content-Type: application/json</code></td></tr> <tr><td>Success</td><td><code>{success:true, message:"OK", data, timestamp, requestId}</code> (<code>response-envelope.interceptor.ts:48-53</code>)</td></tr> <tr><td>Error</td><td><code>{success:false, message, error:{code,details?}, timestamp, requestId}</code> (<code>http-exception.filter.ts:27-35</code>)</td></tr> <tr><td>Tenancy</td><td><code>tenantId</code> never in body — from JWT via <code>TenantContextService</code>; repository tenant-scoped (<code>base.repository.ts:21-23</code>); worker restores tenant context (<code>report.worker.ts:20-29</code>)</td></tr> <tr><td>RBAC</td><td>⚠ <strong>Not enforced on reports endpoints.</strong> <code>report.generate</code> / <code>report.read</code> exist (<code>permissions.constants.ts:39-40</code>) but the controller carries only <code>@UseGuards(JwtAuthGuard)</code> (<code>reports.controller.ts:9</code>) and no <code>@Permissions</code> decorator. Client must hide/disable by permission list until server enforces (OQ — see <a href="reports/14_QA_Checklist.html">14 §9</a>).</td></tr> <tr><td>Polling</td><td>No server push; client polls <code>GET /reports/:jobId</code> per <a href="reports/10_Interaction_Specification.html">10 §1</a></td></tr> </tbody></table> </div> <hr /> <h2 id="e1--generate-a-report"><a class="header" href="#e1--generate-a-report">E1 — Generate a report</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>POST /reports/generate</code> (<code>reports.controller.ts:14-18</code>)</td></tr> <tr><td><strong>Request</strong></td><td><code>GenerateReportDto</code> (<code>generate-report.dto.ts:5-34</code>): <code>type</code> (req, enum <code>report_card|attendance_summary|fee_summary</code> — <code>report-job.schema.ts:7-11</code>), <code>studentId?</code>/<code>classId?</code>/<code>examId?</code> (<code>@IsMongoId</code>), <code>startDate?</code>/<code>endDate?</code> (<code>@IsString</code>)</td></tr> <tr><td><strong>Success</strong></td><td>201 (Nest default — blueprint says 202, <code>04-Modules/Reports.md:48</code>; code returns the default, gap) <code>data: {jobId, status:"queued"}</code> (<code>reports.service.ts:43</code>)</td></tr> <tr><td><strong>Semantics</strong></td><td>Creates <code>report_jobs</code> doc (<code>:31-34</code>), enqueues BullMQ job <code>generate</code> on <code>report-generate</code> queue with <code>{jobId, tenantId, type, params}</code> (<code>:36-41</code>). <strong>No idempotency key</strong> — each POST = new job</td></tr> <tr><td><strong>Errors</strong></td><td>400 (enum/mongo-id validation); 401; 429; 5xx (queue down)</td></tr> <tr><td><strong>Screen</strong></td><td>S2 (<a href="reports/06_Screen_Specifications.html">06 §S2</a>)</td></tr> </tbody></table> </div> <h2 id="e2--get-job-status-and-result-polling-contract"><a class="header" href="#e2--get-job-status-and-result-polling-contract">E2 — Get job status and result (polling contract)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>GET /reports/:jobId</code> (<code>reports.controller.ts:20-24</code>)</td></tr> <tr><td><strong>Success</strong></td><td>200 <code>data: ReportJobDocument</code> — full doc (<code>report-job.schema.ts:21-43</code>) incl. <code>type, status, params, result?, error?, completedAt?, createdAt</code></td></tr> <tr><td><strong>404</strong></td><td><code>RESOURCE_NOT_FOUND</code>, <code>Report job not found.</code> (<code>reports.service.ts:48</code>) — tenant-scoped; cross-tenant id also 404s</td></tr> <tr><td><strong>Polling</strong></td><td>Client polls every 2 s while <code>status ∈ {queued, processing}</code>; terminal statuses <code>completed</code>/<code>failed</code> stop the cycle (<a href="reports/10_Interaction_Specification.html">10 §1</a>); 404 → stop</td></tr> <tr><td><strong>Screen</strong></td><td>S4/S5 (<a href="reports/06_Screen_Specifications.html">06 §S4-S5</a>)</td></tr> </tbody></table> </div> <h2 id="e3--download-result-planned"><a class="header" href="#e3--download-result-planned">E3 — Download result <code>(planned)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>GET /reports/:jobId/download</code> — blueprint only (<code>04-Modules/Reports.md:26</code>)</td></tr> <tr><td><strong>Status</strong></td><td>NOT in code. No controller route, no PDF/CSV serializer, no file write. Blueprint intent: streamed downloads (<code>:49</code>), files under <code>sl/{tenantId}/reports/{uuid}</code> with TTL cleanup (<code>:50</code>)</td></tr> <tr><td><strong>Interim</strong></td><td>Until it lands: results are JSON on the job doc (E2); export CTAs hidden (<a href="reports/06_Screen_Specifications.html">06 §S5</a>)</td></tr> </tbody></table> </div> <h2 id="e4--list-jobs-needed-by-s3"><a class="header" href="#e4--list-jobs-needed-by-s3">E4 — List jobs (needed by S3)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td>None. Index <code>{tenantId, createdAt:-1}</code> exists (<code>report-job.schema.ts:48</code>) but no <code>GET /reports</code> route</td></tr> <tr><td><strong>Client</strong></td><td>local persisted history of submitted jobIds; <code>(forward-looking)</code> server <code>GET /reports?status=</code></td></tr> </tbody></table> </div> <h2 id="e5--files-delivery-path-for-future-exports-planned"><a class="header" href="#e5--files-delivery-path-for-future-exports-planned">E5 — Files (delivery path for future exports) <code>(planned)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Upload</strong></td><td><code>POST /files/upload</code> (multipart, <code>file.upload</code> — <code>files.controller.ts:29-41</code>)</td></tr> <tr><td><strong>Read/download</strong></td><td><code>GET /files/:id</code> (<code>file.read</code>, <code>:49-53</code>); <code>GET /files/:id/download</code> (<code>file.read</code>, <code>:55-64</code>) → <code>Content-Type</code> + <code>Content-Disposition: attachment; filename="…"</code> streamed buffer (<code>files.service.ts:58-64</code>)</td></tr> <tr><td><strong>Delete</strong></td><td><code>DELETE /files/:id</code> (<code>file.delete</code>, <code>:66-71</code>) — soft delete</td></tr> <tr><td><strong>Note</strong></td><td>Report generation does NOT write files today; E3 will need this path</td></tr> </tbody></table> </div> <h2 id="e6--scheduler-scheduled-reports"><a class="header" href="#e6--scheduler-scheduled-reports">E6 — Scheduler (scheduled reports)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td>none (admin API <code>(planned)</code>, perms <code>scheduler.read/create/delete</code>, <code>permissions.constants.ts:94-96</code>)</td></tr> <tr><td><strong>Behavior</strong></td><td><code>AttendanceReportJob.execute</code> enqueues <code>generate-attendance-report</code> on the <code>report-generate</code> queue (<code>attendance-report.job.ts:13-24</code>); scheduler service wires queue <code>QUEUE.REPORT_GENERATE</code> (<code>scheduler.service.ts:32,102,163,219</code>)</td></tr> <tr><td><strong>Payload</strong></td><td><code>{eventType:"attendance-report", tenantId, correlationId, actorId:"scheduler", payload:{reportType:"daily"|"weekly"}}</code> (<code>attendance-report.job.ts:16-21</code>)</td></tr> <tr><td><strong>Note</strong></td><td>Enqueued jobs use jobName <code>generate-attendance-report</code>; <code>ReportsService.executeJob</code> is reached via the worker (<code>report.worker.ts:34</code>) and switches on <strong>job doc type</strong> (<code>reports.service.ts:64-76</code>) — scheduled jobs must land as <code>attendance_summary</code> jobs to be processed</td></tr> </tbody></table> </div> <h2 id="worker-contract-server-side-context"><a class="header" href="#worker-contract-server-side-context">Worker contract (server-side context)</a></h2> <ul> <li>Queue: <code>QUEUE.REPORT_GENERATE = 'report-generate'</code> (<code>queue.constants.ts:10</code>).</li> <li>Processor: <code>@Processor('report-generate')</code> (<code>report.worker.ts:7</code>); <code>process()</code> restores tenant context then <code>reportsService.executeJob(jobId)</code> (<code>:18-35</code>).</li> <li><code>executeJob</code>: markProcessing → type switch (<code>report_card</code>/<code>attendance_summary</code>/ <code>fee_summary</code> — <code>reports.service.ts:64-76</code>) → markCompleted w/ result, or markFailed w/ error (<code>:78-82</code>; repository <code>:21-47</code>).</li> <li><code>eventQueueMap</code> has NO report route — <code>ReportGenerated</code> notifications <code>(planned)</code> (<code>event-queue-map.ts</code>).</li> </ul> <hr /> <h2 id="client-side-error-mapping-table"><a class="header" href="#client-side-error-mapping-table">Client-side error mapping table</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>code</th><th>UI</th></tr></thead><tbody> <tr><td>S2 submit</td><td>400</td><td>inline field error</td></tr> <tr><td>S2 submit</td><td>401</td><td>silent refresh → resubmit</td></tr> <tr><td>S2 submit</td><td>429</td><td>countdown, no auto-retry</td></tr> <tr><td>S2 submit</td><td>5xx</td><td>snackbar + form kept (re-POST safe — new job per POST)</td></tr> <tr><td>S4 poll</td><td>404</td><td>not-found state, back</td></tr> <tr><td>S4 poll</td><td>429</td><td>pause poll 30 s</td></tr> <tr><td>S4 poll</td><td>5xx/network</td><td>banner + manual retry (<a href="reports/10_Interaction_Specification.html">10 §1</a>)</td></tr> <tr><td>any (future)</td><td>403</td><td>shared 403 screen — server emits none today (no <code>@Permissions</code>)</td></tr> </tbody></table> </div> <h2 id="optimistic--undo-7"><a class="header" href="#optimistic--undo-7">Optimistic / undo</a></h2> <ul> <li>Generate: no optimistic result (async); navigate on 201 only.</li> <li>Retry: new job; previous failed job remains in history (audit trail).</li> <li>No undo anywhere — jobs are immutable once created.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-reports-module"><a class="header" href="#13--state-management-reports-module">13 — State Management (Reports Module)</a></h1> <ul> <li><a href="reports/13_State_Management.html#1-cubits">1. Cubits</a> <ul> <li><a href="reports/13_State_Management.html#reportcatalogcubit-s1"><code>ReportCatalogCubit</code> (S1)</a></li> <li><a href="reports/13_State_Management.html#generatereportcubit-s2"><code>GenerateReportCubit</code> (S2)</a></li> <li><a href="reports/13_State_Management.html#reportjoblistcubit-s3"><code>ReportJobListCubit</code> (S3)</a></li> <li><a href="reports/13_State_Management.html#reportjobdetailcubit-s4--owns-the-polling-engine"><code>ReportJobDetailCubit</code> (S4) — owns the polling engine</a></li> <li><a href="reports/13_State_Management.html#reportresultcubit-s5"><code>ReportResultCubit</code> (S5)</a></li> </ul> </li> <li><a href="reports/13_State_Management.html#2-job-state-machine-server-authoritative">2. Job state machine (server-authoritative)</a></li> <li><a href="reports/13_State_Management.html#3-data-persistence">3. Data persistence</a></li> <li><a href="reports/13_State_Management.html#4-loading--streaming--realtime">4. Loading / streaming / realtime</a></li> </ul> <blockquote> <p>Cubit/BloC layout for the Reports module per <a href="reports/../00-shared/06_State_Management.html">00-shared/06</a> (recommendation, not an implemented decision). One Cubit per screen cluster; polling owned by a single engine. Includes the job state machine.</p> </blockquote> <hr /> <h2 id="1-cubits-3"><a class="header" href="#1-cubits-3">1. Cubits</a></h2> <h3 id="reportcatalogcubit-s1"><a class="header" href="#reportcatalogcubit-s1"><code>ReportCatalogCubit</code> (S1)</a></h3> <ul> <li>State: <code>CatalogState { templates: ReportTemplate[] }</code> — static; loads once; <code>(forward-looking)</code> fetches server-supported types.</li> <li>No async beyond init.</li> </ul> <h3 id="generatereportcubit-s2"><a class="header" href="#generatereportcubit-s2"><code>GenerateReportCubit</code> (S2)</a></h3> <ul> <li>State: <code>GenerateState { type, params, submitting, fieldErrors }</code>.</li> <li>Events: <code>SelectType</code>, <code>UpdateParam</code>, <code>Submit</code>.</li> <li><code>Submit</code>: validate (client presence rule <a href="reports/08_Form_Specifications.html">08 §3.1</a>) → <code>POST /reports/generate</code> → success: emit <code>jobCreated(jobId)</code> → router to S4 (<code>reports.controller.ts:14-18</code>).</li> <li>Server 400 maps to <code>fieldErrors</code>; 429/5xx to snackbar (<a href="reports/12_API_Mapping.html">12 §client table</a>).</li> </ul> <h3 id="reportjoblistcubit-s3"><a class="header" href="#reportjoblistcubit-s3"><code>ReportJobListCubit</code> (S3)</a></h3> <ul> <li>State: <code>JobListState { jobs: LocalJobEntry[], filter, loading }</code>.</li> <li><code>LocalJobEntry</code> = <code>{jobId, type, status, createdAt, params?, source}</code> from the local history store (<a href="reports/12_API_Mapping.html">12 E4</a> gap) — refreshed by polling visible rows.</li> <li>Events: <code>LoadHistory</code>, <code>Refresh</code>, <code>Filter</code>, <code>Retry(jobId)</code>.</li> </ul> <h3 id="reportjobdetailcubit-s4--owns-the-polling-engine"><a class="header" href="#reportjobdetailcubit-s4--owns-the-polling-engine"><code>ReportJobDetailCubit</code> (S4) — owns the polling engine</a></h3> <ul> <li>State: <code>JobDetailState { job?, status, elapsed, pollPaused, error? }</code>.</li> <li>Events: <code>Open(jobId)</code>, <code>PollTick</code>, <code>Retry</code>, <code>CheckAgain</code> (manual after pause).</li> <li><strong>Polling engine</strong> (<a href="reports/10_Interaction_Specification.html">10 §1</a>): <code>Timer.periodic(2s)</code> while status non-terminal; GET → map doc; terminal → cancel timer; network/5xx → pause + emit <code>pollPaused</code>; 404 → terminal not-found.</li> <li><code>Retry</code>: re-POST <code>params</code> (<code>reports.service.ts:31-43</code>) → pushReplacement detail of new job.</li> </ul> <h3 id="reportresultcubit-s5"><a class="header" href="#reportresultcubit-s5"><code>ReportResultCubit</code> (S5)</a></h3> <ul> <li>State: <code>ResultState { job, renderModel, exporting }</code>.</li> <li>Pure mapping from <code>job.result</code> per type (<a href="reports/06_Screen_Specifications.html">06 §S5</a> renderers, <code>reports.service.ts:96-160</code>).</li> <li><code>Export</code> <code>(planned)</code>: no-op/disabled today; future: E3 download → share sheet.</li> </ul> <hr /> <h2 id="2-job-state-machine-server-authoritative"><a class="header" href="#2-job-state-machine-server-authoritative">2. Job state machine (server-authoritative)</a></h2> <p>Statuses from <code>report-job.schema.ts:13-18</code>; transitions applied by <code>report-job.repository.ts:21-47</code> (markProcessing/markCompleted/markFailed) and the initial <code>queued</code> default (<code>report-job.schema.ts:28-29</code>). The Cubit mirrors, never predicts.</p> <pre class="mermaid">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 --> [*] </pre> <p>Notes:</p> <ul> <li><code>queued → failed</code> without <code>processing</code> is possible (executor throws before markProcessing completes — <code>reports.service.ts:59-82</code>); UI must not assume processing was seen.</li> <li><strong>No <code>processing → queued</code> requeue</strong> exists in code — a crashed worker leaves a job stuck in <code>processing</code> (QA item, <code>14 §5</code>).</li> <li>Retry is never a status transition on the same doc — always a new job.</li> </ul> <h2 id="3-data-persistence"><a class="header" href="#3-data-persistence">3. Data persistence</a></h2> <div class="table-wrapper"><table><thead><tr><th>Store</th><th>Key</th><th>TTL</th><th>Purpose</th></tr></thead><tbody> <tr><td>local history</td><td><code>reports.localHistory</code></td><td>cap 50 entries</td><td>S3 list (E4 gap)</td></tr> <tr><td>last-good job doc</td><td><code>reports.job.{jobId}</code></td><td>24 h</td><td>S4 offline render</td></tr> <tr><td>last-good result</td><td><code>reports.result.{jobId}</code></td><td>24 h</td><td>S5 offline render</td></tr> </tbody></table> </div> <p>Server remains source of truth; caches are read-only mirrors.</p> <h2 id="4-loading--streaming--realtime"><a class="header" href="#4-loading--streaming--realtime">4. Loading / streaming / realtime</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Loading</th><th>Streaming</th><th>Realtime</th></tr></thead><tbody> <tr><td>S1</td><td>none (static)</td><td>—</td><td>—</td></tr> <tr><td>S2</td><td>submit spinner</td><td>—</td><td>—</td></tr> <tr><td>S3</td><td>skeleton rows</td><td>—</td><td>poll visible rows on refresh</td></tr> <tr><td>S4</td><td>full skeleton</td><td>—</td><td><strong>poll 2 s</strong> (the module's realtime)</td></tr> <tr><td>S5</td><td>render on doc</td><td>—</td><td>— (terminal)</td></tr> </tbody></table> </div> <p>No WS topic for reports (<code>00-shared/07 §8</code> has none) — <code>(forward-looking)</code>: push notification on <code>ReportGenerated</code> (<code>04-Modules/Reports.md:33</code>).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-reports-module"><a class="header" href="#14--qa-checklist-reports-module">14 — QA Checklist (Reports Module)</a></h1> <ul> <li><a href="reports/14_QA_Checklist.html#1-functional-client">1. Functional (client)</a></li> <li><a href="reports/14_QA_Checklist.html#2-large-datasets">2. Large datasets</a></li> <li><a href="reports/14_QA_Checklist.html#3-concurrency">3. Concurrency</a></li> <li><a href="reports/14_QA_Checklist.html#4-retry--dlq">4. Retry & DLQ</a></li> <li><a href="reports/14_QA_Checklist.html#5-reliability--failure-injection-server">5. Reliability / failure injection (server)</a></li> <li><a href="reports/14_QA_Checklist.html#6-scheduled-reports">6. Scheduled reports</a></li> <li><a href="reports/14_QA_Checklist.html#7-permissions--security">7. Permissions & security</a></li> <li><a href="reports/14_QA_Checklist.html#8-performance-budgets">8. Performance budgets</a></li> <li><a href="reports/14_QA_Checklist.html#9-accessibility-00-shared09">9. Accessibility (00-shared/09)</a></li> <li><a href="reports/14_QA_Checklist.html#10-release-gates">10. Release gates</a></li> </ul> <blockquote> <p>Test plan for the async generate → poll → view flow. Baseline: <a href="reports/../00-shared/10_QA_Baseline.html">00-shared/10</a>. Module facts that drive cases: job doc schema (<code>report-job.schema.ts:21-43</code>), service logic (<code>reports.service.ts:28-160</code>), worker (<code>report.worker.ts:18-35</code>), repository (<code>report-job.repository.ts:21-47</code>), queue name <code>report-generate</code> (<code>queue.constants.ts:10</code>).</p> </blockquote> <hr /> <h2 id="1-functional-client"><a class="header" href="#1-functional-client">1. Functional (client)</a></h2> <ul> <li><input disabled="" type="checkbox"/> POST generate returns <code>{jobId, status:"queued"}</code> (<code>reports.service.ts:43</code>); navigates to S4.</li> <li><input disabled="" type="checkbox"/> S4 shows all 4 statuses via poll; terminal stops polling (<a href="reports/10_Interaction_Specification.html">10 §1</a>).</li> <li><input disabled="" type="checkbox"/> Report card requires student+exam client-side (<a href="reports/08_Form_Specifications.html">08 §3.1</a>); server async failure path verified too (<code>reports.service.ts:91</code>).</li> <li><input disabled="" type="checkbox"/> Attendance summary with classId/date range filters correctly (<code>reports.service.ts:117-123</code>); result counts per status match source records (<code>:126-129</code>).</li> <li><input disabled="" type="checkbox"/> Fee summary math: collected/pending/overdue per <code>reports.service.ts:142-152</code> (OVERDUE status wins — <code>:150</code>).</li> <li><input disabled="" type="checkbox"/> Empty dataset → completed with <code>total:0</code>/empty summary renders empty state, not error (<code>reports.service.ts:131-136</code>).</li> <li><input disabled="" type="checkbox"/> 404 on poll → not-found state; cross-tenant jobId → same 404 (no leak, <code>base.repository.ts:21-23</code>).</li> <li><input disabled="" type="checkbox"/> Retry creates a NEW job; failed job stays in history.</li> <li><input disabled="" type="checkbox"/> Local history (S3) survives app restart; cap 50 (<a href="reports/13_State_Management.html">13 §3</a>).</li> </ul> <h2 id="2-large-datasets"><a class="header" href="#2-large-datasets">2. Large datasets</a></h2> <ul> <li><input disabled="" type="checkbox"/> report_card with max subject count (schema-driven) renders table without jank; <code>tabularFigures</code> on marks (<a href="reports/11_Design_System_Mapping.html">11 §4</a>).</li> <li><input disabled="" type="checkbox"/> attendance_summary over a full academic year: server loops all matching records in memory (<code>reports.service.ts:125-129</code>) — verify response time and that the client handles a large <code>summary</code> map / long period render.</li> <li><input disabled="" type="checkbox"/> fee_summary with >10k invoices: loop <code>:147-152</code> is O(n) in-process — measure p95; QA flag: blueprint requires streaming for large exports (<code>04-Modules/Reports.md:49</code>), code does not stream yet.</li> <li><input disabled="" type="checkbox"/> 50+ jobs in local history list: lazy list builds, no frame drops.</li> </ul> <h2 id="3-concurrency"><a class="header" href="#3-concurrency">3. Concurrency</a></h2> <ul> <li><input disabled="" type="checkbox"/> Double-tap submit → single job (button disabled in flight, <a href="reports/09_User_Behaviour.html">09 §2</a>).</li> <li><input disabled="" type="checkbox"/> Two clients polling same job concurrently — no conflict (read-only GET).</li> <li><input disabled="" type="checkbox"/> Generate N jobs at once (term-end) — queue drains; no DB contention; <code>{tenantId,status}</code> index used (<code>report-job.schema.ts:47</code>).</li> <li><input disabled="" type="checkbox"/> Same user re-generates same report while previous is processing — independent jobs; results not crossed (jobId-keyed).</li> </ul> <h2 id="4-retry--dlq"><a class="header" href="#4-retry--dlq">4. Retry & DLQ</a></h2> <ul> <li><input disabled="" type="checkbox"/> Worker failure (e.g. student not found, <code>reports.service.ts:94</code>) → job <code>failed</code> with readable <code>error</code> (<code>:79-82</code>); user sees error + Retry.</li> <li><input disabled="" type="checkbox"/> BullMQ retries: <code>report-generate</code> queue config (retries/DLQ) verified — worker redeploys mid-job don't lose jobs <code>(verify queue options)</code>.</li> <li><input disabled="" type="checkbox"/> DLQ behavior: poisoned job (bad payload) lands in DLQ; tenant gets failed job doc; no silent gap between queue state and <code>report_jobs</code> state (<a href="reports/13_State_Management.html">13 §2</a> — two sources of truth must stay consistent).</li> </ul> <h2 id="5-reliability--failure-injection-server"><a class="header" href="#5-reliability--failure-injection-server">5. Reliability / failure injection (server)</a></h2> <ul> <li><input disabled="" type="checkbox"/> <strong>Stuck processing</strong>: worker crash between <code>markProcessing</code> and terminal write leaves <code>processing</code> forever (no requeue/timeout in code — <code>report-job.repository.ts:21-47</code>). Decide: watchdog job or timeout → mark failed. <strong>Known gap — test and document.</strong></li> <li><input disabled="" type="checkbox"/> Redis down at POST → 5xx; form kept; no orphan job doc (create happens before queue.add — <code>reports.service.ts:31-41</code>; a queue failure leaves an orphaned <code>queued</code> job doc — verify cleanup behavior).</li> <li><input disabled="" type="checkbox"/> Mongo down at GET → 5xx; client banner + paused poll (<a href="reports/10_Interaction_Specification.html">10 §4</a>).</li> <li><input disabled="" type="checkbox"/> Scheduler enqueues while worker off — jobs stay queued (BullMQ durable); process when worker returns (<code>scheduler.service.ts:32,163,219</code>).</li> <li><input disabled="" type="checkbox"/> Worker tenant isolation: job for tenant A cannot read tenant B data (<code>report.worker.ts:20-29</code>).</li> </ul> <h2 id="6-scheduled-reports"><a class="header" href="#6-scheduled-reports">6. Scheduled reports</a></h2> <ul> <li><input disabled="" type="checkbox"/> Daily/weekly attendance job → <code>report_jobs</code> doc exists with correct type so <code>executeJob</code> switch handles it (<code>reports.service.ts:64-76</code>; <code>attendance-report.job.ts:13-24</code>). ⚠ verify enqueued payload produces a job doc — the queue job payload <code>{reportType}</code> (<code>attendance-report.job.ts:21</code>) differs from <code>ReportsService.generate</code>'s shape; confirm end-to-end.</li> <li><input disabled="" type="checkbox"/> Remove-on-complete retention <code>{age:3600,count:100}</code> (<code>:23</code>) doesn't destroy <code>report_jobs</code> docs (different stores — confirm).</li> </ul> <h2 id="7-permissions--security"><a class="header" href="#7-permissions--security">7. Permissions & security</a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>report.generate</code> / <code>report.read</code> defined (<code>permissions.constants.ts:39-40</code>).</li> <li><input disabled="" type="checkbox"/> <strong>Gap</strong>: endpoints enforce JWT only (<code>reports.controller.ts:9</code>) — no <code>@Permissions</code>. Plan: server adds permission decorators; client gates meanwhile (<a href="reports/12_API_Mapping.html">12 §0</a>).</li> <li><input disabled="" type="checkbox"/> tenantId never accepted from body (<code>BaseSchema</code>, repository scoping).</li> <li><input disabled="" type="checkbox"/> Error messages don't leak cross-tenant existence (404 path, <code>:48</code>).</li> </ul> <h2 id="8-performance-budgets"><a class="header" href="#8-performance-budgets">8. Performance budgets</a></h2> <div class="table-wrapper"><table><thead><tr><th>Metric</th><th>Budget</th></tr></thead><tbody> <tr><td>POST → 201</td><td>< 500 ms</td></tr> <tr><td>poll GET</td><td>< 150 ms p95 (indexed)</td></tr> <tr><td>fee_summary >10k invoices</td><td>< 5 s p95 (flag if exceeded)</td></tr> <tr><td>app frame rate during poll</td><td>no jank at 2 s cadence</td></tr> <tr><td>poll load</td><td>≤ 1 req/2 s/device (jittered, <a href="reports/10_Interaction_Specification.html">10 §1</a>)</td></tr> </tbody></table> </div> <h2 id="9-accessibility-00-shared09-1"><a class="header" href="#9-accessibility-00-shared09-1">9. Accessibility (<a href="reports/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>)</a></h2> <ul> <li><input disabled="" type="checkbox"/> Status changes announced (live region) without full-card re-announce.</li> <li><input disabled="" type="checkbox"/> 48 dp targets; contrast 4.5:1 (<a href="reports/11_Design_System_Mapping.html">11 §6</a>).</li> <li><input disabled="" type="checkbox"/> ReduceMotion: instant swaps, polling unchanged.</li> </ul> <h2 id="10-release-gates-1"><a class="header" href="#10-release-gates-1">10. Release gates</a></h2> <ul> <li><input disabled="" type="checkbox"/> All of 1–3 pass; 4–5 documented decisions (stuck-processing & orphan-queued cases); 7 server change ticket opened for RBAC enforcement.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-reports-module"><a class="header" href="#15--flutter-implementation-guide-reports-module">15 — Flutter Implementation Guide (Reports Module)</a></h1> <ul> <li><a href="reports/15_Flutter_Implementation_Guide.html#1-packages">1. Packages</a></li> <li><a href="reports/15_Flutter_Implementation_Guide.html#2-service-layer">2. Service layer</a></li> <li><a href="reports/15_Flutter_Implementation_Guide.html#3-polling-engine-shared-widget-level-service">3. Polling engine (shared widget-level service)</a></li> <li><a href="reports/15_Flutter_Implementation_Guide.html#4-cubit-wiring-13-1">4. Cubit wiring (<code>13 §1</code>)</a></li> <li><a href="reports/15_Flutter_Implementation_Guide.html#5-download--viewer-planned">5. Download & viewer <code>(planned)</code></a></li> <li><a href="reports/15_Flutter_Implementation_Guide.html#6-scheduler--notifications-forward-looking">6. Scheduler & notifications <code>(forward-looking)</code></a></li> <li><a href="reports/15_Flutter_Implementation_Guide.html#7-offline">7. Offline</a></li> <li><a href="reports/15_Flutter_Implementation_Guide.html#8-testing">8. Testing</a></li> </ul> <blockquote> <p>Concrete Flutter build notes for the Reports module. App architecture per <a href="reports/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a> (recommended: Cubit, <a href="reports/../00-shared/06_State_Management.html">00-shared/06</a>); state layout <a href="reports/13_State_Management.html">13</a>; polling <a href="reports/10_Interaction_Specification.html">10</a>; contracts <a href="reports/12_API_Mapping.html">12</a>.</p> </blockquote> <hr /> <h2 id="1-packages-1"><a class="header" href="#1-packages-1">1. Packages</a></h2> <ul> <li>Existing shared stack only — no new dependencies for polling (core <code>dart:async Timer</code>).</li> <li>Downloads/viewer <code>(planned)</code> — see §5 for recommended additions once the server endpoint exists: <code>flutter_downloader</code> or <code>dio</code> + <code>path_provider</code> (follow <a href="reports/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a>).</li> <li>No PDF viewer today (no PDFs exist).</li> </ul> <h2 id="2-service-layer"><a class="header" href="#2-service-layer">2. Service layer</a></h2> <pre><code class="language-dart">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']); } } </code></pre> <p>Envelope unwrap per <code>00-shared/07</code>; DTO mirrors <code>GenerateReportDto</code> (<code>generate-report.dto.ts:5-34</code>): <code>type</code> enum string, optional MongoIds as strings, dates as ISO strings.</p> <h2 id="3-polling-engine-shared-widget-level-service"><a class="header" href="#3-polling-engine-shared-widget-level-service">3. Polling engine (shared widget-level service)</a></h2> <pre><code class="language-dart">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; } } </code></pre> <p>Rules: 2 s ± 400 ms jitter; pause after 2 consecutive failures; <code>stop()</code> on terminal/404; cancel in <code>dispose</code> (<code>10 §1</code>). <code>ReportJobStatus</code> maps <code>queued|processing|completed|failed</code> (<code>report-job.schema.ts:13-18</code>).</p> <h2 id="4-cubit-wiring-13-1"><a class="header" href="#4-cubit-wiring-13-1">4. Cubit wiring (<code>13 §1</code>)</a></h2> <pre><code class="language-dart">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 } } </code></pre> <p>Never predict status; mirror the server doc only (<code>13 §2</code> mermaid).</p> <h2 id="5-download--viewer-planned"><a class="header" href="#5-download--viewer-planned">5. Download & viewer <code>(planned)</code></a></h2> <p>Until <code>GET /reports/:jobId/download</code> (<code>04-Modules/Reports.md:26</code>) exists:</p> <ul> <li>Hide export CTAs (<code>06 §S5</code>); no download manager code ships.</li> </ul> <p>When it lands:</p> <ul> <li><strong>Download manager</strong>: <code>GET /reports/:jobId/download</code> → stream to app docs dir (<code>path_provider</code>), <code>Content-Disposition</code> filename parsed from headers (<code>files.controller.ts:55-64</code> pattern — <code>attachment; filename="…"</code>); notify completion; keep per-tenant folder.</li> <li><strong>Viewer</strong>: open PDF via <code>flutter_pdfview</code> or share via <code>share_plus</code>; never load the whole file into memory for large exports (streaming, <code>:49</code>).</li> <li>Alternate path: server stores via files module → client uses <code>GET /files/:id/download</code> (<code>file.read</code>, <code>files.controller.ts:55-64</code>).</li> </ul> <h2 id="6-scheduler--notifications-forward-looking"><a class="header" href="#6-scheduler--notifications-forward-looking">6. Scheduler & notifications <code>(forward-looking)</code></a></h2> <ul> <li>Scheduled jobs (<code>attendance-report.job.ts:13-24</code>) surface via history once a list API exists (<code>12 E4</code>).</li> <li>Completion notification: subscribe when server emits <code>ReportGenerated</code> (<code>04-Modules/Reports.md:33</code>) — deep link <code>/reports/jobs/:jobId</code>.</li> </ul> <h2 id="7-offline"><a class="header" href="#7-offline">7. Offline</a></h2> <ul> <li>History + last-good job/result cached (<code>13 §3</code>); banner + paused poll (<code>10 §4</code>); no writes offline (generate requires network).</li> </ul> <h2 id="8-testing"><a class="header" href="#8-testing">8. Testing</a></h2> <ul> <li>Unit: cubit transitions, poller pause/resume/jitter, renderer mapping (<code>result</code> → models per <code>06 §S5</code>).</li> <li>Widget: S4 status swaps with fake poller; S2 validation (<code>08 §3</code>).</li> <li>Golden: metric cards with <code>tabularFigures</code> (<code>11 §4</code>).</li> <li>E2E (<code>00-shared/10</code>): mock API — full journey J1/J2/J3 (<code>03</code>); failure paths: 404, job failed, network drop mid-poll.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-results-module"><a class="header" href="#01--product-overview-results-module">01 — Product Overview (Results Module)</a></h1> <ul> <li><a href="results/01_Product_Overview.html#1-purpose">1. Purpose</a></li> <li><a href="results/01_Product_Overview.html#2-scope">2. Scope</a></li> <li><a href="results/01_Product_Overview.html#3-out-of-scope-phase-1-backend">3. Out of scope (Phase 1 backend)</a></li> <li><a href="results/01_Product_Overview.html#4-key-behavioural-facts-from-source">4. Key behavioural facts from source</a></li> <li><a href="results/01_Product_Overview.html#5-success-metrics">5. Success metrics</a></li> <li><a href="results/01_Product_Overview.html#6-open-questions--gaps">6. Open questions / gaps</a></li> </ul> <blockquote> <p>StudyLyon — multi-tenant ERP / School Management API. This package designs the <strong>Results module client</strong> (Flutter, forward-looking spec) against the implemented NestJS backend. Every endpoint, DTO field, schema, index, event, and permission is derived directly from <code>src/modules/results/**</code>, <code>src/modules/exams/**</code>, <code>src/infrastructure/bullmq/event-queue-map.ts</code>, <code>src/modules/rbac/permissions.constants.ts</code>, and <code>docs/IMPLEMENTATION_PLAN.md</code>. No feature is invented; gaps are flagged in <strong>Open Questions</strong> and marked <code>(planned)</code> / <code>(forward-looking)</code> / <code>(proposed)</code> inline. The PRD keeps the <strong>mobile client out of Phase 1</strong> — this entire package is the <code>(forward-looking)</code> client design against the v1 API.</p> </blockquote> <hr /> <h2 id="1-purpose-20"><a class="header" href="#1-purpose-20">1. Purpose</a></h2> <p>Results is the grading and reporting layer of the academic cycle. Teachers enter <strong>marks per (exam-subject, student)</strong>, the backend computes <strong>totals, percentage, and an overall grade per report card</strong>, and publication releases results to stakeholders. The module owns the <code>examination_results</code> collection (<code>examination-result.schema.ts:7-8</code>) but marks are written by the Exams module service (<code>examination.service.ts:176-182</code>); the Results controller proxies marks entry into that service (<code>result.controller.ts:26-31</code>).</p> <div class="table-wrapper"><table><thead><tr><th>Responsibility</th><th>Source</th></tr></thead><tbody> <tr><td>List results for one student</td><td><code>result.controller.ts:16-20</code> → <code>result.service.ts:36-38</code></td></tr> <tr><td>List results for one exam-subject</td><td><code>result.controller.ts:21-25</code> → <code>result.service.ts:40-44</code></td></tr> <tr><td>Enter/update marks (idempotent upsert)</td><td><code>result.controller.ts:26-31</code> → <code>examination.service.ts:139-195</code></td></tr> <tr><td>Report card per (student, exam)</td><td><code>result.controller.ts:32-37</code> → <code>result.service.ts:46-98</code></td></tr> <tr><td>Publish all results of an exam</td><td><code>examination.controller.ts:54-56</code> → <code>examination.service.ts:203-220</code></td></tr> </tbody></table> </div> <h2 id="2-scope-1"><a class="header" href="#2-scope-1">2. Scope</a></h2> <ul> <li><strong>Marks entry</strong> per exam-subject, one student at a time, with idempotent re-entry (upsert, <code>examination.service.ts:147-175</code>).</li> <li><strong>Result lookup</strong> by student and by exam-subject.</li> <li><strong>Report card</strong> generation: per-subject marks, totals, percentage (2 decimals, <code>result.service.ts:83-86</code>), overall grade via deterministic bands (<code>result.service.ts:130-138</code>).</li> <li><strong>Publication</strong> of an exam's results, which stamps <code>publishedAt</code> on every result row and flips the exam to <code>status: 'published'</code> (<code>examination.service.ts:203-220</code>).</li> <li><strong>Notification</strong> of publication via <code>ExamResultsPublished</code> → <code>in-app</code> queue / <code>results-published</code> job (<code>event-queue-map.ts:27</code>).</li> </ul> <h2 id="3-out-of-scope-phase-1-backend"><a class="header" href="#3-out-of-scope-phase-1-backend">3. Out of scope (Phase 1 backend)</a></h2> <ul> <li><strong>Student/parent portal visibility</strong> — no per-role visibility filter exists in code; every endpoint is behind <code>JwtAuthGuard</code> only (<code>result.controller.ts:10</code>). Blueprint <code>Results.md</code> "before publish, results hidden" is <strong>not implemented</strong> (see 4.2).</li> <li><strong>Rank computation</strong> — blueprint <code>Results.md:54</code> says "Rank computed per class per exam", but no rank logic exists in <code>result.service.ts</code>. <code>(planned)</code></li> <li><strong>Analytics</strong> — blueprint lists <code>GET /api/v1/results/analytics</code> (<code>Results.md:28</code>); no controller route exists. <code>(proposed)</code></li> <li><strong>PDF report card export</strong> — blueprint assigns it to the Reports module (<code>Results.md:57</code>); no implementation. <code>(planned)</code></li> <li><strong>Mobile/Flutter client</strong> — the PRD keeps mobile out of Phase 1; this spec is the forward-looking client design against the v1 API. <code>(forward-looking)</code></li> <li><strong>RBAC permissions</strong> — <code>permissions.constants.ts:1-97</code> contains <strong>no <code>result.*</code> or <code>exam.*</code> permissions</strong>; the blueprint's <code>result.read/compute/publish/report</code> (<code>Results.md:62-69</code>) are unimplemented. <code>(planned)</code></li> </ul> <h2 id="4-key-behavioural-facts-from-source"><a class="header" href="#4-key-behavioural-facts-from-source">4. Key behavioural facts from source</a></h2> <ol> <li><strong>Marks upsert is idempotent</strong>: <code>examination.service.ts:147-150</code> looks up by <code>(studentId, examinationSubjectId)</code>; existing row is updated in place (<code>:152-161</code>), otherwise created (<code>:176-182</code>). A client can safely re-submit.</li> <li><strong>Unique index</strong>: <code>{tenantId, studentId, examinationSubjectId}</code> unique (<code>examination-result.schema.ts:31-33</code>) — the DB-level backstop for the upsert.</li> <li><strong>Missing marks count as 0</strong> in the report card (<code>result.service.ts:70</code> <code>result?.marksObtained ?? 0</code>), but <code>totalMaximumMarks</code> still sums every subject (<code>:72</code>). A student with no entries is graded F, not excluded.</li> <li><strong>Over-maximum marks throw 404</strong> (<code>examination.service.ts:145-146</code>, <code>NotFoundException</code>) — a quirk: business-rule violations should be 422 <code>BUSINESS_RULE_VIOLATION</code>, but this path returns 404 <code>RESOURCE_NOT_FOUND</code>. The client must treat both as "marks rejected" (see <a href="results/14_QA_Checklist.html">14_QA_Checklist.md</a>).</li> <li><strong>Publication is not an immutability lock</strong>: <code>publishResults</code> (<code>examination.service.ts:203-220</code>) only stamps <code>publishedAt</code> and sets exam status; marks can still be upserted afterwards. There is no "frozen" check in <code>enterMarks</code>.</li> <li><strong>Overall grade bands</strong> (<code>result.service.ts:130-138</code>): A+ ≥ 90, A ≥ 80, B+ ≥ 70, B ≥ 60, C ≥ 50, D ≥ 40, F < 40 — only the <em>overall</em> grade is computed server-side. <strong>Per-subject <code>grade</code> is client-supplied</strong> (optional string in <code>EnterMarksDto</code>, <code>examination-subject.dto.ts:57-60</code>) and stored verbatim (<code>examination.service.ts:157</code>).</li> <li><strong>Report card subject name is the subject ID</strong>: <code>subjectName: sub.subjectId.toString()</code> (<code>result.service.ts:75</code>) — no populate/join; the client must resolve display names locally.</li> </ol> <h2 id="5-success-metrics"><a class="header" href="#5-success-metrics">5. Success metrics</a></h2> <ul> <li>Marks entry converges in ≤ 2 attempts (idempotent upsert → no duplicate/conflict errors).</li> <li>Report card always renders even with missing marks (0-fill) or zero subjects (404 → guided empty state).</li> <li>Publish action visibly stamps results and fires the in-app <code>results-published</code> notification.</li> </ul> <hr /> <h2 id="6-open-questions--gaps"><a class="header" href="#6-open-questions--gaps">6. Open questions / gaps</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Gap</th><th>Status</th></tr></thead><tbody> <tr><td>1</td><td>No role-based visibility: students/parents can't be restricted to their own results</td><td><code>(planned)</code> — RBAC guards not yet implemented (AGENTS.md)</td></tr> <tr><td>2</td><td><code>publishedAt</code> is never filtered on read paths (<code>examination-result.repository.ts:20-40</code>)</td><td><code>(planned)</code></td></tr> <tr><td>3</td><td>Per-subject grade computed? Currently client-sent; blueprint implies server grading</td><td>Open question</td></tr> <tr><td>4</td><td>Rank/percentile per class per exam</td><td><code>(planned)</code></td></tr> <tr><td>5</td><td>Bulk marks entry (grid POST) — only per-student <code>enterMarks</code> exists</td><td><code>(planned)</code> — <code>/:id/marks-import</code> in <code>IMPLEMENTATION_PLAN.md:219</code></td></tr> <tr><td>6</td><td>Analytics endpoint</td><td><code>(proposed)</code></td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-results-module"><a class="header" href="#02--user-personas-results-module">02 — User Personas (Results Module)</a></h1> <ul> <li><a href="results/02_User_Personas.html#21-teacher-subject-examiner--primary-marks-entry-user">2.1 Teacher (subject examiner) — primary marks-entry user</a></li> <li><a href="results/02_User_Personas.html#22-exam-coordinator--academic-head--publication-owner">2.2 Exam coordinator / academic head — publication owner</a></li> <li><a href="results/02_User_Personas.html#23-student-and-parent--consumer-of-published-results">2.3 Student (and parent) — consumer of published results</a></li> <li><a href="results/02_User_Personas.html#24-institution-admin--configuration--oversight">2.4 Institution admin — configuration & oversight</a></li> <li><a href="results/02_User_Personas.html#25-forward-looking-platform-operator">2.5 (forward-looking) Platform operator</a></li> <li><a href="results/02_User_Personas.html#persona--capability-matrix-today-vs-intended">Persona × capability matrix (today vs intended)</a></li> </ul> <blockquote> <p>Personas are grounded in the code's actor model: every request carries a JWT (<code>JwtAuthGuard</code>, <code>result.controller.ts:10</code>), tenant comes from token (<code>base.repository.ts:20-30</code>), and <code>actorId</code> is recorded on domain events (<code>examination.service.ts:167</code>, <code>result.service.ts:117</code>). Roles beyond the guard are <strong>not yet enforced</strong> (no <code>result.*</code> permissions in <code>permissions.constants.ts:1-97</code>).</p> </blockquote> <hr /> <h2 id="21-teacher-subject-examiner--primary-marks-entry-user"><a class="header" href="#21-teacher-subject-examiner--primary-marks-entry-user">2.1 Teacher (subject examiner) — primary marks-entry user</a></h2> <ul> <li><strong>Goal</strong>: record marks for each student per exam-subject, correctly and fast; fix mistakes.</li> <li><strong>Uses</strong>: <code>POST /api/v1/results/exam-subject/:examSubjectId/marks</code> (<code>result.controller.ts:26-31</code>).</li> <li><strong>Needs</strong>: subject list per exam (<code>GET /api/v1/examinations/:id/subjects</code>, <code>examination.controller.ts:51-53</code>), student list, max-marks context (<code>maximumMarks</code>, <code>examination-subject.schema.ts:27-28</code>), per-student re-entry without duplicates.</li> <li><strong>Pain</strong>: over-max marks are rejected with 404 (<code>examination.service.ts:145-146</code>) — confusing error text must be surfaced by the client.</li> <li><strong>Permission today</strong>: any valid JWT. Intended: <code>result.compute</code> / <code>exam.mark</code> <code>(planned)</code>.</li> </ul> <h2 id="22-exam-coordinator--academic-head--publication-owner"><a class="header" href="#22-exam-coordinator--academic-head--publication-owner">2.2 Exam coordinator / academic head — publication owner</a></h2> <ul> <li><strong>Goal</strong>: verify completeness of marks, then release results atomically per exam.</li> <li><strong>Uses</strong>: <code>POST /api/v1/examinations/:id/publish</code> (<code>examination.controller.ts:54-56</code>).</li> <li><strong>Needs</strong>: completion overview per exam (how many students have entries), preview report cards, publish confirmation; <strong>cannot un-publish</strong> (no such endpoint).</li> <li><strong>Permission today</strong>: any valid JWT. Intended: <code>result.publish</code> <code>(planned)</code>.</li> </ul> <h2 id="23-student-and-parent--consumer-of-published-results"><a class="header" href="#23-student-and-parent--consumer-of-published-results">2.3 Student (and parent) — consumer of published results</a></h2> <ul> <li><strong>Goal</strong>: see report card per exam: subject-wise marks, totals, percentage, overall grade.</li> <li><strong>Uses</strong>: <code>GET /api/v1/results/report-card/:studentId/:examId</code> (<code>result.controller.ts:32-37</code>) and <code>GET /api/v1/results/student/:studentId</code> (<code>:16-20</code>).</li> <li><strong>Reality gap</strong>: no server-side check that the JWT subject owns <code>studentId</code>; no <code>publishedAt</code> filter (<code>examination-result.repository.ts:20-40</code>). The client must gate this experience behind publish state and later server RBAC <code>(planned)</code>.</li> <li><strong>Pain</strong>: <code>subjectName</code> in report cards is the raw subject ID (<code>result.service.ts:75</code>) — client must map IDs → display names.</li> </ul> <h2 id="24-institution-admin--configuration--oversight"><a class="header" href="#24-institution-admin--configuration--oversight">2.4 Institution admin — configuration & oversight</a></h2> <ul> <li><strong>Goal</strong>: define exams (<code>POST /api/v1/examinations</code>, <code>examination.controller.ts:27-29</code>), add subjects with <code>maximumMarks</code>/<code>passingMarks</code> (<code>examination-subject.schema.ts:27-31</code>), monitor publish history.</li> <li><strong>Uses</strong>: full examinations CRUD (<code>examination.controller.ts:27-43</code>).</li> <li><strong>Permission today</strong>: any valid JWT. Intended: <code>exam.create/update/delete</code>, <code>result.read</code> <code>(planned)</code>.</li> </ul> <h2 id="25-forward-looking-platform-operator"><a class="header" href="#25-forward-looking-platform-operator">2.5 (forward-looking) Platform operator</a></h2> <ul> <li>Cross-tenant support: platform admin bypasses tenant scope (<code>base.repository.ts:21-23</code>) — out of Phase 1 client scope.</li> </ul> <hr /> <h2 id="persona--capability-matrix-today-vs-intended"><a class="header" href="#persona--capability-matrix-today-vs-intended">Persona × capability matrix (today vs intended)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Capability</th><th>Teacher</th><th>Coordinator</th><th>Student/Parent</th><th>Admin</th></tr></thead><tbody> <tr><td>Enter marks (upsert)</td><td>✅ today</td><td>✅ today</td><td>—</td><td>✅ today</td></tr> <tr><td>Read results</td><td>✅ today (all)</td><td>✅ today</td><td>⚠️ not scoped</td><td>✅ today</td></tr> <tr><td>Report card</td><td>✅ today</td><td>✅ today</td><td>⚠️ not scoped</td><td>✅ today</td></tr> <tr><td>Publish exam</td><td>✅ today</td><td>✅ today</td><td>—</td><td>✅ today</td></tr> <tr><td>RBAC-restricted</td><td><code>(planned)</code></td><td><code>(planned)</code></td><td><code>(planned)</code></td><td><code>(planned)</code></td></tr> </tbody></table> </div> <blockquote> <p>⚠️ Today every endpoint is available to any authenticated user; the matrix above reflects the intended design (<code>permissions.constants.ts</code> has no <code>result.*</code> entries yet).</p> </blockquote> <div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journey-results-module"><a class="header" href="#03--user-journey-results-module">03 — User Journey (Results Module)</a></h1> <ul> <li><a href="results/03_User_Journey.html#journey-a--teacher-enter-marks-for-one-exam-subject-happy-path">Journey A — Teacher: enter marks for one exam-subject (happy path)</a></li> <li><a href="results/03_User_Journey.html#journey-a--rejected-marks-error-path">Journey A′ — rejected marks (error path)</a></li> <li><a href="results/03_User_Journey.html#journey-b--coordinator-verify--publish-exam-results">Journey B — Coordinator: verify & publish exam results</a></li> <li><a href="results/03_User_Journey.html#journey-c--student-view-report-card">Journey C — Student: view report card</a></li> <li><a href="results/03_User_Journey.html#journey-d--admin-create-exam--subjects-precondition-to-a">Journey D — Admin: create exam + subjects (precondition to A)</a></li> <li><a href="results/03_User_Journey.html#journey-e--planned-bulk-marks-import">Journey E — (planned) bulk marks import</a></li> <li><a href="results/03_User_Journey.html#flow-summary">Flow summary</a></li> </ul> <blockquote> <p>Journeys map to real endpoints; async hops go through BullMQ (<code>event-queue-map.ts:27</code> — <code>ExamResultsPublished</code> → <code>in-app</code> / <code>results-published</code>). States marked <code>(planned)</code> are not implemented server-side.</p> </blockquote> <hr /> <h2 id="journey-a--teacher-enter-marks-for-one-exam-subject-happy-path"><a class="header" href="#journey-a--teacher-enter-marks-for-one-exam-subject-happy-path">Journey A — Teacher: enter marks for one exam-subject (happy path)</a></h2> <ol> <li>Open exam → subject list: <code>GET /api/v1/examinations/:id/subjects</code> (<code>examination.controller.ts:51-53</code>).</li> <li>Open subject detail: shows <code>date</code>, <code>startTime</code>/<code>endTime</code>, <code>maximumMarks</code>, <code>passingMarks</code> (<code>examination-subject.schema.ts:18-31</code>).</li> <li>Load existing entries: <code>GET /api/v1/results/exam-subject/:examSubjectId</code> (<code>result.controller.ts:21-25</code>).</li> <li>Enter marks per student: <code>POST /api/v1/results/exam-subject/:examSubjectId/marks</code> with <code>EnterMarksDto</code> (<code>examination-subject.dto.ts:47-65</code>).</li> <li>Re-enter to correct → same endpoint, server <strong>updates in place</strong> (idempotent upsert, <code>examination.service.ts:147-175</code>); no 409 duplicates.</li> <li>Client keeps local draft; syncs in background (see <a href="results/15_Flutter_Implementation_Guide.html">15_Flutter_Implementation_Guide.md</a> §Offline).</li> </ol> <h2 id="journey-a--rejected-marks-error-path"><a class="header" href="#journey-a--rejected-marks-error-path">Journey A′ — rejected marks (error path)</a></h2> <ul> <li>Marks > <code>maximumMarks</code> → server throws 404 <code>RESOURCE_NOT_FOUND</code> (<code>examination.service.ts:145-146</code>). Client normalises to "marks cannot exceed maximum" and keeps the field in error state. See <a href="results/14_QA_Checklist.html">14_QA_Checklist.md</a> §5 for the 404-vs-422 quirk.</li> <li>Missing/invalid <code>studentId</code> or <code>examSubjectId</code> → <code>VALIDATION_ERROR</code> (400) per <a href="results/07_Component_Library.html">07_API_Conventions.md</a>.</li> </ul> <h2 id="journey-b--coordinator-verify--publish-exam-results"><a class="header" href="#journey-b--coordinator-verify--publish-exam-results">Journey B — Coordinator: verify & publish exam results</a></h2> <ol> <li>Open exam detail: <code>GET /api/v1/examinations/:id</code> (<code>examination.controller.ts:33-35</code>).</li> <li>Check status (<code>draft | active | completed | published</code>, <code>examination.schema.ts:28-33</code>).</li> <li>Inspect subject-wise completion: <code>GET /api/v1/examinations/:id/subjects</code> + per-subject results (Journey A step 3).</li> <li>Publish: <code>POST /api/v1/examinations/:id/publish</code> (<code>examination.controller.ts:54-56</code>). <ul> <li>Server stamps <code>publishedAt</code> on every result row of every subject (<code>examination.service.ts:207</code>), sets exam <code>status: 'published'</code> (<code>:209-211</code>), emits <code>ExamResultsPublished</code> (<code>:212-219</code>) → <code>in-app</code> queue → <code>results-published</code> job (<code>event-queue-map.ts:27</code>).</li> </ul> </li> <li>Student/parent in-app notification delivered (async — job on <code>in-app</code> queue).</li> <li>⚠️ No un-publish endpoint exists. No immutability lock after publish (<code>enterMarks</code> still works) — by design for now, flagged in <a href="results/14_QA_Checklist.html">14_QA_Checklist.md</a> §2.</li> </ol> <h2 id="journey-c--student-view-report-card"><a class="header" href="#journey-c--student-view-report-card">Journey C — Student: view report card</a></h2> <ol> <li>Open results list: <code>GET /api/v1/results/student/:studentId</code> (<code>result.controller.ts:16-20</code>) → list of <code>examination_results</code> rows with <code>marksObtained</code>, <code>grade</code>, <code>remarks</code>, <code>publishedAt</code> (<code>examination-result.schema.ts:15-25</code>).</li> <li>Open report card for an exam: <code>GET /api/v1/results/report-card/:studentId/:examId</code> (<code>result.controller.ts:32-37</code>).</li> <li>Read: per-subject <code>marksObtained</code>/<code>maximumMarks</code>/<code>grade</code>/<code>remarks</code>; totals; <code>percentage</code> (2 decimals); <code>overallGrade</code> (A+…F bands, <code>result.service.ts:130-138</code>); <code>generatedAt</code> (<code>result.service.ts:96</code>).</li> <li>Missing entries render as 0 with grade F (<code>result.service.ts:70</code>) — client should visually distinguish "0 entered" from "not entered" (data limitation: <strong>cannot distinguish</strong>; see <a href="results/14_QA_Checklist.html">14_QA_Checklist.md</a> §3).</li> <li>⚠️ <code>subjectName</code> is the raw subject ID (<code>result.service.ts:75</code>) — client maps to display names.</li> <li>Empty exam (no subjects) → 404 <code>No subjects found for this examination.</code> (<code>result.service.ts:51-52</code>) → client shows empty state.</li> </ol> <h2 id="journey-d--admin-create-exam--subjects-precondition-to-a"><a class="header" href="#journey-d--admin-create-exam--subjects-precondition-to-a">Journey D — Admin: create exam + subjects (precondition to A)</a></h2> <ol> <li><code>POST /api/v1/examinations</code> (<code>examination.controller.ts:27-29</code>) — always starts <code>status: 'draft'</code> (<code>examination.service.ts:48</code>); body per <code>CreateExaminationDto</code> (<code>examination.dto.ts:4-26</code>): <code>academicYearId</code>, <code>name</code>, <code>type</code> (<code>midterm|final|unit_test|quarterly|other</code>), <code>startDate</code>, <code>endDate</code>.</li> <li><code>POST /api/v1/examinations/:id/subjects</code> (<code>examination.controller.ts:45-50</code>) with <code>CreateExaminationSubjectDto</code> (<code>examination-subject.dto.ts:11-45</code>): <code>subjectId</code>, <code>classId</code>, <code>date</code>, <code>startTime</code>, <code>endTime</code>, <code>maximumMarks</code> (≥1), <code>passingMarks</code> (≥1).</li> <li>Marks entry enabled (Journey A).</li> </ol> <h2 id="journey-e--planned-bulk-marks-import"><a class="header" href="#journey-e--planned-bulk-marks-import">Journey E — (planned) bulk marks import</a></h2> <ul> <li><code>POST /api/v1/examinations/:id/marks-import</code> — spreadsheet import with validation + rollback (<code>IMPLEMENTATION_PLAN.md:219</code>). <code>(planned)</code></li> <li><code>GET /api/v1/examinations/:id/schedule</code> (hall tickets/seating <code>IMPLEMENTATION_PLAN.md:216-218</code>) <code>(planned)</code>.</li> </ul> <hr /> <h2 id="flow-summary"><a class="header" href="#flow-summary">Flow summary</a></h2> <pre class="mermaid">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] </pre> <div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-results-module"><a class="header" href="#04--information-architecture-results-module">04 — Information Architecture (Results Module)</a></h1> <ul> <li><a href="results/04_Information_Architecture.html#1-ia-map">1. IA map</a></li> <li><a href="results/04_Information_Architecture.html#2-information-units-from-contracts">2. Information units (from contracts)</a></li> <li><a href="results/04_Information_Architecture.html#3-navigation-rules">3. Navigation rules</a></li> <li><a href="results/04_Information_Architecture.html#4-state-flags-driving-ia">4. State flags driving IA</a></li> <li><a href="results/04_Information_Architecture.html#5-empty--error-placements">5. Empty & error placements</a></li> </ul> <blockquote> <p>IA derives from route structure (<code>result.controller.ts:9-37</code>, <code>examination.controller.ts:23-56</code>) and the report card contract (<code>result.service.ts:8-26</code>). Navigation labels are client-side; the API shape constrains what each screen can show.</p> </blockquote> <hr /> <h2 id="1-ia-map"><a class="header" href="#1-ia-map">1. IA map</a></h2> <pre><code>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 </code></pre> <h2 id="2-information-units-from-contracts"><a class="header" href="#2-information-units-from-contracts">2. Information units (from contracts)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Unit</th><th>Fields</th><th>Source</th></tr></thead><tbody> <tr><td>Examination</td><td><code>academicYearId</code>, <code>name</code>, <code>type</code>, <code>startDate</code>, <code>endDate</code>, <code>status</code> (<code>draft/active/completed/published</code>), <code>gradingSchemeId?</code></td><td><code>examination.schema.ts:8-36</code></td></tr> <tr><td>ExaminationSubject</td><td><code>examinationId</code>, <code>subjectId</code>, <code>classId</code>, <code>date</code>, <code>startTime</code>, <code>endTime</code>, <code>maximumMarks</code>, <code>passingMarks</code></td><td><code>examination-subject.schema.ts:8-31</code></td></tr> <tr><td>ExaminationResult</td><td><code>studentId</code>, <code>examinationSubjectId</code>, <code>marksObtained?</code>, <code>grade?</code>, <code>remarks?</code>, <code>publishedAt?</code> + <code>tenantId</code>/timestamps</td><td><code>examination-result.schema.ts:8-25</code>, <code>base.schema.ts</code></td></tr> <tr><td>ReportCard</td><td><code>studentId</code>, <code>examinationId</code>, <code>subjects[]</code> (<code>subjectId</code>, <code>subjectName</code>*, <code>marksObtained</code>, <code>maximumMarks</code>, <code>grade?</code>, <code>remarks?</code>), <code>totalMarksObtained</code>, <code>totalMaximumMarks</code>, <code>percentage</code>, <code>overallGrade</code>, <code>generatedAt</code></td><td><code>result.service.ts:8-26</code></td></tr> <tr><td>Marks entry input</td><td><code>studentId</code>, <code>marksObtained</code>, <code>grade?</code>, <code>remarks?</code></td><td><code>examination-subject.dto.ts:47-65</code></td></tr> </tbody></table> </div> <p>* <code>subjectName</code> currently equals the subject ID (<code>result.service.ts:75</code>) — see gaps.</p> <h2 id="3-navigation-rules-1"><a class="header" href="#3-navigation-rules-1">3. Navigation rules</a></h2> <ul> <li><strong>Marks entry lives under Examinations</strong>, not Results: the write endpoint is proxied from <code>result.controller.ts:26-31</code> into the Exams service, and subjects are exam-owned. Results tab = read-only (student lists + report cards).</li> <li><strong>Publish is exam-scoped</strong> (<code>examinations/:id/publish</code>), not result-scoped; UI must place it on exam detail, not on a single result row.</li> <li>Report card requires <strong>both</strong> <code>studentId</code> and <code>examId</code> path params (<code>result.controller.ts:32-37</code>) — navigation must always carry the pair.</li> </ul> <h2 id="4-state-flags-driving-ia"><a class="header" href="#4-state-flags-driving-ia">4. State flags driving IA</a></h2> <div class="table-wrapper"><table><thead><tr><th>Flag</th><th>Derivation</th><th>UI effect</th></tr></thead><tbody> <tr><td>Exam status</td><td><code>examination.schema.ts:28-33</code></td><td><code>published</code> → mark entry read-only*; else editable</td></tr> <tr><td>Result published</td><td><code>publishedAt</code> on row (<code>examination-result.schema.ts:24-25</code>)</td><td>badge "Published" on result rows/list items</td></tr> <tr><td>Entry exists</td><td>row present for (student, subject)</td><td>grid cell shows marks vs. empty</td></tr> </tbody></table> </div> <p>* Server does not enforce read-only after publish (<code>examination.service.ts:139-195</code> has no published check) — UI-only for now, flagged <code>(planned)</code>.</p> <h2 id="5-empty--error-placements"><a class="header" href="#5-empty--error-placements">5. Empty & error placements</a></h2> <ul> <li>Zero subjects for exam → report card 404 (<code>result.service.ts:51-52</code>) → screen-level empty state (see <code>06_Screen_Specifications.md</code>).</li> <li>No results rows for a student → <code>findByStudent</code> returns <code>[]</code> (repository returns array, <code>examination-result.repository.ts:26-28</code>) → empty state, no error.</li> <li>Marks > max → 404 at <code>examination.service.ts:145-146</code> → inline field error (client normalises code).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-results-module"><a class="header" href="#05--screen-inventory-results-module">05 — Screen Inventory (Results Module)</a></h1> <ul> <li><a href="results/05_Screen_Inventory.html#screen-purposes">Screen purposes</a> <ul> <li><a href="results/05_Screen_Inventory.html#s1--marks-entry-grid">S1 — Marks Entry Grid</a></li> <li><a href="results/05_Screen_Inventory.html#s2--exam-subject-detail">S2 — Exam-Subject Detail</a></li> <li><a href="results/05_Screen_Inventory.html#s3--student-results-list">S3 — Student Results List</a></li> <li><a href="results/05_Screen_Inventory.html#s4--report-card-view">S4 — Report Card View</a></li> <li><a href="results/05_Screen_Inventory.html#s5--grade-summary-completion--distribution-overview">S5 — Grade Summary (completion & distribution overview)</a></li> </ul> </li> <li><a href="results/05_Screen_Inventory.html#gaps-vs-screens">Gaps vs. screens</a></li> </ul> <blockquote> <p>Every screen maps 1:1 to a real endpoint or DTO. No screen is invented; five screens cover the module. <code>(forward-looking)</code> = client screens against the implemented API.</p> </blockquote> <hr /> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Screen</th><th>Route (client)</th><th>Primary endpoint</th><th>Source</th></tr></thead><tbody> <tr><td>S1</td><td><strong>Marks Entry Grid</strong> (per exam-subject)</td><td><code>/examinations/:examId/subjects/:examSubjectId/marks</code></td><td><code>GET /api/v1/results/exam-subject/:examSubjectId</code> + <code>POST …/marks</code></td><td><code>result.controller.ts:21-31</code></td></tr> <tr><td>S2</td><td><strong>Exam-Subject Detail</strong></td><td><code>/examinations/:examId/subjects/:examSubjectId</code></td><td><code>GET /api/v1/examinations/:id/subjects</code> (+ entry counts via S1)</td><td><code>examination.controller.ts:51-53</code>, <code>examination-subject.schema.ts:8-31</code></td></tr> <tr><td>S3</td><td><strong>Student Results List</strong></td><td><code>/students/:studentId/results</code></td><td><code>GET /api/v1/results/student/:studentId</code></td><td><code>result.controller.ts:16-20</code></td></tr> <tr><td>S4</td><td><strong>Report Card View</strong></td><td><code>/students/:studentId/results/:examId</code></td><td><code>GET /api/v1/results/report-card/:studentId/:examId</code></td><td><code>result.controller.ts:32-37</code></td></tr> <tr><td>S5</td><td><strong>Grade Summary</strong> (per exam, per class — completion overview)</td><td><code>/examinations/:examId/grades</code></td><td>Derived: <code>GET /api/v1/examinations/:id/subjects</code> → per-subject results</td><td><code>examination.controller.ts:51-53</code>, <code>result.controller.ts:21-25</code></td></tr> </tbody></table> </div> <h2 id="screen-purposes"><a class="header" href="#screen-purposes">Screen purposes</a></h2> <h3 id="s1--marks-entry-grid"><a class="header" href="#s1--marks-entry-grid">S1 — Marks Entry Grid</a></h3> <ul> <li>Data: all <code>ExaminationResult</code> rows for one exam-subject (<code>examination-result.repository.ts:20-24</code>), one editable row per student: <code>marksObtained</code>, <code>grade</code> (client-supplied string), <code>remarks</code> (optional).</li> <li>Writes: per-row <code>POST /api/v1/results/exam-subject/:examSubjectId/marks</code> (idempotent upsert, <code>examination.service.ts:147-175</code>).</li> <li>Context header: subject <code>date</code>, <code>startTime</code>/<code>endTime</code>, <code>maximumMarks</code>, <code>passingMarks</code> (<code>examination-subject.schema.ts:18-31</code>).</li> </ul> <h3 id="s2--exam-subject-detail"><a class="header" href="#s2--exam-subject-detail">S2 — Exam-Subject Detail</a></h3> <ul> <li>Data: one <code>ExaminationSubject</code> row; entry coverage (entered/total students — computed client-side from S1 data).</li> <li>Actions: navigate to S1; shows subject schedule info.</li> </ul> <h3 id="s3--student-results-list"><a class="header" href="#s3--student-results-list">S3 — Student Results List</a></h3> <ul> <li>Data: all <code>ExaminationResult</code> rows for a student (<code>examination-result.repository.ts:26-28</code>) — flat rows, <strong>not grouped by exam</strong> (no grouping endpoint exists; client groups by <code>examinationSubjectId</code> or via exam lookup).</li> <li>Action: open S4 for any exam with a report card.</li> </ul> <h3 id="s4--report-card-view"><a class="header" href="#s4--report-card-view">S4 — Report Card View</a></h3> <ul> <li>Data: <code>ReportCard</code> object (<code>result.service.ts:8-26</code>): subject rows, totals, <code>percentage</code>, <code>overallGrade</code>, <code>generatedAt</code>.</li> <li>Read-only. Errors: 404 when exam has no subjects (<code>result.service.ts:51-52</code>).</li> </ul> <h3 id="s5--grade-summary-completion--distribution-overview"><a class="header" href="#s5--grade-summary-completion--distribution-overview">S5 — Grade Summary (completion & distribution overview)</a></h3> <ul> <li>Data: subjects of exam (S2 endpoint) + results per subject (S1 endpoint).</li> <li>Shows per-class/per-subject: entries entered vs students, marks distribution; <strong>publish readiness</strong>.</li> <li>No dedicated backend endpoint — <code>(proposed)</code> for a server-side aggregation (blueprint <code>GET /api/v1/results/analytics</code>, <code>Results.md:28</code>); today client-side aggregation only.</li> </ul> <h2 id="gaps-vs-screens"><a class="header" href="#gaps-vs-screens">Gaps vs. screens</a></h2> <div class="table-wrapper"><table><thead><tr><th>Gap</th><th>Impact</th><th>Status</th></tr></thead><tbody> <tr><td>No bulk marks POST (one request per student)</td><td>S1 must serialize N upserts</td><td><code>(planned)</code> — <code>marks-import</code> <code>IMPLEMENTATION_PLAN.md:219</code></td></tr> <tr><td>No per-exam grouping endpoint for student rows</td><td>S3 groups client-side</td><td><code>(planned)</code></td></tr> <tr><td>No rank</td><td>S4 cannot show rank</td><td><code>(planned)</code> — <code>Results.md:54</code></td></tr> <tr><td>No publish-lock</td><td>S1 stays editable after publish</td><td><code>(planned)</code></td></tr> <tr><td>No analytics</td><td>S5 is client-aggregated</td><td><code>(proposed)</code></td></tr> <tr><td>No PDF report card</td><td>S4 share/export is web-only</td><td><code>(planned)</code> — <code>Results.md:57</code></td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-results-module"><a class="header" href="#06--screen-specifications-results-module">06 — Screen Specifications (Results Module)</a></h1> <ul> <li><a href="results/06_Screen_Specifications.html#s1--marks-entry-grid">S1 — Marks Entry Grid</a> <ul> <li><a href="results/06_Screen_Specifications.html#1-data-contract">1. Data contract</a></li> <li><a href="results/06_Screen_Specifications.html#2-layout">2. Layout</a></li> <li><a href="results/06_Screen_Specifications.html#3-states">3. States</a></li> <li><a href="results/06_Screen_Specifications.html#4-components-from-03_component_librarymd">4. Components (from 03_Component_Library.md)</a></li> <li><a href="results/06_Screen_Specifications.html#5-tokens-02_design_tokensmd">5. Tokens (02_Design_Tokens.md)</a></li> <li><a href="results/06_Screen_Specifications.html#6-darklight">6. Dark/light</a></li> <li><a href="results/06_Screen_Specifications.html#7-responsive">7. Responsive</a></li> <li><a href="results/06_Screen_Specifications.html#8-accessibility-09_accessibility_baselinemd">8. Accessibility (09_Accessibility_Baseline.md)</a></li> </ul> </li> <li><a href="results/06_Screen_Specifications.html#s2--exam-subject-detail">S2 — Exam-Subject Detail</a> <ul> <li><a href="results/06_Screen_Specifications.html#1-data-contract-1">1. Data contract</a></li> <li><a href="results/06_Screen_Specifications.html#2-layout-1">2. Layout</a></li> <li><a href="results/06_Screen_Specifications.html#3-states-1">3. States</a></li> <li><a href="results/06_Screen_Specifications.html#47-componentstokensresponsivea11y">4–7. Components/Tokens/Responsive/A11y</a></li> </ul> </li> <li><a href="results/06_Screen_Specifications.html#s3--student-results-list">S3 — Student Results List</a> <ul> <li><a href="results/06_Screen_Specifications.html#1-data-contract-2">1. Data contract</a></li> <li><a href="results/06_Screen_Specifications.html#2-layout-2">2. Layout</a></li> <li><a href="results/06_Screen_Specifications.html#3-states-2">3. States</a></li> <li><a href="results/06_Screen_Specifications.html#47-componentstokensresponsivea11y-1">4–7. Components/Tokens/Responsive/A11y</a></li> </ul> </li> <li><a href="results/06_Screen_Specifications.html#s4--report-card-view">S4 — Report Card View</a> <ul> <li><a href="results/06_Screen_Specifications.html#1-data-contract-3">1. Data contract</a></li> <li><a href="results/06_Screen_Specifications.html#2-layout-3">2. Layout</a></li> <li><a href="results/06_Screen_Specifications.html#3-states-3">3. States</a></li> <li><a href="results/06_Screen_Specifications.html#47-componentstokensresponsivea11y-2">4–7. Components/Tokens/Responsive/A11y</a></li> </ul> </li> <li><a href="results/06_Screen_Specifications.html#s5--grade-summary-completion--distribution">S5 — Grade Summary (completion & distribution)</a> <ul> <li><a href="results/06_Screen_Specifications.html#1-data-contract-4">1. Data contract</a></li> <li><a href="results/06_Screen_Specifications.html#2-layout-4">2. Layout</a></li> <li><a href="results/06_Screen_Specifications.html#3-states-4">3. States</a></li> <li><a href="results/06_Screen_Specifications.html#47-componentstokensresponsivea11y-3">4–7. Components/Tokens/Responsive/A11y</a></li> <li><a href="results/06_Screen_Specifications.html#8-cross-cutting-a11y-notes-all-screens">8. Cross-cutting a11y notes (all screens)</a></li> </ul> </li> </ul> <blockquote> <p>The largest doc: full behaviour per screen. References shared specs by name: <a href="results/02_User_Personas.html">02_Design_Tokens.md</a> (tokens <code>T-</code>), <a href="results/03_User_Journey.html">03_Component_Library.md</a> (components <code>C-</code>), <a href="results/09_User_Behaviour.html">09_Accessibility_Baseline.md</a> (a11y rules), <code>06_State_Management.md</code> (cubits), <a href="results/07_Component_Library.html">07_API_Conventions.md</a> (envelope/errors). Server facts cited with <code>file:line</code>. All screens <code>(forward-looking)</code> Flutter client design.</p> </blockquote> <hr /> <h1 id="s1--marks-entry-grid-1"><a class="header" href="#s1--marks-entry-grid-1">S1 — Marks Entry Grid</a></h1> <p><strong>Route:</strong> <code>/examinations/:examId/subjects/:examSubjectId/marks</code> <strong>Purpose:</strong> enter/update marks for every student for one exam-subject.</p> <h2 id="1-data-contract"><a class="header" href="#1-data-contract">1. Data contract</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Source</th></tr></thead><tbody> <tr><td>Subject context: <code>date</code>, <code>startTime</code>, <code>endTime</code>, <code>maximumMarks</code>, <code>passingMarks</code></td><td><code>examination-subject.schema.ts:18-31</code></td></tr> <tr><td>Rows: <code>studentId</code>, <code>marksObtained?</code>, <code>grade?</code>, <code>remarks?</code>, <code>publishedAt?</code></td><td><code>examination-result.schema.ts:8-25</code></td></tr> <tr><td>Load: <code>GET /api/v1/results/exam-subject/:examSubjectId</code></td><td><code>result.controller.ts:21-25</code></td></tr> <tr><td>Save: <code>POST /api/v1/results/exam-subject/:examSubjectId/marks</code> body <code>{studentId, marksObtained, grade?, remarks?}</code></td><td><code>result.controller.ts:26-31</code>, <code>examination-subject.dto.ts:47-65</code></td></tr> </tbody></table> </div> <h2 id="2-layout"><a class="header" href="#2-layout">2. Layout</a></h2> <ul> <li><strong>Header block (S1-H)</strong>: exam name + subject + class; chips: date, time range, <code>maximumMarks</code>, <code>passingMarks</code>, entry count <code>n/students</code>.</li> <li><strong>Table (S1-T)</strong>: columns — student (name, id), marks obtained, grade, remarks, status. Rows sorted by student name (client-side).</li> <li><strong>Bottom action bar (S1-A)</strong>: "Save all pending" with pending count; "Sync" for offline draft.</li> <li><strong>Status strip (S1-S)</strong>: unsaved-changes count; last-sync time.</li> </ul> <h2 id="3-states"><a class="header" href="#3-states">3. States</a></h2> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Trigger</th><th>UI</th></tr></thead><tbody> <tr><td>Loading</td><td>cubit fetch of S1-T + header</td><td><code>C-AppSkeleton</code> rows (shimmer, 8 rows)</td></tr> <tr><td>Loaded, clean</td><td>fetch ok, no edits</td><td>Table + <code>C-AppSnackbar</code>-free header chip <code>Draft</code></td></tr> <tr><td>Loaded, dirty</td><td>≥1 field edited</td><td>Save bar active; per-row unsaved dot</td></tr> <tr><td>Saving row</td><td>upsert in flight</td><td>Row spinner on that row only (per-row concurrency, <code>examination.service.ts:147-175</code> upsert)</td></tr> <tr><td>Saved</td><td>2xx</td><td>Row clears dirty state; <code>C-AppSnackbar</code> success < 3 s</td></tr> <tr><td>Row rejected</td><td>404 <code>RESOURCE_NOT_FOUND</code> from <code>examination.service.ts:145-146</code> (marks > max)</td><td>Inline field error: "Marks cannot exceed {maximumMarks}"; row keeps dirty state</td></tr> <tr><td>Network fail</td><td>timeout/offline</td><td><code>C-AppOfflineBanner</code>; edits preserved in offline draft (15_Flutter §Offline)</td></tr> <tr><td>Empty</td><td>exam-subject has no students yet</td><td><code>C-AppEmptyState</code>: "No students to grade yet"</td></tr> <tr><td>Published (server row has <code>publishedAt</code>, <code>examination-result.schema.ts:24-25</code>)</td><td>row-level</td><td>Row locked + "Published" chip; <strong>UI-only lock</strong> — server still accepts writes (<code>examination.service.ts:139-195</code> has no published check, <code>(planned)</code>)</td></tr> </tbody></table> </div> <h2 id="4-components-from-03_component_librarymd"><a class="header" href="#4-components-from-03_component_librarymd">4. Components (from <a href="results/03_User_Journey.html">03_Component_Library.md</a>)</a></h2> <p><code>C-AppTextField</code> (numeric, <code>tabularFigures()</code> per <a href="results/02_User_Personas.html">02_Design_Tokens.md</a> §2), <code>C-AppSelect</code> (grade), <code>C-AppSnackbar</code>, <code>C-AppOfflineBanner</code>, <code>C-AppEmptyState</code>, <code>C-AppSkeleton</code>, <code>C-StatusChip</code> (module: draft/saved/published), <code>C-DataTable</code> (module: virtualised, see 15_Flutter §Table), <code>C-FilterChip</code> (only-entered/only-missing toggle).</p> <h2 id="5-tokens-02_design_tokensmd"><a class="header" href="#5-tokens-02_design_tokensmd">5. Tokens (<a href="results/02_User_Personas.html">02_Design_Tokens.md</a>)</a></h2> <ul> <li>Table header row: <code>surfaceContainerHigh</code>; zebra: <code>surface</code> / <code>surfaceContainerLow</code>.</li> <li>Entered marks: <code>onSurface</code>; missing row: <code>onSurfaceVariant</code> italic; over-max error: <code>error</code> + <code>errorContainer</code> field fill.</li> <li>Publish chip: <code>secondaryContainer</code>-style <code>success</code>; draft chip: <code>surfaceVariant</code>.</li> <li>Numerals <code>mono</code> 14/400/20 + <code>FontFeature.tabularFigures()</code>.</li> </ul> <h2 id="6-darklight"><a class="header" href="#6-darklight">6. Dark/light</a></h2> <p>Both modes identical structure; only roles swap per <a href="results/02_User_Personas.html">02_Design_Tokens.md</a> §1 (<code>surfaceContainerHigh</code> <code>#ECE6E0</code> light / <code>#2B2929</code> dark, etc.). Grid lines use <code>outlineVariant</code> in both.</p> <h2 id="7-responsive"><a class="header" href="#7-responsive">7. Responsive</a></h2> <ul> <li>≥ 720 px: full table, columns as above.</li> <li>< 720 px: table → cards; per-student card: name header, marks field, grade dropdown, remarks field, status chip. <code>maximumMarks</code> hint beside marks field in both.</li> <li>Portrait phones: save bar fixed bottom (respect safe-area), sticky header block with subject chips scrollable horizontally.</li> </ul> <h2 id="8-accessibility-09_accessibility_baselinemd"><a class="header" href="#8-accessibility-09_accessibility_baselinemd">8. Accessibility (<a href="results/09_User_Behaviour.html">09_Accessibility_Baseline.md</a>)</a></h2> <ul> <li>Table: <code>Semantics</code> header row; each row labelled "Student {name}, marks {value} of {max}".</li> <li>Marks field: label "Marks obtained — {student}" + <code>errorText</code> announced via <code>SemanticsService</code> on 404-reject.</li> <li>Keyboard: Tab order = marks → grade → remarks per row; Enter saves row.</li> <li>Color not the only signal: published/missing states carry icon + text, not just chip color.</li> <li>Charts/tables a11y (see S5 §8): table content is real <code>Semantics</code>, not image.</li> <li>Focus visible on save bar; reduced motion: no per-row spinner animation (static progress).</li> </ul> <hr /> <h1 id="s2--exam-subject-detail-1"><a class="header" href="#s2--exam-subject-detail-1">S2 — Exam-Subject Detail</a></h1> <p><strong>Route:</strong> <code>/examinations/:examId/subjects/:examSubjectId</code></p> <h2 id="1-data-contract-1"><a class="header" href="#1-data-contract-1">1. Data contract</a></h2> <p>Single <code>ExaminationSubject</code> (<code>examination-subject.schema.ts:8-31</code>): <code>examinationId</code>, <code>subjectId</code>, <code>classId</code>, <code>date</code>, <code>startTime</code>, <code>endTime</code>, <code>maximumMarks</code>, <code>passingMarks</code>. Source endpoint: <code>GET /api/v1/examinations/:id/subjects</code> (<code>examination.controller.ts:51-53</code>) — client picks the row by id (no single-row GET exists).</p> <h2 id="2-layout-1"><a class="header" href="#2-layout-1">2. Layout</a></h2> <ul> <li>Hero card: subject name (client-mapped from <code>subjectId</code>), class.</li> <li>Info grid: date; <code>startTime</code>–<code>endTime</code>; <code>maximumMarks</code>; <code>passingMarks</code>.</li> <li>Coverage card: entered count / total students + linear progress (<code>C-ProgressIndicator</code>); computed client-side from S1 rows.</li> <li>Primary action: "Enter marks" → S1. Secondary: "Grade summary" → S5 (filtered to this subject).</li> </ul> <h2 id="3-states-1"><a class="header" href="#3-states-1">3. States</a></h2> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Trigger</th><th>UI</th></tr></thead><tbody> <tr><td>Loading</td><td>subjects fetch</td><td>Hero <code>C-AppSkeleton</code></td></tr> <tr><td>Found</td><td>id in list</td><td>Full detail</td></tr> <tr><td>Not found</td><td>id absent (or exam 404)</td><td><code>C-AppErrorState</code> <code>RESOURCE_NOT_FOUND</code> + retry</td></tr> <tr><td>Covered</td><td>count == total</td><td>Progress 100%, <code>success</code> tint</td></tr> <tr><td>Partial</td><td>0 < count < total</td><td>Progress partial, <code>primary</code> tint, "n remaining" hint</td></tr> <tr><td>Zero</td><td>count == 0</td><td><code>C-AppEmptyState</code> compact, CTA "Enter marks"</td></tr> </tbody></table> </div> <h2 id="47-componentstokensresponsivea11y"><a class="header" href="#47-componentstokensresponsivea11y">4–7. Components/Tokens/Responsive/A11y</a></h2> <ul> <li>Same tokens as S1; info grid uses <code>bodyMedium</code> <code>onSurfaceVariant</code> labels.</li> <li>Responsive: info grid 2 cols ≥ 600 px, 1 col below.</li> <li>A11y: coverage progress announces "marks entered for {n} of {total} students"; hero card single <code>Semantics</code> node.</li> </ul> <hr /> <h1 id="s3--student-results-list-1"><a class="header" href="#s3--student-results-list-1">S3 — Student Results List</a></h1> <p><strong>Route:</strong> <code>/students/:studentId/results</code></p> <h2 id="1-data-contract-2"><a class="header" href="#1-data-contract-2">1. Data contract</a></h2> <p><code>GET /api/v1/results/student/:studentId</code> → array of <code>ExaminationResult</code> rows (<code>examination-result.repository.ts:26-28</code>): <code>studentId</code>, <code>examinationSubjectId</code>, <code>marksObtained?</code>, <code>grade?</code>, <code>remarks?</code>, <code>publishedAt?</code>, timestamps.</p> <blockquote> <p>Flat rows, not grouped by exam — client groups by <code>examinationSubjectId</code> → exam lookup (<code>GET /api/v1/examinations/:id</code>, <code>examination.controller.ts:33-35</code>) to build the list. <code>(planned)</code> server-side grouping would remove N lookups.</p> </blockquote> <h2 id="2-layout-2"><a class="header" href="#2-layout-2">2. Layout</a></h2> <ul> <li><strong>Grouped list (S3-L)</strong>: exam group headers (name + status chip); items per subject: subject name, marks <code>obtained/max</code>, grade chip, remarks.</li> <li><strong>FAB / header action</strong>: "Report card" per exam → S4 (needs both ids — <code>result.controller.ts:32-37</code>).</li> <li><strong>Filter chip row</strong>: All / Published (<code>publishedAt</code> set) / Unpublished / Grade (A+…F).</li> </ul> <h2 id="3-states-2"><a class="header" href="#3-states-2">3. States</a></h2> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Trigger</th><th>UI</th></tr></thead><tbody> <tr><td>Loading</td><td>fetch</td><td><code>C-AppSkeleton</code> list</td></tr> <tr><td>Empty</td><td><code>[]</code> returned</td><td><code>C-AppEmptyState</code>: "No results recorded"</td></tr> <tr><td>Loaded</td><td>rows</td><td>Grouped list; unpublished items dimmed (<code>onSurfaceVariant</code>)</td></tr> <tr><td>Published-only filter</td><td>filter chip</td><td>Only <code>publishedAt != null</code> groups (<code>examination-result.schema.ts:24-25</code>)</td></tr> <tr><td>Error</td><td>401/403/429</td><td><code>C-AppErrorState</code> per <a href="results/07_Component_Library.html">07_API_Conventions.md</a> codes</td></tr> </tbody></table> </div> <h2 id="47-componentstokensresponsivea11y-1"><a class="header" href="#47-componentstokensresponsivea11y-1">4–7. Components/Tokens/Responsive/A11y</a></h2> <ul> <li>Grade chip colors: A+/A <code>success</code>, B+/B <code>primary</code>, C <code>tertiary</code>, D <code>tertiary</code>, F <code>error</code> (band mapping <code>result.service.ts:130-138</code>).</li> <li>Responsive: single-column list everywhere; filters scroll horizontally.</li> <li>A11y: each item announces "subject, marks of maximum, grade"; group headers <code>titleSmall</code> + <code>Semantics(header: true)</code>.</li> </ul> <hr /> <h1 id="s4--report-card-view-1"><a class="header" href="#s4--report-card-view-1">S4 — Report Card View</a></h1> <p><strong>Route:</strong> <code>/students/:studentId/results/:examId</code></p> <h2 id="1-data-contract-3"><a class="header" href="#1-data-contract-3">1. Data contract</a></h2> <p><code>GET /api/v1/results/report-card/:studentId/:examId</code> → <code>ReportCard</code> (<code>result.service.ts:8-26</code>):</p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Notes</th><th>Source</th></tr></thead><tbody> <tr><td><code>studentId</code>, <code>examinationId</code></td><td>echo of path params</td><td><code>result.service.ts:18-19</code></td></tr> <tr><td><code>subjects[]</code></td><td><code>subjectId</code>, <code>subjectName</code> (= raw subject ID — client must map!), <code>marksObtained</code> (<strong>0 when missing</strong>, <code>result.service.ts:70</code>), <code>maximumMarks</code>, <code>grade?</code>, <code>remarks?</code></td><td><code>result.service.ts:68-81</code></td></tr> <tr><td><code>totalMarksObtained</code></td><td>sum incl. 0-filled missing</td><td><code>result.service.ts:65-71</code></td></tr> <tr><td><code>totalMaximumMarks</code></td><td>sum of all subject maxima</td><td><code>result.service.ts:72</code></td></tr> <tr><td><code>percentage</code></td><td>round(obtained/max × 100, 2) — <strong>computed server-side</strong></td><td><code>result.service.ts:83-86</code></td></tr> <tr><td><code>overallGrade</code></td><td>bands A+≥90, A≥80, B+≥70, B≥60, C≥50, D≥40, F<40 — <strong>computed server-side</strong></td><td><code>result.service.ts:95, 130-138</code></td></tr> <tr><td><code>generatedAt</code></td><td>ISO string, per-request</td><td><code>result.service.ts:96</code></td></tr> </tbody></table> </div> <h2 id="2-layout-3"><a class="header" href="#2-layout-3">2. Layout</a></h2> <ul> <li><strong>Card header</strong>: exam name, student name, <code>generatedAt</code> (formatted local), overall grade hero chip.</li> <li><strong>Totals bar</strong>: <code>totalMarksObtained</code> / <code>totalMaximumMarks</code> + <code>percentage</code> (mono, 2 decimals).</li> <li><strong>Subject table (S4-T)</strong>: subject (mapped name), marks, max, grade chip, remarks.</li> <li><strong>Actions</strong>: Share (web share — <code>(forward-looking)</code>); Print/PDF — <code>(planned)</code> (<code>Results.md:57</code>, Reports module).</li> </ul> <h2 id="3-states-3"><a class="header" href="#3-states-3">3. States</a></h2> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Trigger</th><th>UI</th></tr></thead><tbody> <tr><td>Loading</td><td>fetch</td><td><code>C-AppSkeleton</code> card + table</td></tr> <tr><td>Success</td><td>2xx</td><td>Full card</td></tr> <tr><td>Exam has no subjects</td><td>404 <code>No subjects found for this examination.</code> (<code>result.service.ts:51-52</code>)</td><td><code>C-AppErrorState</code> → guided empty: "No subjects configured for this exam"</td></tr> <tr><td>Student not found / cross-tenant</td><td>404 (envelope per <a href="results/07_Component_Library.html">07_API_Conventions.md</a> §6)</td><td><code>C-AppErrorState</code> <code>RESOURCE_NOT_FOUND</code></td></tr> <tr><td>Missing subject rows</td><td><code>marksObtained: 0</code>, no grade</td><td>Row shows "0 / max" with "not entered" hint chip <code>(data limitation: server cannot distinguish 0 from missing — </code>result.service.ts:70<code>)</code></td></tr> <tr><td>Network</td><td>offline</td><td><code>C-AppOfflineBanner</code> + cached card if previously loaded (15_Flutter §Cache)</td></tr> </tbody></table> </div> <h2 id="47-componentstokensresponsivea11y-2"><a class="header" href="#47-componentstokensresponsivea11y-2">4–7. Components/Tokens/Responsive/A11y</a></h2> <ul> <li>Grade hero: <code>displaySmall</code>; totals <code>headlineMedium</code> mono; subject table per S1 §5.</li> <li><strong>Dark/light</strong>: card surface <code>surfaceContainerLow</code>; hero chip uses grade role colors (see S3).</li> <li><strong>Responsive</strong>: ≥ 600 px two-column (totals bar + grade hero side by side); below → stacked; table → stacked rows with labels.</li> <li><strong>A11y</strong>: hero <code>Semantics(label: "Overall grade A plus")</code> — never read "A+" as literal; table rows announced "Subject, marks x of y, grade z"; totals bar announced as one summary sentence. <code>generatedAt</code> in <code>Semantics</code> with full date. Contrast of grade chips ≥ 4.5:1 (<a href="results/09_User_Behaviour.html">09_Accessibility_Baseline.md</a>).</li> </ul> <hr /> <h1 id="s5--grade-summary-completion--distribution"><a class="header" href="#s5--grade-summary-completion--distribution">S5 — Grade Summary (completion & distribution)</a></h1> <p><strong>Route:</strong> <code>/examinations/:examId/grades</code></p> <h2 id="1-data-contract-4"><a class="header" href="#1-data-contract-4">1. Data contract</a></h2> <p>No dedicated endpoint <code>(proposed)</code> — blueprint <code>GET /api/v1/results/analytics</code> (<code>Results.md:28</code>) unimplemented. Client aggregates:</p> <ol> <li><code>GET /api/v1/examinations/:id/subjects</code> (<code>examination.controller.ts:51-53</code>)</li> <li>per subject: <code>GET /api/v1/results/exam-subject/:examSubjectId</code> (<code>result.controller.ts:21-25</code>) Then computes client-side: coverage %, grade distribution (applying <strong>server bands</strong> <code>result.service.ts:130-138</code> to the <strong>server-computed</strong> report-card percentages where possible; per-subject grades are client-supplied strings, so distribution uses stored <code>grade</code> strings), pass/fail vs <code>passingMarks</code> (<code>examination-subject.schema.ts:30-31</code>).</li> </ol> <h2 id="2-layout-4"><a class="header" href="#2-layout-4">2. Layout</a></h2> <ul> <li><strong>Class filter</strong> (from subject <code>classId</code>, <code>examination-subject.schema.ts:15-16</code>).</li> <li><strong>Coverage row</strong>: per-subject progress bars (entered / total).</li> <li><strong>Distribution chart</strong>: horizontal bar per grade (A+…F) — counts of stored grades; ungraded bucket.</li> <li><strong>Publish readiness banner</strong>: <code>C-AppBanner</code> warning when any subject has < 100% coverage; <code>success</code> when complete.</li> <li>Action: "Publish results" → confirm dialog → <code>POST /api/v1/examinations/:id/publish</code> (<code>examination.controller.ts:54-56</code>).</li> </ul> <h2 id="3-states-4"><a class="header" href="#3-states-4">3. States</a></h2> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Trigger</th><th>UI</th></tr></thead><tbody> <tr><td>Aggregating</td><td>N subject fetches in flight</td><td>Skeleton bars</td></tr> <tr><td>Ready</td><td>all fetched</td><td>Charts + banner</td></tr> <tr><td>Empty</td><td>no subjects</td><td><code>C-AppEmptyState</code> "No subjects added to this exam"</td></tr> <tr><td>Published exam</td><td>status <code>published</code> (<code>examination.schema.ts:28-33</code>)</td><td>Banner → "Results published on {date}"; publish action disabled</td></tr> </tbody></table> </div> <h2 id="47-componentstokensresponsivea11y-3"><a class="header" href="#47-componentstokensresponsivea11y-3">4–7. Components/Tokens/Responsive/A11y</a></h2> <ul> <li>Charts: <strong>module-specific simple bar chart</strong> (no chart dependency, see <a href="results/07_Component_Library.html">07_Component_Library.md</a> + 15_Flutter §Charts). Bars use grade role colors; ungraded = <code>surfaceVariant</code>.</li> <li>Tokens per S3 §5.</li> <li>Responsive: bars stack vertically < 600 px; chart has scrollable horizontal axis.</li> <li><strong>A11y (charts/tables)</strong>: every bar is a <code>Semantics</code>-labelled node "Grade A, 12 students (24%)"; raw counts also rendered as text (never chart-only); <code>C-AppBanner</code> announced via <code>SemanticsService</code> (<a href="results/09_User_Behaviour.html">09_Accessibility_Baseline.md</a> §charts).</li> </ul> <h2 id="8-cross-cutting-a11y-notes-all-screens"><a class="header" href="#8-cross-cutting-a11y-notes-all-screens">8. Cross-cutting a11y notes (all screens)</a></h2> <ul> <li>Charts and tables always ship a text equivalent; no pure-image data.</li> <li>All error surfaces map envelope codes → copy (<a href="results/07_Component_Library.html">07_API_Conventions.md</a> §3); 5xx → generic + <code>requestId</code>.</li> <li>Focus order linear; <code>C-AppSnackbar</code> errors persist ≥ 4 s.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-results-module"><a class="header" href="#07--component-library-results-module">07 — Component Library (Results Module)</a></h1> <ul> <li><a href="results/07_Component_Library.html#r-datatable-virtualised-results-grid">R-DataTable (virtualised results grid)</a></li> <li><a href="results/07_Component_Library.html#r-gradechip">R-GradeChip</a></li> <li><a href="results/07_Component_Library.html#r-marksfield">R-MarksField</a></li> <li><a href="results/07_Component_Library.html#r-coveragebar">R-CoverageBar</a></li> <li><a href="results/07_Component_Library.html#r-gradedistributionchart">R-GradeDistributionChart</a></li> <li><a href="results/07_Component_Library.html#r-publishpanel">R-PublishPanel</a></li> </ul> <blockquote> <p>Module-specific components. Shared components (<code>AppTextField</code>, <code>AppSelect</code>, <code>AppSnackbar</code>, <code>AppOfflineBanner</code>, <code>AppEmptyState</code>, <code>AppErrorState</code>, <code>AppSkeleton</code>, <code>AppBanner</code>, <code>StatusChip</code>, <code>ProgressIndicator</code>, <code>FilterChip</code>) are referenced from <a href="results/03_User_Journey.html">03_Component_Library.md</a> — not reproduced here.</p> </blockquote> <hr /> <h2 id="r-datatable-virtualised-results-grid"><a class="header" href="#r-datatable-virtualised-results-grid">R-DataTable (virtualised results grid)</a></h2> <ul> <li><strong>Purpose</strong>: marks-entry grid for S1; report-card subject table for S4.</li> <li><strong>Props</strong>: <code>columns</code>, <code>rows</code>, <code>editableCell</code>, <code>onCellChanged</code>, <code>rowStates</code> (dirty/saving/saved/error/published), <code>maxMarks</code>.</li> <li><strong>Behaviour</strong>: virtualised (only visible rows built — see 15_Flutter §Table); per-row editing; numeric keyboard; tabular numerals (<a href="results/02_User_Personas.html">02_Design_Tokens.md</a> §2).</li> <li><strong>States</strong>: loading (skeleton), clean, dirty, row-save spinner, row-error (<code>errorContainer</code> fill + <code>errorText</code>), published-locked.</li> <li><strong>A11y</strong>: header <code>Semantics</code>; row label "Student {name}, marks {v} of {max}".</li> <li><strong>Tokens</strong>: <code>surfaceContainerHigh</code> header, <code>outlineVariant</code> gridlines, <code>mono</code> numerals.</li> </ul> <h2 id="r-gradechip"><a class="header" href="#r-gradechip">R-GradeChip</a></h2> <ul> <li><strong>Purpose</strong>: grade badge (A+/A/B+/B/C/D/F or client-supplied string).</li> <li><strong>Props</strong>: <code>grade</code>, <code>size</code>.</li> <li><strong>Behaviour</strong>: colour by band (<code>result.service.ts:130-138</code>): A+/A <code>success</code>, B+/B <code>primary</code>, C/D <code>tertiary</code>, F <code>error</code>; unknown string → <code>surfaceVariant</code> + neutral text.</li> <li><strong>A11y</strong>: announced as letters, never symbols ("grade A plus").</li> <li><strong>Tokens</strong>: role-colour container + <code>on*Container</code> text, <code>labelMedium</code>.</li> </ul> <h2 id="r-marksfield"><a class="header" href="#r-marksfield">R-MarksField</a></h2> <ul> <li><strong>Purpose</strong>: numeric marks input bound to a student row.</li> <li><strong>Props</strong>: <code>studentId</code>, <code>value</code>, <code>maximumMarks</code>, <code>passingMarks</code>, <code>readOnly</code>, <code>onCommit</code>.</li> <li><strong>Behaviour</strong>: local text state until commit; validation client-side <code>value ≤ maximumMarks</code> mirrors server rule (<code>examination.service.ts:145-146</code>); over-max shows inline error without a round trip.</li> <li><strong>Tokens</strong>: <code>AppTextField</code> tokens; error <code>error</code>/<code>errorContainer</code>.</li> </ul> <h2 id="r-coveragebar"><a class="header" href="#r-coveragebar">R-CoverageBar</a></h2> <ul> <li><strong>Purpose</strong>: marks-entry completeness (S2, S5).</li> <li><strong>Props</strong>: <code>entered</code>, <code>total</code>.</li> <li><strong>Behaviour</strong>: 100 % → <code>success</code>; partial → <code>primary</code>; 0 → <code>surfaceVariant</code>; label "n of m".</li> <li><strong>A11y</strong>: <code>Semantics(value: "entered n of m")</code>.</li> </ul> <h2 id="r-gradedistributionchart"><a class="header" href="#r-gradedistributionchart">R-GradeDistributionChart</a></h2> <ul> <li><strong>Purpose</strong>: S5 per-grade bars.</li> <li><strong>Props</strong>: <code>counts: Record<grade, number></code>, <code>total</code>.</li> <li><strong>Behaviour</strong>: horizontal bars per band incl. ungraded bucket; pure Flutter <code>Row</code>/<code>FractionallySizedBox</code> — no chart dependency (see 15_Flutter §Charts).</li> <li><strong>A11y</strong>: each bar = <code>Semantics(label: "Grade {g}, {n} students ({p}%)")</code>; counts always rendered as text.</li> <li><strong>Tokens</strong>: band colours per <code>R-GradeChip</code>.</li> </ul> <h2 id="r-publishpanel"><a class="header" href="#r-publishpanel">R-PublishPanel</a></h2> <ul> <li><strong>Purpose</strong>: S5 publish action with confirmation.</li> <li><strong>Props</strong>: <code>examId</code>, <code>status</code>, <code>coverage</code>, <code>onPublish</code>.</li> <li><strong>Behaviour</strong>: disabled unless coverage 100 % (advisory — server accepts regardless, <code>examination.service.ts:203-220</code>); confirm dialog warns "cannot be reverted — no unpublish endpoint"; on success banner <code>success</code> "Results published", fires <code>POST /api/v1/examinations/:id/publish</code> (<code>examination.controller.ts:54-56</code>).</li> <li><strong>Tokens</strong>: <code>success</code> container / <code>primary</code> FAB.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-results-module"><a class="header" href="#08--form-specifications-results-module">08 — Form Specifications (Results Module)</a></h1> <ul> <li><a href="results/08_Form_Specifications.html#f1--marks-entry-per-student-per-exam-subject">F1 — Marks entry (per student, per exam-subject)</a> <ul> <li><a href="results/08_Form_Specifications.html#f11-studentid">F1.1 <code>studentId</code></a></li> <li><a href="results/08_Form_Specifications.html#f12-marksobtained">F1.2 <code>marksObtained</code></a></li> <li><a href="results/08_Form_Specifications.html#f13-grade-optional">F1.3 <code>grade</code> (optional)</a></li> <li><a href="results/08_Form_Specifications.html#f14-remarks-optional">F1.4 <code>remarks</code> (optional)</a></li> <li><a href="results/08_Form_Specifications.html#f15-submission-semantics">F1.5 Submission semantics</a></li> </ul> </li> <li><a href="results/08_Form_Specifications.html#f2--publish-confirmation-dialog-s5">F2 — Publish confirmation (dialog, S5)</a></li> <li><a href="results/08_Form_Specifications.html#f3--form-validation-summary-envelope-codes">F3 — Form validation summary (envelope codes)</a></li> </ul> <blockquote> <p>Only one real form exists in the domain: the <strong>marks entry form</strong> (S1), whose contract is <code>EnterMarksDto</code> (<code>examination-subject.dto.ts:47-65</code>). S5's publish confirmation is a dialog, not a form. Field-by-field below, including the server-side validation each field maps to. <code>(forward-looking)</code> client spec.</p> </blockquote> <hr /> <h2 id="f1--marks-entry-per-student-per-exam-subject"><a class="header" href="#f1--marks-entry-per-student-per-exam-subject">F1 — Marks entry (per student, per exam-subject)</a></h2> <p><strong>Submit:</strong> <code>POST /api/v1/results/exam-subject/:examSubjectId/marks</code> (<code>result.controller.ts:26-31</code>) — one submission per student row; idempotent upsert (<code>examination.service.ts:147-175</code>).</p> <h3 id="f11-studentid"><a class="header" href="#f11-studentid">F1.1 <code>studentId</code></a></h3> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Type</td><td>hidden — from the selected row; never user-editable</td></tr> <tr><td>Server rule</td><td><code>@IsMongoId()</code> required (<code>examination-subject.dto.ts:49-50</code>)</td></tr> <tr><td>Errors</td><td>400 <code>VALIDATION_ERROR</code> if malformed</td></tr> <tr><td>Notes</td><td>Row identity; unique with <code>examinationSubjectId</code> per DB index (<code>examination-result.schema.ts:31-33</code>)</td></tr> <tr><td>A11y</td><td><code>Semantics(exclude)</code> on the hidden field; row label carries the student name</td></tr> </tbody></table> </div> <h3 id="f12-marksobtained"><a class="header" href="#f12-marksobtained">F1.2 <code>marksObtained</code></a></h3> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Type</td><td>numeric, decimal allowed (backend <code>IsNumber</code> has no integer constraint, <code>:53-55</code>)</td></tr> <tr><td>Keyboard</td><td><code>TextInputType.numberWithOptions(decimal: true)</code>; <code>mono</code> + <code>tabularFigures()</code></td></tr> <tr><td>Client rule</td><td><code>0 ≤ marks ≤ maximumMarks</code> — mirrors server <code>marksObtained > subject.maximumMarks → 404</code> (<code>examination.service.ts:145-146</code>)</td></tr> <tr><td>Server rules</td><td><code>@IsNumber()</code> <code>@Min(0)</code> required (<code>examination-subject.dto.ts:52-55</code>)</td></tr> <tr><td>Hint</td><td>"Max {maximumMarks}" beside field (<code>examination-subject.schema.ts:27-28</code>)</td></tr> <tr><td>Errors</td><td>Client: over-max inline error <em>before</em> submit (no round trip). Server rejects still possible (stale <code>maximumMarks</code>): 404 <code>RESOURCE_NOT_FOUND</code> → normalise to "marks cannot exceed maximum" (404-vs-422 quirk, <a href="results/14_QA_Checklist.html">14_QA_Checklist.md</a> §5). 400 if negative or non-numeric.</td></tr> <tr><td>Focus</td><td>auto-advance Enter → grade field; commit-on-blur + Enter</td></tr> <tr><td>A11y</td><td>label "Marks obtained — {student}"; <code>errorText</code> announced via <code>SemanticsService</code></td></tr> </tbody></table> </div> <h3 id="f13-grade-optional"><a class="header" href="#f13-grade-optional">F1.3 <code>grade</code> (optional)</a></h3> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Type</td><td>dropdown of A+, A, B+, B, C, D, F + "clear" — but server accepts <strong>any string</strong> (<code>@IsOptional() @IsString()</code>, <code>examination-subject.dto.ts:57-60</code>), stored verbatim (<code>examination.service.ts:157</code>)</td></tr> <tr><td>Default</td><td>empty (server derives no per-subject grade; only the <em>overall</em> grade is computed, <code>result.service.ts:130-138</code>)</td></tr> <tr><td>Note</td><td>Client-supplied; do <strong>not</strong> auto-derive from marks client-side unless the org's scheme is known — flag: server grading <code>(planned)</code> (blueprint <code>Results.md:54-55</code>)</td></tr> <tr><td>Errors</td><td>400 if non-string</td></tr> <tr><td>A11y</td><td>label "Grade — {student}"; selected value announced via <code>R-GradeChip</code> semantics ("grade A plus")</td></tr> </tbody></table> </div> <h3 id="f14-remarks-optional"><a class="header" href="#f14-remarks-optional">F1.4 <code>remarks</code> (optional)</a></h3> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Type</td><td>free text, single-line <code>AppTextField</code></td></tr> <tr><td>Server rule</td><td><code>@IsOptional() @IsString()</code> (<code>examination-subject.dto.ts:62-65</code>) — <strong>no max length in code</strong></td></tr> <tr><td>Client rule</td><td>cap 200 chars locally <code>(proposed)</code> — no server contract exists</td></tr> <tr><td>Errors</td><td>400 if non-string</td></tr> <tr><td>A11y</td><td>label "Remarks — {student}"</td></tr> </tbody></table> </div> <h3 id="f15-submission-semantics"><a class="header" href="#f15-submission-semantics">F1.5 Submission semantics</a></h3> <ul> <li><strong>Per-row commit</strong>, not a whole-grid POST (no bulk endpoint — <code>(planned)</code> <code>marks-import</code>, <code>IMPLEMENTATION_PLAN.md:219</code>).</li> <li>Re-submitting the same row is an <strong>update</strong>, never a duplicate (upsert <code>examination.service.ts:147-175</code>).</li> <li>Partial payloads: <code>grade</code>/<code>remarks</code> omitted → <strong>cleared</strong> on update (<code>$set</code> with undefined fields is not applied — only provided keys are written, <code>:152-161</code>); send all three keys to avoid surprise.</li> <li>Offline: rows queue in the offline draft and replay in row order (15_Flutter §Offline).</li> </ul> <h2 id="f2--publish-confirmation-dialog-s5"><a class="header" href="#f2--publish-confirmation-dialog-s5">F2 — Publish confirmation (dialog, S5)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Trigger</td><td>"Publish results" → <code>POST /api/v1/examinations/:id/publish</code> (<code>examination.controller.ts:54-56</code>)</td></tr> <tr><td>Fields</td><td>none — confirmation copy only: exam name, coverage %, warning "results become visible; <strong>cannot be un-published</strong> (no endpoint)"</td></tr> <tr><td>Commit</td><td><code>C-AppButton</code> "Publish" (destructive-affirmative), dismissible "Cancel"</td></tr> <tr><td>Result</td><td>2xx → banner "Results published"; failure 404 (exam missing) / 429 / 5xx → <code>C-AppErrorState</code> in dialog</td></tr> </tbody></table> </div> <h2 id="f3--form-validation-summary-envelope-codes"><a class="header" href="#f3--form-validation-summary-envelope-codes">F3 — Form validation summary (envelope codes)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Condition</th><th>Client</th><th>Server</th></tr></thead><tbody> <tr><td>marks > maximumMarks</td><td>inline error, no submit</td><td>404 <code>RESOURCE_NOT_FOUND</code> (<code>examination.service.ts:145-146</code>)</td></tr> <tr><td>negative marks</td><td><code>Min(0)</code> inline</td><td>400 <code>VALIDATION_ERROR</code></td></tr> <tr><td>malformed id</td><td>—</td><td>400 <code>VALIDATION_ERROR</code></td></tr> <tr><td>wrong tenant / deleted subject</td><td>—</td><td>404 <code>RESOURCE_NOT_FOUND</code> (<code>examination.service.ts:143-144</code>, scoped repo)</td></tr> <tr><td>duplicate race</td><td>impossible by design (upsert)</td><td>unique index backstop (<code>examination-result.schema.ts:31-33</code>) → dup-key 5xx if raced, flagged <a href="results/14_QA_Checklist.html">14_QA_Checklist.md</a> §6</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-results-module"><a class="header" href="#09--user-behaviour-results-module">09 — User Behaviour (Results Module)</a></h1> <ul> <li><a href="results/09_User_Behaviour.html#b1--marks-entry-is-incremental-and-re-entrant">B1 — Marks entry is incremental and re-entrant</a></li> <li><a href="results/09_User_Behaviour.html#b2--client-side-guardrails-beat-server-round-trips">B2 — Client-side guardrails beat server round trips</a></li> <li><a href="results/09_User_Behaviour.html#b3--missing-marks-read-as-zero-on-the-report-card">B3 — Missing marks read as zero on the report card</a></li> <li><a href="results/09_User_Behaviour.html#b4--publish-is-a-point-of-no-return">B4 — Publish is a point of no return</a></li> <li><a href="results/09_User_Behaviour.html#b5--publication-informs-asynchronously">B5 — Publication informs, asynchronously</a></li> <li><a href="results/09_User_Behaviour.html#b6--the-results-list-is-a-flat-feed">B6 — The results list is a flat feed</a></li> <li><a href="results/09_User_Behaviour.html#b7--filters-are-local-until-the-server-supports-them">B7 — Filters are local until the server supports them</a></li> <li><a href="results/09_User_Behaviour.html#b8--error-recovery-is-code-driven-not-message-driven">B8 — Error recovery is code-driven, not message-driven</a></li> <li><a href="results/09_User_Behaviour.html#b9--offline-first-grading">B9 — Offline-first grading</a></li> <li><a href="results/09_User_Behaviour.html#b10--behavioural-metrics-client-instrumentation-proposed">B10 — Behavioural metrics (client instrumentation <code>(proposed)</code>)</a></li> </ul> <blockquote> <p>Behavioural requirements derived from server semantics. Each behaviour cites the code fact that justifies it. Reference <a href="results/../00-shared/08_Interaction_&_Motion.html">00-shared/08_Interaction_&_Motion.md</a> for motion defaults and <a href="results/../00-shared/09_Accessibility_Baseline.html">00-shared/09_Accessibility_Baseline.md</a> for a11y obligations.</p> </blockquote> <hr /> <h2 id="b1--marks-entry-is-incremental-and-re-entrant"><a class="header" href="#b1--marks-entry-is-incremental-and-re-entrant">B1 — Marks entry is incremental and re-entrant</a></h2> <ul> <li>Teachers type marks row-by-row; each committed row is an independent upsert (<code>examination.service.ts:147-175</code>). The UI must not block other rows on one failing save (per-row saving state, S1 §3).</li> <li><strong>Re-entry must be frictionless</strong>: the same row can be re-submitted any number of times (update path <code>:152-161</code>). No "already entered" blocking.</li> </ul> <h2 id="b2--client-side-guardrails-beat-server-round-trips"><a class="header" href="#b2--client-side-guardrails-beat-server-round-trips">B2 — Client-side guardrails beat server round trips</a></h2> <ul> <li>Over-max and negative values are caught client-side (<a href="results/08_Form_Specifications.html">08_Form_Specifications.md</a> F1.2) because the server's rejection (404, <code>examination.service.ts:145-146</code>) is semantically wrong for the user. Users expect "try again", not "not found".</li> </ul> <h2 id="b3--missing-marks-read-as-zero-on-the-report-card"><a class="header" href="#b3--missing-marks-read-as-zero-on-the-report-card">B3 — Missing marks read as zero on the report card</a></h2> <ul> <li><code>result.service.ts:70</code> fills <code>marksObtained: 0</code> and totals still include the subject maximum (<code>:72</code>). Behaviour rule: <strong>never silently show a plausible zero</strong> — 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.</li> </ul> <h2 id="b4--publish-is-a-point-of-no-return"><a class="header" href="#b4--publish-is-a-point-of-no-return">B4 — Publish is a point of no return</a></h2> <ul> <li><code>publishResults</code> (<code>examination.service.ts:203-220</code>) is the only visibility switch; <strong>no unpublish endpoint exists</strong>. The confirm dialog must state this. After publish, S5 disables the action; S1 rows show locked-but-editable state (server still accepts writes — <code>(planned)</code> lock).</li> </ul> <h2 id="b5--publication-informs-asynchronously"><a class="header" href="#b5--publication-informs-asynchronously">B5 — Publication informs, asynchronously</a></h2> <ul> <li>The visible effect of publish is the <code>ExamResultsPublished</code> event → <code>in-app</code> queue → <code>results-published</code> job (<code>event-queue-map.ts:27</code>). Users should be told "notification queued", not "notification sent" — delivery is async.</li> </ul> <h2 id="b6--the-results-list-is-a-flat-feed"><a class="header" href="#b6--the-results-list-is-a-flat-feed">B6 — The results list is a flat feed</a></h2> <ul> <li><code>GET /api/v1/results/student/:studentId</code> returns flat <code>examination_results</code> rows (<code>examination-result.repository.ts:26-28</code>). Users expect exam-grouped results; the client groups (S3) and must tolerate rows whose exam lookup 404s (exam soft-deleted — <code>examination.repository.ts</code> scoped soft-delete) by dropping the orphan group silently.</li> </ul> <h2 id="b7--filters-are-local-until-the-server-supports-them"><a class="header" href="#b7--filters-are-local-until-the-server-supports-them">B7 — Filters are local until the server supports them</a></h2> <ul> <li>Filter chips on S3/S5 (published-only, grade, class) are client-side. A user filtering "published only" must not assume server-side security — <code>publishedAt</code> is never filtered on read paths (<code>examination-result.repository.ts:20-40</code>, <code>(planned)</code>).</li> </ul> <h2 id="b8--error-recovery-is-code-driven-not-message-driven"><a class="header" href="#b8--error-recovery-is-code-driven-not-message-driven">B8 — Error recovery is code-driven, not message-driven</a></h2> <ul> <li>Envelope codes (<a href="results/07_Component_Library.html">07_API_Conventions.md</a> §3) drive copy: <code>VALIDATION_ERROR</code> → inline field errors; <code>RESOURCE_NOT_FOUND</code> → keep input + explain (404 quirk §B2); <code>RATE_LIMITED</code> → backoff + retry after window; 5xx → generic + <code>requestId</code>.</li> <li>Marks save failures never discard the row edit — dirty state persists (S1 §3).</li> </ul> <h2 id="b9--offline-first-grading"><a class="header" href="#b9--offline-first-grading">B9 — Offline-first grading</a></h2> <ul> <li>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.</li> </ul> <h2 id="b10--behavioural-metrics-client-instrumentation-proposed"><a class="header" href="#b10--behavioural-metrics-client-instrumentation-proposed">B10 — Behavioural metrics (client instrumentation <code>(proposed)</code>)</a></h2> <ul> <li>Save attempts per row (convergence ≤ 2, per <a href="results/01_Product_Overview.html">01_Product_Overview.md</a> §5), rejection rate (404 quirk encounters), publish-to-view latency, offline-draft replay failures.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-results-module"><a class="header" href="#10--interaction-specification-results-module">10 — Interaction Specification (Results Module)</a></h1> <ul> <li><a href="results/10_Interaction_Specification.html#i1--marks-entry-grid-s1">I1 — Marks Entry Grid (S1)</a> <ul> <li><a href="results/10_Interaction_Specification.html#i11-keyboard--focus">I1.1 Keyboard & focus</a></li> <li><a href="results/10_Interaction_Specification.html#i12-save-choreography">I1.2 Save choreography</a></li> <li><a href="results/10_Interaction_Specification.html#i13-offline">I1.3 Offline</a></li> <li><a href="results/10_Interaction_Specification.html#i14-published-rows">I1.4 Published rows</a></li> </ul> </li> <li><a href="results/10_Interaction_Specification.html#i2--exam-subject-detail-s2">I2 — Exam-Subject Detail (S2)</a></li> <li><a href="results/10_Interaction_Specification.html#i3--student-results-list-s3">I3 — Student Results List (S3)</a></li> <li><a href="results/10_Interaction_Specification.html#i4--report-card-s4">I4 — Report Card (S4)</a></li> <li><a href="results/10_Interaction_Specification.html#i5--grade-summary--publish-s5">I5 — Grade Summary & Publish (S5)</a></li> <li><a href="results/10_Interaction_Specification.html#i6--motion--feedback-summary">I6 — Motion & feedback summary</a></li> </ul> <blockquote> <p>Precise interactions per screen. Motion defaults per <a href="results/../00-shared/08_Interaction_&_Motion.html">00-shared/08_Interaction_&_Motion.md</a>; a11y obligations per <a href="results/../00-shared/09_Accessibility_Baseline.html">00-shared/09_Accessibility_Baseline.md</a>; components <code>C-*</code> per <a href="results/../00-shared/03_Component_Library.html">00-shared/03_Component_Library.md</a>, <code>R-*</code> per <a href="results/07_Component_Library.html">07_Component_Library.md</a>.</p> </blockquote> <hr /> <h2 id="i1--marks-entry-grid-s1"><a class="header" href="#i1--marks-entry-grid-s1">I1 — Marks Entry Grid (S1)</a></h2> <h3 id="i11-keyboard--focus"><a class="header" href="#i11-keyboard--focus">I1.1 Keyboard & focus</a></h3> <ul> <li>Tab order per row: <code>marksObtained</code> → <code>grade</code> → <code>remarks</code> → next row's <code>marksObtained</code>.</li> <li><strong>Enter</strong> commits the focused cell and saves the row (per-row upsert, <code>examination.service.ts:147-175</code>); <strong>Tab</strong> from <code>remarks</code> moves to next student's marks field (when grid layout) or commits + moves (card layout).</li> <li>Esc clears the row's dirty state back to last-saved value.</li> </ul> <h3 id="i12-save-choreography"><a class="header" href="#i12-save-choreography">I1.2 Save choreography</a></h3> <ul> <li>Row states: <code>clean → dirty → saving → saved | error</code> (S1 §3).</li> <li><code>saving</code>: inline <code>C-ProgressIndicator</code> on the row only — never a full-screen block (row independence).</li> <li><code>saved</code>: row turns clean; <code>C-AppSnackbar</code> "Saved — {student}" < 3 s; multiple saves coalesce into one snackbar.</li> <li><code>error</code>: field shows <code>errorText</code>; row stays dirty; snackbar ≥ 4 s with <code>requestId</code> for 5xx.</li> <li>"Save all pending": queues dirty rows in order; each still fires its own POST; failures leave that row dirty and continue others.</li> </ul> <h3 id="i13-offline"><a class="header" href="#i13-offline">I1.3 Offline</a></h3> <ul> <li>Connectivity lost mid-entry → <code>C-AppOfflineBanner</code>; current edit remains in the field; row enters the offline draft (15_Flutter §Offline).</li> <li>Reconnect → banner "n unsynced rows" with <strong>Sync</strong> action; replay in row order; per-row success/failure surfaced (failed rows stay dirty).</li> </ul> <h3 id="i14-published-rows"><a class="header" href="#i14-published-rows">I1.4 Published rows</a></h3> <ul> <li>Rows with server <code>publishedAt</code> (<code>examination-result.schema.ts:24-25</code>) render locked (fields disabled, "Published" chip). <strong>UI-only</strong> — server still accepts writes (<code>(planned)</code> lock).</li> </ul> <h2 id="i2--exam-subject-detail-s2"><a class="header" href="#i2--exam-subject-detail-s2">I2 — Exam-Subject Detail (S2)</a></h2> <ul> <li>Coverage progress animates width 300 ms on load (<a href="results/08_Form_Specifications.html">08_Interaction_&_Motion.md</a>); announces once via <code>SemanticsService</code> — no repeated announcements on rebuild.</li> <li>"Enter marks" → push S1; return preserves S1's dirty state (keep cubit alive in the navigator stack).</li> </ul> <h2 id="i3--student-results-list-s3"><a class="header" href="#i3--student-results-list-s3">I3 — Student Results List (S3)</a></h2> <ul> <li>Pull-to-refresh refetches rows and re-groups; cache is refreshed, not evicted first (renders from old + updates in place).</li> <li>Filter chips are instant, client-side, and <strong>sticky</strong> during session.</li> <li>Tapping a group header collapses/expands the group (default expanded).</li> </ul> <h2 id="i4--report-card-s4"><a class="header" href="#i4--report-card-s4">I4 — Report Card (S4)</a></h2> <ul> <li>Read-only: no edits, no pull-to-refresh; data is server-computed (<code>result.service.ts:46-98</code>).</li> <li>Share action (web share sheet) shares a text summary <code>(forward-looking)</code>; Print/PDF <code>(planned)</code> (<code>Results.md:57</code>).</li> <li>Stale display: if exam <code>status</code> flips to <code>published</code> while the card is open (in-app event <code>results-published</code>), refresh the card and show the "Published" chip without user action.</li> </ul> <h2 id="i5--grade-summary--publish-s5"><a class="header" href="#i5--grade-summary--publish-s5">I5 — Grade Summary & Publish (S5)</a></h2> <ul> <li>Aggregation runs N subject fetches; show skeleton bars; results stream in as each resolves (partial render).</li> <li>Publish button: disabled until coverage = 100 % (advisory); on tap → <code>C-AppDialog</code> confirm (B4 warning, no-revert copy) → <code>POST /api/v1/examinations/:id/publish</code> (<code>examination.controller.ts:54-56</code>) → on 2xx replace banner with "Results published on {date}".</li> <li>On 429 <code>RATE_LIMITED</code>: disable publish, show countdown of the retry window.</li> </ul> <h2 id="i6--motion--feedback-summary"><a class="header" href="#i6--motion--feedback-summary">I6 — Motion & feedback summary</a></h2> <div class="table-wrapper"><table><thead><tr><th>Action</th><th>Feedback</th><th>Duration</th></tr></thead><tbody> <tr><td>Row save start</td><td>inline row spinner (static under reduced motion)</td><td>until response</td></tr> <tr><td>Row saved</td><td>snackbar success</td><td>< 3 s</td></tr> <tr><td>Row rejected</td><td>inline error + snackbar (4 s)</td><td>≥ 4 s</td></tr> <tr><td>Publish success</td><td>banner + snackbar</td><td>banner persists</td></tr> <tr><td>Offline</td><td>banner (persists until synced)</td><td>persists</td></tr> <tr><td>Coverage change</td><td>progress bar 300 ms ease-out</td><td>300 ms</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-results-module"><a class="header" href="#11--design-system-mapping-results-module">11 — Design System Mapping (Results Module)</a></h1> <ul> <li><a href="results/11_Design_System_Mapping.html#1-grade-band--colour-role">1. Grade band → colour role</a></li> <li><a href="results/11_Design_System_Mapping.html#2-surface-tokens-by-screen">2. Surface tokens by screen</a></li> <li><a href="results/11_Design_System_Mapping.html#3-typography">3. Typography</a></li> <li><a href="results/11_Design_System_Mapping.html#4-spacing--radius--elevation">4. Spacing / radius / elevation</a></li> <li><a href="results/11_Design_System_Mapping.html#5-iconography">5. Iconography</a></li> <li><a href="results/11_Design_System_Mapping.html#6-breakpoints--layout-mapping">6. Breakpoints & layout mapping</a></li> <li><a href="results/11_Design_System_Mapping.html#7-darklight-parity-notes">7. Dark/light parity notes</a></li> </ul> <blockquote> <p>Maps every screen surface to <a href="results/02_User_Personas.html">02_Design_Tokens.md</a> (tokens <code>T-</code>) and shared components (<a href="results/03_User_Journey.html">03_Component_Library.md</a>). Dark/light parity per <a href="results/02_User_Personas.html">02_Design_Tokens.md</a> §10.</p> </blockquote> <hr /> <h2 id="1-grade-band--colour-role"><a class="header" href="#1-grade-band--colour-role">1. Grade band → colour role</a></h2> <p>Server bands (<code>result.service.ts:130-138</code>) map to semantic roles (light & dark both):</p> <div class="table-wrapper"><table><thead><tr><th>Grade</th><th>Band</th><th>Role (light)</th><th>Role (dark)</th><th>Text</th></tr></thead><tbody> <tr><td>A+</td><td>≥ 90</td><td><code>success</code> container</td><td><code>success</code> container</td><td><code>onSuccessContainer</code></td></tr> <tr><td>A</td><td>≥ 80</td><td><code>success</code> container</td><td><code>success</code> container</td><td><code>onSuccessContainer</code></td></tr> <tr><td>B+</td><td>≥ 70</td><td><code>primary</code> container</td><td><code>primary</code> container</td><td><code>onPrimaryContainer</code></td></tr> <tr><td>B</td><td>≥ 60</td><td><code>primary</code> container</td><td><code>primary</code> container</td><td><code>onPrimaryContainer</code></td></tr> <tr><td>C</td><td>≥ 50</td><td><code>tertiary</code> container</td><td><code>tertiary</code> container</td><td><code>onTertiaryContainer</code></td></tr> <tr><td>D</td><td>≥ 40</td><td><code>tertiary</code> container</td><td><code>tertiary</code> container</td><td><code>onTertiaryContainer</code></td></tr> <tr><td>F</td><td>< 40</td><td><code>error</code> container</td><td><code>error</code> container</td><td><code>onErrorContainer</code></td></tr> <tr><td>unknown string</td><td>stored verbatim</td><td><code>surfaceVariant</code></td><td><code>surfaceVariant</code></td><td><code>onSurfaceVariant</code></td></tr> </tbody></table> </div> <p>All chip text <code>labelMedium</code> (≥ 4.5:1, <a href="results/09_User_Behaviour.html">09_Accessibility_Baseline.md</a> §2).</p> <h2 id="2-surface-tokens-by-screen"><a class="header" href="#2-surface-tokens-by-screen">2. Surface tokens by screen</a></h2> <div class="table-wrapper"><table><thead><tr><th>Surface</th><th>Token (light / dark)</th><th>Used on</th></tr></thead><tbody> <tr><td>Page background</td><td><code>surface</code></td><td>all screens</td></tr> <tr><td>Card / hero</td><td><code>surfaceContainerLow</code></td><td>S2 hero, S4 card</td></tr> <tr><td>Table header</td><td><code>surfaceContainerHigh</code></td><td>S1-T, S4-T</td></tr> <tr><td>Table zebra</td><td><code>surface</code> / <code>surfaceContainerLow</code></td><td>S1-T rows</td></tr> <tr><td>Table gridlines</td><td><code>outlineVariant</code></td><td>S1-T, S4-T</td></tr> <tr><td>Field error fill</td><td><code>errorContainer</code></td><td>S1 over-max cells</td></tr> <tr><td>Empty/disabled</td><td><code>surfaceVariant</code></td><td>ungraded bucket, unknown grade</td></tr> </tbody></table> </div> <h2 id="3-typography"><a class="header" href="#3-typography">3. Typography</a></h2> <div class="table-wrapper"><table><thead><tr><th>Element</th><th>Type style (<a href="results/02_User_Personas.html">02_Design_Tokens.md</a> §2)</th></tr></thead><tbody> <tr><td>Screen title</td><td><code>headlineSmall</code></td></tr> <tr><td>S4 grade hero</td><td><code>displaySmall</code></td></tr> <tr><td>S4 totals</td><td><code>headlineMedium</code> <strong>mono</strong>, <code>tabularFigures()</code></td></tr> <tr><td>S1 table numerals</td><td><code>mono</code> 14/400/20, <code>tabularFigures()</code></td></tr> <tr><td>Labels</td><td><code>labelMedium</code> <code>onSurfaceVariant</code></td></tr> <tr><td>Missing-row text</td><td><code>bodyMedium</code> <code>onSurfaceVariant</code> italic</td></tr> <tr><td>Group headers (S3)</td><td><code>titleSmall</code></td></tr> </tbody></table> </div> <h2 id="4-spacing--radius--elevation"><a class="header" href="#4-spacing--radius--elevation">4. Spacing / radius / elevation</a></h2> <ul> <li>4 dp grid per <a href="results/02_User_Personas.html">02_Design_Tokens.md</a> §3: table cell padding 8 dp vertical / 12 dp horizontal; card padding 16 dp; screen margins 16 dp.</li> <li>Radius: cards & chips <code>radius-medium</code> (12), table cells 0 (straight grid), dialogs <code>radius-large</code>.</li> <li>Elevation: cards <code>elevation-1</code> (resting), publish panel <code>elevation-2</code>, save bar <code>elevation-2</code> (fixed bottom), dialog <code>elevation-3</code>.</li> <li>Touch targets ≥ 48×48 dp for all interactive cells (<a href="results/09_User_Behaviour.html">09_Accessibility_Baseline.md</a> §3).</li> </ul> <h2 id="5-iconography-1"><a class="header" href="#5-iconography-1">5. Iconography</a></h2> <div class="table-wrapper"><table><thead><tr><th>Icon</th><th>Where</th></tr></thead><tbody> <tr><td><code>edit</code></td><td>S1 rows, S2 "Enter marks"</td></tr> <tr><td><code>check_circle</code> / <code>schedule</code></td><td>saved / draft status chips</td></tr> <tr><td><code>lock</code></td><td>published-locked rows</td></tr> <tr><td><code>cloud_off</code></td><td>offline banner</td></tr> <tr><td><code>bar_chart</code></td><td>S5 distribution</td></tr> <tr><td><code>campaign</code></td><td>publish action</td></tr> <tr><td><code>download</code> / <code>share</code></td><td>S4 actions (<code>(planned)</code> PDF)</td></tr> </tbody></table> </div> <h2 id="6-breakpoints--layout-mapping"><a class="header" href="#6-breakpoints--layout-mapping">6. Breakpoints & layout mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Width</th><th>S1</th><th>S4</th><th>S5</th></tr></thead><tbody> <tr><td>< 600 px</td><td>cards layout, save bar fixed</td><td>stacked, table → labelled rows</td><td>bars stack vertical</td></tr> <tr><td>600–719 px</td><td>cards layout, save bar inline</td><td>two-column totals</td><td>stacked bars</td></tr> <tr><td>≥ 720 px</td><td>full table</td><td>two-column card + table</td><td>side-by-side chart grid</td></tr> </tbody></table> </div> <h2 id="7-darklight-parity-notes"><a class="header" href="#7-darklight-parity-notes">7. Dark/light parity notes</a></h2> <ul> <li>Identical structure both modes; only role pairs swap (<a href="results/02_User_Personas.html">02_Design_Tokens.md</a> §1).</li> <li>Gridlines <code>outlineVariant</code> differ per mode (#C4C7C5 light / #444746 dark) — never hardcode.</li> <li>Grade chips use the <strong>same</strong> role mapping in both modes (verified contrast per mode).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-results-module"><a class="header" href="#12--api-mapping-results-module">12 — API Mapping (Results Module)</a></h1> <ul> <li><a href="results/12_API_Mapping.html#e1--list-results-for-a-student-s3">E1 — List results for a student (S3)</a></li> <li><a href="results/12_API_Mapping.html#e2--list-results-for-an-exam-subject-s1-s5">E2 — List results for an exam-subject (S1, S5)</a></li> <li><a href="results/12_API_Mapping.html#e3--enter--update-marks-s1">E3 — Enter / update marks (S1)</a></li> <li><a href="results/12_API_Mapping.html#e4--report-card-s4">E4 — Report card (S4)</a></li> <li><a href="results/12_API_Mapping.html#e5--get-examination-s3-grouping-s5-header">E5 — Get examination (S3 grouping, S5 header)</a></li> <li><a href="results/12_API_Mapping.html#e6--list-subjects-of-an-exam-s1-header-s2-s5">E6 — List subjects of an exam (S1 header, S2, S5)</a></li> <li><a href="results/12_API_Mapping.html#e7--publish-exam-results-s5">E7 — Publish exam results (S5)</a></li> <li><a href="results/12_API_Mapping.html#e8--list-examinations-s1s5-entry-navigation--paginated">E8 — List examinations (S1/S5 entry navigation) — paginated</a></li> <li><a href="results/12_API_Mapping.html#e9--planned-bulk-marks-import">E9 — (planned) bulk marks import</a></li> <li><a href="results/12_API_Mapping.html#e10--proposed-analytics">E10 — (proposed) analytics</a></li> <li><a href="results/12_API_Mapping.html#e11--planned-rank">E11 — (planned) rank</a></li> <li><a href="results/12_API_Mapping.html#event-side-effect-map">Event side-effect map</a></li> </ul> <blockquote> <p>Exact endpoints per screen. Wire contract per <a href="results/../00-shared/07_API_Conventions.html">00-shared/07_API_Conventions.md</a>: base <code>/api/v1</code>, Bearer JWT, success <code>{success:true, message:"OK", data, meta?, timestamp, requestId}</code>, error <code>{success:false, message, error:{code, details?}, timestamp, requestId}</code>; codes <code>VALIDATION_ERROR(400)</code>, <code>UNAUTHENTICATED(401)</code>, <code>PERMISSION_DENIED(403)</code>, <code>RESOURCE_NOT_FOUND(404)</code>, <code>DUPLICATE_RESOURCE(409)</code>, <code>BUSINESS_RULE_VIOLATION(422)</code>, <code>RATE_LIMITED(429)</code>, <code>INTERNAL_SERVER_ERROR(5xx)</code>. Pagination <code>page</code>/<code>limit</code>(1-100, default 20)/<code>sort</code>(<code>-desc</code>)/<code>q</code>. Sources: <code>result.controller.ts</code>, <code>examination.controller.ts</code>, <code>examination.service.ts</code>, <code>result.service.ts</code>. Note: <strong>no endpoint is paginated in this module</strong> — all results routes return bare arrays; pagination exists only on the exams list.</p> </blockquote> <h2 id="e1--list-results-for-a-student-s3"><a class="header" href="#e1--list-results-for-a-student-s3">E1 — List results for a student (S3)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>GET /api/v1/results/student/:studentId</code> (<code>result.controller.ts:16-20</code>)</td></tr> <tr><td>Guard</td><td><code>JwtAuthGuard</code> only (<code>:10</code>) — no ownership/visibility check; RBAC <code>(planned)</code></td></tr> <tr><td>Response</td><td>200 envelope, <code>data</code> = flat <code>ExaminationResult[]</code> (<code>examination-result.repository.ts:26-28</code>) — no <code>meta</code></td></tr> <tr><td>Errors</td><td>400 (malformed id); 429 <code>RATE_LIMITED</code>; 5xx; cross-tenant/deleted → <code>[]</code> (scoped repo, not 404)</td></tr> <tr><td>Client</td><td>S3 rows; group by <code>examinationSubjectId</code> → E5</td></tr> </tbody></table> </div> <h2 id="e2--list-results-for-an-exam-subject-s1-s5"><a class="header" href="#e2--list-results-for-an-exam-subject-s1-s5">E2 — List results for an exam-subject (S1, S5)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>GET /api/v1/results/exam-subject/:examSubjectId</code> (<code>result.controller.ts:21-25</code>)</td></tr> <tr><td>Response</td><td>200 envelope, <code>data</code> = <code>ExaminationResult[]</code> (<code>examination-result.repository.ts:20-24</code>)</td></tr> <tr><td>Errors</td><td>400; 404 via repo scoping (subject not in tenant → <code>[]</code>); 429; 5xx</td></tr> <tr><td>Client</td><td>S1 table rows; S5 per-subject aggregation</td></tr> <tr><td>Notes</td><td>Includes rows with <code>publishedAt</code> — no filter param exists (<code>(planned)</code>)</td></tr> </tbody></table> </div> <h2 id="e3--enter--update-marks-s1"><a class="header" href="#e3--enter--update-marks-s1">E3 — Enter / update marks (S1)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>POST /api/v1/results/exam-subject/:examSubjectId/marks</code> (<code>result.controller.ts:26-31</code>)</td></tr> <tr><td>Guard</td><td><code>JwtAuthGuard</code>; intended <code>result.compute</code> <code>(planned)</code></td></tr> <tr><td>Body</td><td><code>EnterMarksDto</code> (<code>examination-subject.dto.ts:47-65</code>): <code>studentId</code> (MongoId, req), <code>marksObtained</code> (Number, Min 0, req), <code>grade?</code> (String), <code>remarks?</code> (String)</td></tr> <tr><td>Behaviour</td><td>idempotent upsert on <code>(studentId, examSubjectId)</code> (<code>examination.service.ts:147-175</code>); unknown subject → 404 (<code>:143-144</code>); <code>marksObtained > maximumMarks</code> → <strong>404</strong> <code>RESOURCE_NOT_FOUND</code> (<code>:145-146</code>, quirk — see <a href="results/14_QA_Checklist.html">14_QA_Checklist.md</a> §5)</td></tr> <tr><td>Response</td><td>2xx envelope, <code>data</code> = saved <code>ExaminationResult</code></td></tr> <tr><td>Errors</td><td>400 <code>VALIDATION_ERROR</code> (bad id, negative marks); 404 (subject missing / over-max); 429; 5xx (incl. dup-key race → 500, <code>examination-result.schema.ts:31-33</code>)</td></tr> <tr><td>Side effects</td><td><code>MarksEntered</code> event → <strong>no queue route</strong> in <code>event-queue-map.ts</code> (audit only, <code>examination.service.ts:163-173</code>)</td></tr> <tr><td>Client</td><td>S1 per-row save; replay-safe offline (idempotent)</td></tr> </tbody></table> </div> <h2 id="e4--report-card-s4"><a class="header" href="#e4--report-card-s4">E4 — Report card (S4)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>GET /api/v1/results/report-card/:studentId/:examId</code> (<code>result.controller.ts:32-37</code>)</td></tr> <tr><td>Response</td><td>200 envelope, <code>data</code> = <code>ReportCard</code> (<code>result.service.ts:8-26</code>): <code>studentId</code>, <code>examinationId</code>, <code>subjects[]</code> (<code>subjectId</code>, <code>subjectName</code>=subject ID string <code>:75</code>, <code>marksObtained</code> 0-filled <code>:70</code>, <code>maximumMarks</code>, <code>grade?</code>, <code>remarks?</code>), <code>totalMarksObtained</code>, <code>totalMaximumMarks</code>, <code>percentage</code> (2 dp <code>:83-86</code>), <code>overallGrade</code> (<code>computeGrade</code> bands <code>:130-138</code>), <code>generatedAt</code></td></tr> <tr><td>Errors</td><td>404 <code>No subjects found for this examination.</code> (<code>:51-52</code>); 400 malformed ids; 429; 5xx</td></tr> <tr><td>Client</td><td>S4; cache key <code>(studentId, examId)</code>; <code>subjectName</code> → local display-name map</td></tr> <tr><td>Notes</td><td><code>publishedAt</code> <strong>not</strong> checked — report card works pre-publish (<code>(planned)</code> visibility)</td></tr> </tbody></table> </div> <h2 id="e5--get-examination-s3-grouping-s5-header"><a class="header" href="#e5--get-examination-s3-grouping-s5-header">E5 — Get examination (S3 grouping, S5 header)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>GET /api/v1/examinations/:id</code> (<code>examination.controller.ts:33-35</code>)</td></tr> <tr><td>Response</td><td>200 envelope, <code>data</code> = <code>Examination</code> (<code>examination.schema.ts:8-36</code>): <code>academicYearId</code>, <code>name</code>, <code>type</code> (`midterm</td></tr> <tr><td>Errors</td><td>404 <code>Examination not found.</code> (<code>examination.service.ts:63</code>); 429; 5xx</td></tr> <tr><td>Client</td><td>S3 group headers (name + status chip); orphan-group drop when 404</td></tr> </tbody></table> </div> <h2 id="e6--list-subjects-of-an-exam-s1-header-s2-s5"><a class="header" href="#e6--list-subjects-of-an-exam-s1-header-s2-s5">E6 — List subjects of an exam (S1 header, S2, S5)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>GET /api/v1/examinations/:id/subjects</code> (<code>examination.controller.ts:51-53</code>)</td></tr> <tr><td>Response</td><td>200 envelope, <code>data</code> = <code>ExaminationSubject[]</code> (<code>examination-subject.schema.ts:8-31</code>): <code>examinationId</code>, <code>subjectId</code>, <code>classId</code>, <code>date</code>, <code>startTime</code>, <code>endTime</code>, <code>maximumMarks</code>, <code>passingMarks</code></td></tr> <tr><td>Errors</td><td>400; 429; 5xx (no subject → <code>[]</code>)</td></tr> <tr><td>Client</td><td>S1 header context; S2 single-row pick; S5 aggregation input</td></tr> </tbody></table> </div> <h2 id="e7--publish-exam-results-s5"><a class="header" href="#e7--publish-exam-results-s5">E7 — Publish exam results (S5)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>POST /api/v1/examinations/:id/publish</code> (<code>examination.controller.ts:54-56</code>)</td></tr> <tr><td>Behaviour</td><td>stamps <code>publishedAt</code> on all results of all subjects (<code>examination.service.ts:203-207</code>, <code>examination-result.repository.ts:42-51</code>), sets exam <code>status: 'published'</code> (<code>:209-211</code>), emits <code>ExamResultsPublished</code> (<code>:212-219</code>) → <code>in-app</code> queue / <code>results-published</code> job (<code>event-queue-map.ts:27</code>)</td></tr> <tr><td>Response</td><td>2xx envelope, <code>data</code> null; exam with zero subjects → still publishes (<code>subjectIds.length</code> guard, <code>:206</code>)</td></tr> <tr><td>Errors</td><td>404 (exam missing via <code>subjectRepo</code>/<code>examRepo</code>); 429; 5xx</td></tr> <tr><td>Notes</td><td><strong>No unpublish endpoint</strong>; repeat publish is idempotent (re-stamps <code>publishedAt</code>)</td></tr> <tr><td>Blueprint</td><td><code>Results.md:37</code> names event <code>ResultPublished</code> — <strong>code emits <code>ExamResultsPublished</code></strong> (naming mismatch, flag for backend alignment)</td></tr> </tbody></table> </div> <h2 id="e8--list-examinations-s1s5-entry-navigation--paginated"><a class="header" href="#e8--list-examinations-s1s5-entry-navigation--paginated">E8 — List examinations (S1/S5 entry navigation) — paginated</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>GET /api/v1/examinations?page&limit&sort&q</code> (<code>examination.controller.ts:30-32</code>)</td></tr> <tr><td>Params</td><td><code>page</code> ≥1 default 1; <code>limit</code> 1–100 default 20; <code>sort</code> (<code>-field</code> desc); <code>q</code> (<code>pagination-query.dto.ts:5-30</code>)</td></tr> <tr><td>Response</td><td>200 envelope, <code>data</code> + <code>meta {page,limit,totalItems,totalPages,hasNext,hasPrevious}</code> (<code>pagination-query.dto.ts:32-39</code>)</td></tr> <tr><td>Client</td><td>exam picker for S1/S5; infinite scroll while <code>hasNext</code></td></tr> </tbody></table> </div> <h2 id="e9--planned-bulk-marks-import"><a class="header" href="#e9--planned-bulk-marks-import">E9 — (planned) bulk marks import</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>POST /api/v1/examinations/:id/marks-import</code> — spreadsheet import, validation + rollback (<code>IMPLEMENTATION_PLAN.md:219</code>) <code>(planned)</code></td></tr> <tr><td>Client</td><td>S1 "Import" action; async job progress <code>(planned)</code></td></tr> </tbody></table> </div> <h2 id="e10--proposed-analytics"><a class="header" href="#e10--proposed-analytics">E10 — (proposed) analytics</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>GET /api/v1/results/analytics</code> (blueprint <code>Results.md:28</code>) — <strong>not implemented</strong> <code>(proposed)</code></td></tr> <tr><td>Client</td><td>S5 today = client-side aggregation of E2 + E6</td></tr> </tbody></table> </div> <h2 id="e11--planned-rank"><a class="header" href="#e11--planned-rank">E11 — (planned) rank</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Fact</td><td>Blueprint <code>Results.md:54</code> "Rank computed per class per exam"; <strong>no code exists</strong> <code>(planned)</code></td></tr> <tr><td>Client</td><td>S4 rank slot reserved; hidden until shipped</td></tr> </tbody></table> </div> <h2 id="event-side-effect-map"><a class="header" href="#event-side-effect-map">Event side-effect map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Queue / job</th><th>Source</th></tr></thead><tbody> <tr><td><code>MarksEntered</code></td><td>none routed (audit-write <code>(planned)</code>)</td><td><code>examination.service.ts:163-173</code></td></tr> <tr><td><code>ExaminationResultCreated</code></td><td>none routed (emitted by <code>ResultService.create</code>, <code>result.service.ts:114-126</code> — path unused by controller)</td><td><code>result.service.ts:114-126</code></td></tr> <tr><td><code>ExamResultsPublished</code></td><td><code>in-app</code> / <code>results-published</code></td><td><code>event-queue-map.ts:27</code></td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-results-module"><a class="header" href="#13--state-management-results-module">13 — State Management (Results Module)</a></h1> <ul> <li><a href="results/13_State_Management.html#1-cubit-map">1. Cubit map</a></li> <li><a href="results/13_State_Management.html#2-repository-layer">2. Repository layer</a></li> <li><a href="results/13_State_Management.html#3-marksentrycubit-s1--the-only-write-state-machine">3. MarksEntryCubit (S1) — the only write state machine</a></li> <li><a href="results/13_State_Management.html#4-studentresultscubit-s3">4. StudentResultsCubit (S3)</a></li> <li><a href="results/13_State_Management.html#5-reportcardcubit-s4">5. ReportCardCubit (S4)</a></li> <li><a href="results/13_State_Management.html#6-gradesummarycubit-s5">6. GradeSummaryCubit (S5)</a></li> <li><a href="results/13_State_Management.html#7-realtime">7. Realtime</a></li> <li><a href="results/13_State_Management.html#8-offline-queue-marks">8. Offline queue (marks)</a></li> </ul> <blockquote> <p>Cubits per <a href="results/../00-shared/06_State_Management.html">00-shared/06_State_Management.md</a> (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.</p> </blockquote> <hr /> <h2 id="1-cubit-map-5"><a class="header" href="#1-cubit-map-5">1. Cubit map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Cubit</th><th>Screen</th><th>Data source</th></tr></thead><tbody> <tr><td><code>ExamListCubit</code></td><td>exam picker (S1/S5 entry)</td><td><code>GET /api/v1/examinations</code> (paginated, E8)</td></tr> <tr><td><code>SubjectDetailCubit</code></td><td>S2</td><td><code>GET /api/v1/examinations/:id/subjects</code> (E6)</td></tr> <tr><td><code>MarksEntryCubit</code></td><td>S1</td><td><code>GET /api/v1/results/exam-subject/:id</code> (E2) + <code>POST …/marks</code> (E3)</td></tr> <tr><td><code>StudentResultsCubit</code></td><td>S3</td><td><code>GET /api/v1/results/student/:id</code> (E1) + <code>GET /api/v1/examinations/:id</code> (E5) per group</td></tr> <tr><td><code>ReportCardCubit</code></td><td>S4</td><td><code>GET /api/v1/results/report-card/:studentId/:examId</code> (E4)</td></tr> <tr><td><code>GradeSummaryCubit</code></td><td>S5</td><td>E6 + N×E2 (client aggregation) + <code>POST /examinations/:id/publish</code> (E7)</td></tr> </tbody></table> </div> <h2 id="2-repository-layer"><a class="header" href="#2-repository-layer">2. Repository layer</a></h2> <ul> <li><code>ResultsRepository</code> — wraps E1–E4; exposes <code>fetchStudentResults</code>, <code>fetchExamSubjectResults</code>, <code>enterMarks</code>, <code>fetchReportCard</code>.</li> <li><code>ExaminationsRepository</code> — wraps E5–E8.</li> <li>Cache keys (<a href="results/../00-shared/06_State_Management.html">00-shared/06 §3</a>): <code>sl:{tenant}:results:student:{studentId}</code>, <code>sl:{tenant}:results:subject:{examSubjectId}</code>, <code>sl:{tenant}:report-card:{studentId}:{examId}</code>, <code>sl:{tenant}:exams:{page}</code>.</li> <li>Volatile TTL 5 min; <strong>report card is the only hard-invalidate candidate</strong> — invalidate on <code>results-published</code> in-app event.</li> </ul> <h2 id="3-marksentrycubit-s1--the-only-write-state-machine"><a class="header" href="#3-marksentrycubit-s1--the-only-write-state-machine">3. MarksEntryCubit (S1) — the only write state machine</a></h2> <pre><code>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 </code></pre> <ul> <li><code>load(examSubjectId)</code> → E2; maps rows; sorts by student name client-side.</li> <li><code>commitRow(studentId, {marksObtained, grade, remarks})</code> → validates <code>≤ maximumMarks</code> locally, sets <code>dirty</code>, fires upsert; on success clears dirty; on 404-over-max keeps dirty + <code>MarksEntryRowError</code>.</li> <li><code>commitAll()</code> → replays dirty rows in order (each its own POST, E3).</li> <li><code>syncOfflineDraft()</code> → replays the local draft queue (15_Flutter §Offline).</li> <li><code>setPublished(rowId)</code> → locks row UI on in-app <code>results-published</code> event.</li> </ul> <pre class="mermaid">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 </pre> <h2 id="4-studentresultscubit-s3"><a class="header" href="#4-studentresultscubit-s3">4. StudentResultsCubit (S3)</a></h2> <pre><code>S3State: Loading | Loaded(groups) | Empty | Error(code) </code></pre> <ul> <li><code>load(studentId)</code> → E1; for each distinct <code>examinationSubjectId</code> → E5 (group); E5 404 → orphan row dropped (exam soft-deleted), rest grouped.</li> <li><code>setFilter(All | Published | Unpublished | grade)</code> — pure client filter, no refetch.</li> </ul> <h2 id="5-reportcardcubit-s4"><a class="header" href="#5-reportcardcubit-s4">5. ReportCardCubit (S4)</a></h2> <pre><code>S4State: Loading | Ready(reportCard) | NoSubjects | NotFound | Error(code) </code></pre> <ul> <li><code>load(studentId, examId)</code> → E4; maps <code>subjectName</code> (raw subject ID, <code>result.service.ts:75</code>) → display names via local subject registry.</li> <li>404 with message "No subjects found for this examination." (<code>result.service.ts:51-52</code>) → <code>NoSubjects</code> (guided empty state), other 404s → <code>NotFound</code>.</li> </ul> <h2 id="6-gradesummarycubit-s5"><a class="header" href="#6-gradesummarycubit-s5">6. GradeSummaryCubit (S5)</a></h2> <ul> <li><code>load(examId)</code> → E6, then N×E2 (parallel, capped concurrency 4); emits <code>Aggregating(progress)</code> per resolved subject → <code>Ready(summary)</code>.</li> <li><code>summary</code> = per-subject coverage + grade distribution (counts of stored <code>grade</code> strings; ungraded bucket) + pass/fail vs <code>passingMarks</code>.</li> <li><code>publish()</code> → E7; success → re-<code>load</code> + invalidate student result caches; <code>RateLimited(seconds)</code> state on 429.</li> </ul> <h2 id="7-realtime"><a class="header" href="#7-realtime">7. Realtime</a></h2> <ul> <li>In-app stream (<code>00-shared/06 §4</code>) consumes <code>results-published</code> (job name per <code>event-queue-map.ts:27</code>): MarksEntryCubit locks published rows, ReportCardCubit refreshes, GradeSummaryCubit disables publish.</li> <li>No per-keystroke network; local-only.</li> </ul> <h2 id="8-offline-queue-marks"><a class="header" href="#8-offline-queue-marks">8. Offline queue (marks)</a></h2> <ul> <li>Draft table keyed <code>(examSubjectId, studentId)</code>; rows carry full <code>EnterMarksDto</code> payload + monotonic sequence; replay on reconnect in sequence order; on per-row failure keep row + surface error (B8).</li> <li>Queue is <strong>not</strong> a queue manager — plain ordered list, replayed by <code>syncOfflineDraft()</code> (ponytail: no queue package).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-results-module"><a class="header" href="#14--qa-checklist-results-module">14 — QA Checklist (Results Module)</a></h1> <ul> <li><a href="results/14_QA_Checklist.html#1-envelope--contract-conformance">1. Envelope & contract conformance</a></li> <li><a href="results/14_QA_Checklist.html#2-publish-behaviour--immutability">2. Publish behaviour & immutability</a></li> <li><a href="results/14_QA_Checklist.html#3-missing-marks-handling">3. Missing-marks handling</a></li> <li><a href="results/14_QA_Checklist.html#4-grade-bands-exact-boundaries">4. Grade bands (exact boundaries)</a></li> <li><a href="results/14_QA_Checklist.html#5-marks-entry-validation-404-vs-422-quirk">5. Marks entry validation (404-vs-422 quirk)</a></li> <li><a href="results/14_QA_Checklist.html#6-concurrency--idempotency">6. Concurrency & idempotency</a></li> <li><a href="results/14_QA_Checklist.html#7-data-quirks-client-workarounds">7. Data quirks (client workarounds)</a></li> <li><a href="results/14_QA_Checklist.html#8-accessibility--responsive">8. Accessibility & responsive</a></li> <li><a href="results/14_QA_Checklist.html#9-performance">9. Performance</a></li> <li><a href="results/14_QA_Checklist.html#10-regression-baseline">10. Regression baseline</a></li> </ul> <blockquote> <p>Acceptance criteria against real server behaviour. Every item cites the code it guards. Reference <a href="results/../00-shared/10_QA_Baseline.html">00-shared/10_QA_Baseline.md</a> for general baseline + envelope conformance.</p> </blockquote> <hr /> <h2 id="1-envelope--contract-conformance"><a class="header" href="#1-envelope--contract-conformance">1. Envelope & contract conformance</a></h2> <ul> <li><input disabled="" type="checkbox"/> Every result endpoint returns the v1 envelope (<a href="results/../00-shared/07_API_Conventions.html">00-shared/07 §2-3</a>): success <code>{success,message:"OK",data,meta?,timestamp,requestId}</code>, error with <code>error.code</code> from the allowed set.</li> <li><input disabled="" type="checkbox"/> <code>GET /api/v1/results/student/:id</code> and <code>exam-subject/:id</code> return <strong>arrays in <code>data</code> with no <code>meta</code></strong> — pagination does not exist on result routes (<code>examination-result.repository.ts:20-28</code>); the client must not page.</li> <li><input disabled="" type="checkbox"/> 401 without a Bearer token on <strong>all</strong> result + exam routes (<code>JwtAuthGuard</code>, <code>result.controller.ts:10</code>).</li> </ul> <h2 id="2-publish-behaviour--immutability"><a class="header" href="#2-publish-behaviour--immutability">2. Publish behaviour & immutability</a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>POST /api/v1/examinations/:id/publish</code> stamps <code>publishedAt</code> on <strong>every</strong> result row of every subject (<code>examination.service.ts:203-207</code>) and sets exam <code>status: 'published'</code> (<code>:209-211</code>).</li> <li><input disabled="" type="checkbox"/> Publish with <strong>zero subjects</strong> still succeeds (<code>subjectIds.length</code> guard, <code>:206</code>) — UI must handle "published with no subjects".</li> <li><input disabled="" type="checkbox"/> <strong>Repeat publish is idempotent</strong> — re-stamping <code>publishedAt</code> must not error or duplicate rows (<code>examination-result.repository.ts:42-51</code>).</li> <li><input disabled="" type="checkbox"/> <strong>No unpublish endpoint exists</strong> — QA confirms client never offers revert.</li> <li><input disabled="" type="checkbox"/> <code>(planned)</code> immutability: after publish, <code>enterMarks</code> <strong>still updates rows</strong> (<code>examination.service.ts:139-195</code> has no <code>publishedAt</code> check). Client lock is UI-only; flag as known gap, revisit when server enforces.</li> <li><input disabled="" type="checkbox"/> <code>ExamResultsPublished</code> fires exactly once per publish (<code>:212-219</code>) → <code>in-app</code>/<code>results-published</code> (<code>event-queue-map.ts:27</code>); in-app job is idempotent (worker test).</li> </ul> <h2 id="3-missing-marks-handling"><a class="header" href="#3-missing-marks-handling">3. Missing-marks handling</a></h2> <ul> <li><input disabled="" type="checkbox"/> Report card with a student missing some subjects: <code>marksObtained: 0</code> for missing rows (<code>result.service.ts:70</code>), totals still include that subject's maximum (<code>:72</code>), <code>percentage</code> and <code>overallGrade</code> computed on the 0-fill (<code>:83-95</code>).</li> <li><input disabled="" type="checkbox"/> <strong>Data limitation</strong>: a real "0" and "not entered" are indistinguishable (<code>result.service.ts:70</code>) — UI distinguishes via presence of a row in E1/E2; QA documents this in release notes.</li> <li><input disabled="" type="checkbox"/> Student with <strong>zero</strong> result rows: E1 returns <code>[]</code> (no error); E4 returns 404 only when the <em>exam</em> has no subjects (<code>result.service.ts:51-52</code>).</li> </ul> <h2 id="4-grade-bands-exact-boundaries"><a class="header" href="#4-grade-bands-exact-boundaries">4. Grade bands (exact boundaries)</a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>computeGrade</code> boundaries verified against <code>result.service.ts:130-138</code>: <code>90→A+</code>, <code>89.99→A</code>, <code>80→A</code>, <code>79.99→B+</code>, <code>70→B+</code>, <code>69.99→B</code>, <code>60→B</code>, <code>59.99→C</code>, <code>50→C</code>, <code>49.99→D</code>, <code>40→D</code>, <code>39.99→F</code>, <code>0→F</code>.</li> <li><input disabled="" type="checkbox"/> Percentage is rounded to <strong>2 decimals</strong> (<code>result.service.ts:83-86</code>) before banding — test 89.995% (rounds to 90 → A+).</li> <li><input disabled="" type="checkbox"/> Per-subject <code>grade</code> is <strong>client-supplied and stored verbatim</strong> (<code>examination-subject.dto.ts:57-60</code>, <code>examination.service.ts:157</code>) — server does not compute per-subject grades; QA asserts UI never claims server-computed per-subject grades.</li> </ul> <h2 id="5-marks-entry-validation-404-vs-422-quirk"><a class="header" href="#5-marks-entry-validation-404-vs-422-quirk">5. Marks entry validation (404-vs-422 quirk)</a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>marksObtained > maximumMarks</code> → server returns <strong>404 <code>RESOURCE_NOT_FOUND</code></strong> (<code>examination.service.ts:145-146</code>), not 422 <code>BUSINESS_RULE_VIOLATION</code>. Client normalises to "marks cannot exceed maximum" (08/09); QA asserts the user-facing message is correct despite the misleading code.</li> <li><input disabled="" type="checkbox"/> Negative marks → 400 <code>VALIDATION_ERROR</code> (<code>Min(0)</code>, <code>examination-subject.dto.ts:54</code>).</li> <li><input disabled="" type="checkbox"/> Unknown <code>examSubjectId</code> → 404 (<code>examination.service.ts:143-144</code>).</li> <li><input disabled="" type="checkbox"/> Malformed Mongo id → 400.</li> </ul> <h2 id="6-concurrency--idempotency"><a class="header" href="#6-concurrency--idempotency">6. Concurrency & idempotency</a></h2> <ul> <li><input disabled="" type="checkbox"/> Two clients upsert the <strong>same</strong> <code>(studentId, examinationSubjectId)</code> — service-level check + unique index <code>{tenantId, studentId, examinationSubjectId}</code> (<code>examination-result.schema.ts:31-33</code>) → last-write-wins; <strong>no <code>DUPLICATE_RESOURCE</code> (409) is expected</strong> on sequential writes.</li> <li><input disabled="" type="checkbox"/> 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 <code>(planned)</code>: catch 11000 → read existing.</li> <li><input disabled="" type="checkbox"/> Re-submitting the same payload (offline replay) → identical end state, one row.</li> <li><input disabled="" type="checkbox"/> Cross-tenant id on any path → 404/<code>[]</code> (scoped repo), never another tenant's data.</li> </ul> <h2 id="7-data-quirks-client-workarounds"><a class="header" href="#7-data-quirks-client-workarounds">7. Data quirks (client workarounds)</a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>subjectName</code> = raw subject ID (<code>result.service.ts:75</code>) — UI shows mapped names; QA checks no raw IDs leak to screens.</li> <li><input disabled="" type="checkbox"/> S3 orphan rows (exam soft-deleted → E5 404) are dropped without crash.</li> <li><input disabled="" type="checkbox"/> <code>createdAt</code>/<code>updatedAt</code>/<code>version</code> present on result rows (<code>base.schema.ts</code>); UI may use <code>updatedAt</code> for "last saved" display.</li> </ul> <h2 id="8-accessibility--responsive"><a class="header" href="#8-accessibility--responsive">8. Accessibility & responsive</a></h2> <ul> <li><input disabled="" type="checkbox"/> Per-screen a11y checklist (<a href="results/06_Screen_Specifications.html">06_Screen_Specifications.md</a> §8 each): table semantics, grade announced as letters ("A plus"), contrast ≥ 4.5:1 on chips (<a href="results/09_User_Behaviour.html">09_Accessibility_Baseline.md</a> §2), charts have text equivalents.</li> <li><input disabled="" type="checkbox"/> Responsive breakpoints per <a href="results/11_Design_System_Mapping.html">11_Design_System_Mapping.md</a> §6: <600 / 600-719 / ≥720; save bar safe-area on portrait phones.</li> </ul> <h2 id="9-performance-1"><a class="header" href="#9-performance-1">9. Performance</a></h2> <ul> <li><input disabled="" type="checkbox"/> S1 grid stays interactive at 200+ students (virtualised — 15_Flutter §Table).</li> <li><input disabled="" type="checkbox"/> S5 aggregation of N subjects: ≤ 4 parallel fetches, skeleton progress (13 §6).</li> <li><input disabled="" type="checkbox"/> Offline draft replay: 100 rows → ordered, per-row success/failure, no deadlock on partial failure.</li> </ul> <h2 id="10-regression-baseline"><a class="header" href="#10-regression-baseline">10. Regression baseline</a></h2> <ul> <li><input disabled="" type="checkbox"/> All reads unchanged after publish (publishedAt stamp must not alter E1/E2/E4 shapes).</li> <li><input disabled="" type="checkbox"/> Soft-deleted exam subject → E2 <code>[]</code>, marks POST 404, no crash.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-results-module"><a class="header" href="#15--flutter-implementation-guide-results-module">15 — Flutter Implementation Guide (Results Module)</a></h1> <ul> <li><a href="results/15_Flutter_Implementation_Guide.html#1-marks-entry-grid-performance-s1">1. Marks-entry grid performance (S1)</a></li> <li><a href="results/15_Flutter_Implementation_Guide.html#2-offline-marks-draft">2. Offline marks draft</a></li> <li><a href="results/15_Flutter_Implementation_Guide.html#3-report-card-rendering--cache-s4">3. Report card rendering & cache (S4)</a></li> <li><a href="results/15_Flutter_Implementation_Guide.html#4-grade-summary-charts-s5">4. Grade summary charts (S5)</a></li> <li><a href="results/15_Flutter_Implementation_Guide.html#5-networking--error-mapping">5. Networking & error mapping</a></li> <li><a href="results/15_Flutter_Implementation_Guide.html#6-realtime">6. Realtime</a></li> <li><a href="results/15_Flutter_Implementation_Guide.html#7-suggested-file-layout">7. Suggested file layout</a></li> <li><a href="results/15_Flutter_Implementation_Guide.html#8-goldenunit-test-hooks">8. Golden/unit test hooks</a></li> </ul> <blockquote> <p>Client implementation notes against the v1 API. Stack per <a href="results/../00-shared/11_Flutter_App_Architecture.html">00-shared/11_Flutter_App_Architecture.md</a>; state per <a href="results/13_State_Management.html">13_State_Management.md</a>; components per <a href="results/07_Component_Library.html">07_Component_Library.md</a>. <code>(forward-looking)</code> — mobile is out of Phase 1 (see <a href="results/01_Product_Overview.html">01_Product_Overview.md</a>).</p> </blockquote> <hr /> <h2 id="1-marks-entry-grid-performance-s1"><a class="header" href="#1-marks-entry-grid-performance-s1">1. Marks-entry grid performance (S1)</a></h2> <ul> <li><strong>Never use stock <code>DataTable</code></strong> for the entry grid: it builds every cell eagerly. Use a virtualised list: <ul> <li><code>ListView.builder</code> with <strong>fixed row extent</strong> (<code>itemExtent</code> or <code>prototypeItem</code>) so off-screen rows are not built.</li> <li>Keep row height constant; varying remarks text wraps inside a fixed two-line box (ellipsis) — never <code>IntrinsicHeight</code>.</li> </ul> </li> <li><strong>Only visible rows own <code>TextEditingController</code>s</strong> — create controllers in <code>itemBuilder</code> and dispose on scroll-out; a 300-student grid otherwise leaks controllers and rebuilds everything per keystroke.</li> <li><strong>Local edit buffer</strong>: <code>TextEditingController.text</code> is the only mutable state (ponytail: no per-cell form model). Commit on Enter/onBlur via <code>MarksEntryCubit.commitRow(...)</code> (13 §3); the cubit owns dirty/saving state.</li> <li>Numeric input: <code>FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$'))</code> (decimal allowed — <code>IsNumber</code> has no integer constraint, <code>examination-subject.dto.ts:53</code>), <code>FontFeature.tabularFigures()</code>, <code>mono</code> type (11 §3).</li> <li>Client validation mirrors the server rule before any POST: <code>marksObtained > maximumMarks → inline error, no request</code> (<code>examination.service.ts:145-146</code>).</li> <li>Debounce <em>nothing</em>; commit is user-initiated (Enter/blur), so per-row saving state stays sparse. Batch saves (<code>commitAll</code>) fire sequentially, one in flight per row, others queued — no <code>Future.wait</code> on the whole grid (rows must stay independent).</li> </ul> <h2 id="2-offline-marks-draft"><a class="header" href="#2-offline-marks-draft">2. Offline marks draft</a></h2> <ul> <li>Local store: <code>drift</code>/<code>sqflite</code> table <code>marks_draft(exam_subject_id, student_id, marks_obtained, grade, remarks, seq INTEGER PRIMARY KEY AUTOINCREMENT)</code> — one row per unsynced edit, <code>seq</code> preserves entry order (13 §8).</li> <li><strong>Upsert semantics make replay safe</strong>: re-POSTing the same <code>EnterMarksDto</code> is an update, never a duplicate (<code>examination.service.ts:147-175</code>). No dedupe keys needed.</li> <li>Replay: on connectivity restore, drain in <code>seq</code> order via <code>MarksEntryCubit.syncOfflineDraft()</code>; per-row failure → keep the row, surface error, continue (09 B8). Row is deleted from the draft <strong>only</strong> on 2xx.</li> <li>Before overwriting a draft row, merge by <code>updatedAt</code> from 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, <code>examination.service.ts:152-161</code>).</li> <li>Conflict policy is deliberately naive (no three-way merge): <code>(planned)</code> revisit when multi-device grading lands.</li> </ul> <h2 id="3-report-card-rendering--cache-s4"><a class="header" href="#3-report-card-rendering--cache-s4">3. Report card rendering & cache (S4)</a></h2> <ul> <li>Cache <code>GET /api/v1/results/report-card/:studentId/:examId</code> keyed <code>(tenant, studentId, examId)</code> TTL 5 min (13 §2); invalidate on <code>results-published</code> in-app event so a published exam's card refreshes immediately (10 I4).</li> <li>Render server-computed values <strong>verbatim</strong> — <code>percentage</code>, <code>overallGrade</code>, <code>generatedAt</code> come from <code>result.service.ts:83-96</code>; the client never recomputes (single source of truth).</li> <li><code>subjectName</code> is the raw subject ID (<code>result.service.ts:75</code>) — resolve via a local subject registry (name map from <code>GET /api/v1/subjects</code>, <code>(planned)</code>-friendly: graceful fallback shows the ID if the registry misses).</li> <li>404 with message "No subjects found for this examination." (<code>result.service.ts:51-52</code>) → <code>NoSubjects</code> guided empty state, not an error screen.</li> </ul> <h2 id="4-grade-summary-charts-s5"><a class="header" href="#4-grade-summary-charts-s5">4. Grade summary charts (S5)</a></h2> <ul> <li><strong>No chart package.</strong> <code>R-GradeDistributionChart</code> = <code>Column</code> of <code>Row(children: [label, Expanded(FractionallySizedBox(widthFactor: n/total, child: container)), countText])</code> — a dozen lines, semantic labels free.</li> <li>Counts come from stored <code>grade</code> strings (client-supplied; <code>R-GradeChip</code> colour mapping per <a href="results/11_Design_System_Mapping.html">11 §1</a>); ungraded bucket = rows without <code>grade</code>.</li> <li>Always render the numeric counts as text next to bars (a11y, <a href="results/06_Screen_Specifications.html">06 §S5</a>).</li> </ul> <h2 id="5-networking--error-mapping"><a class="header" href="#5-networking--error-mapping">5. Networking & error mapping</a></h2> <ul> <li>Single <code>ApiClient</code> wrapper per <a href="results/../00-shared/07_API_Conventions.html">00-shared/07</a> + <a href="results/11_Design_System_Mapping.html">11_Flutter_App_Architecture.md</a>.</li> <li>Map envelope codes → copy:</li> </ul> <div class="table-wrapper"><table><thead><tr><th>Code</th><th>UI</th></tr></thead><tbody> <tr><td>400 <code>VALIDATION_ERROR</code></td><td>inline field errors</td></tr> <tr><td>401 <code>UNAUTHENTICATED</code></td><td>force re-login</td></tr> <tr><td>403 <code>PERMISSION_DENIED</code></td><td>error screen (today unreachable — no RBAC on routes)</td></tr> <tr><td>404 <code>RESOURCE_NOT_FOUND</code></td><td>over-max normalisation OR empty/not-found states (distinguish by route)</td></tr> <tr><td>429 <code>RATE_LIMITED</code></td><td>backoff + countdown (S5 publish)</td></tr> <tr><td>5xx</td><td>generic + <code>requestId</code> in snackbar (≥ 4 s)</td></tr> </tbody></table> </div> <ul> <li>Timeout: marks POST 15 s; reads 10 s. Retry policy: reads ×2 exponential; <strong>writes: no auto-retry</strong> — user-invoked or offline-draft replay only.</li> </ul> <h2 id="6-realtime-3"><a class="header" href="#6-realtime-3">6. Realtime</a></h2> <ul> <li><code>results-published</code> in-app event (<code>event-queue-map.ts:27</code>) → <code>SocketListener</code> routes to <code>ReportCardCubit.refresh()</code> and <code>MarksEntryCubit.setPublished(rowId)</code> (10 I4). No other realtime surface exists for this module.</li> </ul> <h2 id="7-suggested-file-layout"><a class="header" href="#7-suggested-file-layout">7. Suggested file layout</a></h2> <pre><code>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 </code></pre> <h2 id="8-goldenunit-test-hooks"><a class="header" href="#8-goldenunit-test-hooks">8. Golden/unit test hooks</a></h2> <ul> <li><code>computeGrade</code> band edges are <strong>server-side</strong> — unit-test the client <em>display</em> mapping (11 §1) at <code>90/89.99/80/…</code> against <code>result.service.ts:130-138</code> (14 §4).</li> <li><code>MarksEntryCubit</code> states via <code>bloc_test</code> (13 §3 state machine).</li> <li><code>DraftReplayer</code>: offline queue order + partial-failure continuation.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-rooms-module"><a class="header" href="#01--product-overview-rooms-module">01 — Product Overview (Rooms Module)</a></h1> <ul> <li><a href="rooms/01_Product_Overview.html#1-purpose">1. Purpose</a></li> <li><a href="rooms/01_Product_Overview.html#2-business-goals">2. Business goals</a></li> <li><a href="rooms/01_Product_Overview.html#3-user-goals">3. User goals</a></li> <li><a href="rooms/01_Product_Overview.html#4-stakeholders">4. Stakeholders</a></li> <li><a href="rooms/01_Product_Overview.html#5-why-this-exists">5. Why this exists</a></li> <li><a href="rooms/01_Product_Overview.html#6-dependencies-module--platform">6. Dependencies (module + platform)</a></li> <li><a href="rooms/01_Product_Overview.html#7-success-metrics">7. Success metrics</a></li> <li><a href="rooms/01_Product_Overview.html#8-edge-cases">8. Edge cases</a></li> <li><a href="rooms/01_Product_Overview.html#9-assumptions">9. Assumptions</a></li> <li><a href="rooms/01_Product_Overview.html#10-assumptions--open-questions">10. Assumptions & Open Questions</a></li> <li><a href="rooms/01_Product_Overview.html#11-glossary">11. Glossary</a></li> </ul> <blockquote> <p>StudyLyon — multi-tenant ERP / School Management API. This package designs the <strong>Rooms module client</strong> (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, and wire contracts are derived directly from <code>src/modules/rooms/**</code>, <code>src/modules/rbac/permissions.constants.ts</code>, <code>src/database/base.schema.ts</code> + <code>base.repository.ts</code>, and <code>src/common/dto/pagination-query.dto.ts</code>. No feature is invented; gaps are flagged in §10 and the module Assumptions & Open Questions.</p> </blockquote> <hr /> <h2 id="1-purpose-21"><a class="header" href="#1-purpose-21">1. Purpose</a></h2> <p>Rooms is the <strong>physical-facility registry</strong> 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 <code>rooms</code>.</p> <div class="table-wrapper"><table><thead><tr><th>Responsibility</th><th>Source</th></tr></thead><tbody> <tr><td>Room CRUD (name, code, capacity, type, building, facilities)</td><td><code>rooms.controller.ts:24-52</code>; <code>rooms.service.ts:18-51</code></td></tr> <tr><td>Tenant-scoped storage + soft-delete filtering</td><td><code>room.repository.ts:9-15</code>; <code>base.repository.ts:20-30</code></td></tr> <tr><td>Room code uniqueness <strong>per tenant</strong> (create-time)</td><td><code>rooms.service.ts:19-21</code>; <code>room.schema.ts:38</code></td></tr> <tr><td>Room type taxonomy</td><td><code>room.schema.ts:7-14</code> (<code>classroom/lab/library/office/hall/other</code>)</td></tr> <tr><td>Paginated list contract</td><td><code>rooms.service.ts:29-33</code>; <code>pagination-query.dto.ts:32-54</code></td></tr> <tr><td>RBAC permissions <code>rooms.read/create/update/delete</code> (declared, <strong>not enforced server-side</strong>)</td><td><code>permissions.constants.ts:50-53</code></td></tr> <tr><td>Soft delete (logical, <code>isDeleted</code> + <code>deletedAt</code>, never hard)</td><td><code>rooms.service.ts:48-51</code>; <code>base.repository.ts:68-74</code></td></tr> </tbody></table> </div> <h2 id="2-business-goals-18"><a class="header" href="#2-business-goals-18">2. Business goals</a></h2> <div class="table-wrapper"><table><thead><tr><th>Goal</th><th>Measure</th><th>Source</th></tr></thead><tbody> <tr><td>Every bookable/schedulable space is catalogued once per tenant</td><td>one <code>rooms</code> document per physical room; unique <code>(tenantId, code)</code></td><td><code>room.schema.ts:38</code></td></tr> <tr><td>No cross-tenant data leakage</td><td>repository injects <code>tenantId</code> + <code>isDeleted:false</code> on every query</td><td><code>base.repository.ts:20-30</code></td></tr> <tr><td>Duplicate codes fail fast with a clear message</td><td>409 <code>ConflictException</code> "Room code ... already exists."</td><td><code>rooms.service.ts:19-21</code></td></tr> <tr><td>Deletion is reversible</td><td>soft delete only; list/detail never return deleted docs</td><td><code>rooms.service.ts:48-51</code></td></tr> <tr><td>Safe growth toward timetable/bookings</td><td>rooms are the reference entity; bookings marked <code>(planned)</code> below</td><td>§9</td></tr> </tbody></table> </div> <h2 id="3-user-goals-15"><a class="header" href="#3-user-goals-15">3. User goals</a></h2> <ul> <li><strong>Org Admin / Admin Staff:</strong> create, edit, filter, and retire the room catalog; keep codes, types, capacities, buildings, and facilities accurate.</li> <li><strong>Timetable Coordinator (admin staff):</strong> browse rooms by type/building/capacity to plan where classes run (timetable module <code>(planned)</code> — <code>docs/IMPLEMENTATION_PLAN.md:227</code>).</li> <li><strong>Teacher / staff:</strong> look up a room (location, capacity, facilities) before/while using it.</li> <li><strong>Platform Super Admin:</strong> cross-tenant visibility only via platform tooling; never operates inside a tenant's room data.</li> </ul> <h2 id="4-stakeholders-15"><a class="header" href="#4-stakeholders-15">4. Stakeholders</a></h2> <p>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.</p> <h2 id="5-why-this-exists-16"><a class="header" href="#5-why-this-exists-16">5. Why this exists</a></h2> <p>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 (<code>(planned)</code>), bookings (<code>(planned)</code>), and exam seating meaningful.</p> <h2 id="6-dependencies-module--platform-1"><a class="header" href="#6-dependencies-module--platform-1">6. Dependencies (module + platform)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Dependency</th><th>Role</th><th>Status</th></tr></thead><tbody> <tr><td>Auth (<code>JwtAuthGuard</code>)</td><td>every endpoint requires Bearer JWT</td><td>implemented (<code>rooms.controller.ts:19</code>)</td></tr> <tr><td>RBAC</td><td><code>rooms.*</code> permissions declared in <code>ALL_PERMISSIONS</code></td><td>declared (<code>permissions.constants.ts:50-53</code>); <strong>guard enforcement <code>(planned)</code></strong> — see OQ-2</td></tr> <tr><td>Tenant context (<code>TenantContextService</code>)</td><td>tenant scoping via <code>BaseRepository</code></td><td>implemented (<code>base.repository.ts:20-30</code>)</td></tr> <tr><td>Mongoose (collection <code>rooms</code>)</td><td>persistence, unique index</td><td>implemented (<code>rooms.module.ts:13</code>; <code>room.schema.ts:38</code>)</td></tr> <tr><td>Swagger</td><td><code>@ApiTags('rooms')</code>, <code>@ApiOperation</code>, DTO <code>@ApiProperty*</code></td><td>implemented (<code>rooms.controller.ts:12,17,25</code>; <code>create-room.dto.ts:12-38</code>)</td></tr> <tr><td>Timetable module</td><td>consumes rooms as venue reference</td><td><code>(planned)</code> — <code>IMPLEMENTATION_PLAN.md:227</code></td></tr> <tr><td>Bookings module</td><td>room availability, check-in</td><td><code>(planned)</code> — not yet in <code>IMPLEMENTATION_PLAN.md</code></td></tr> <tr><td>QR room signage / check-in</td><td>scan room code → room detail</td><td><code>(forward-looking)</code></td></tr> </tbody></table> </div> <h2 id="7-success-metrics-13"><a class="header" href="#7-success-metrics-13">7. Success metrics</a></h2> <ul> <li>Zero duplicate <code>(tenantId, code)</code> pairs reach the DB (create check + unique index).</li> <li>Room list p95 < 300 ms (paginated find + count).</li> <li>Soft-deleted rooms never appear in list/detail (repository scope).</li> <li>Delete of an unknown id returns 404, never 500.</li> <li>Cross-tenant id access returns 404 (scoped filter), no existence leak.</li> </ul> <h2 id="8-edge-cases-8"><a class="header" href="#8-edge-cases-8">8. Edge cases</a></h2> <div class="table-wrapper"><table><thead><tr><th>Edge case</th><th>Behaviour</th><th>Source</th></tr></thead><tbody> <tr><td>Duplicate room code on create</td><td>409 ConflictException <code>Room code "X" already exists.</code></td><td><code>rooms.service.ts:19-21</code></td></tr> <tr><td>Duplicate room code on <strong>update</strong></td><td><strong>No service check</strong> — <code>$set</code> runs; unique index <code>{tenantId, code}</code> may raise Mongo 11000 → 500. Gap flagged OQ-1</td><td><code>rooms.service.ts:42-46</code>; <code>room.schema.ts:38</code></td></tr> <tr><td>PATCH with partial body</td><td>Controller reuses <code>CreateRoomDto</code> (<code>rooms.controller.ts:44</code>) — <code>name</code>/<code>code</code> are <strong>required</strong> even on PATCH; missing → 400 <code>VALIDATION_ERROR</code></td><td><code>rooms.controller.ts:44</code>; <code>create-room.dto.ts:12-18</code></td></tr> <tr><td><code>capacity</code> <= 0 or non-integer</td><td><strong>No <code>@Min</code>/<code>@IsInt</code></strong> — only <code>@IsNumber</code>; negative/fractional accepted. Client validation <code>(proposed)</code></td><td><code>create-room.dto.ts:20-23</code></td></tr> <tr><td><code>type</code> outside enum</td><td>400 <code>VALIDATION_ERROR</code> (<code>@IsEnum</code>)</td><td><code>create-room.dto.ts:25-28</code></td></tr> <tr><td>Invalid ObjectId in <code>:id</code></td><td>CastError → 400 <code>VALIDATION_ERROR</code> "Invalid resource identifier."</td><td><code>http-exception.filter.ts</code> (shared)</td></tr> <tr><td>Unknown or soft-deleted id</td><td>404 <code>RESOURCE_NOT_FOUND</code> (<code>NotFoundException</code> <code>rooms.service.ts:38,44,50</code>)</td><td><code>rooms.service.ts:36-51</code></td></tr> <tr><td>Delete of a room referenced by timetable/bookings</td><td><strong>No in-use guard today</strong> — deletes succeed; consumers must handle missing venue. Guard <code>(planned)</code></td><td><code>rooms.service.ts:48-51</code></td></tr> <tr><td>Cross-tenant id access</td><td>scoped filter → 404, never leak</td><td><code>base.repository.ts:24-29</code></td></tr> </tbody></table> </div> <h2 id="9-assumptions-2"><a class="header" href="#9-assumptions-2">9. Assumptions</a></h2> <ol> <li><strong>Client scope flag (matches <a href="rooms/../00-shared/01_Product_Overview.html">00-shared/01 §9</a>):</strong> native mobile apps are out of Phase 1 (<code>PRODUCT_REQUIREMENTS_DOCUMENT.md:144</code>); these docs are forward-looking full client specs against the implemented API. Module-specific <code>(forward-looking)</code> marks apply to QR and room check-in surfaces.</li> <li>Rooms surface is <strong>admin-owned</strong> (<code>rooms.create/update/delete</code>), read is broader (<code>rooms.read</code>); timetable coordinators and teachers read the catalog.</li> <li><code>(planned)</code> items: bookings module, RBAC guard enforcement, in-use delete guard, server-side capacity bounds, update-path duplicate check — flagged in §10.</li> <li><code>(forward-looking)</code>: QR room signage, room check-in/check-out, availability calendar.</li> <li><code>(proposed)</code>: analytics events (see <a href="rooms/05_Screen_Inventory.html">05</a>/<a href="rooms/09_User_Behaviour.html">09</a>) and client-side validation rules — the backend DTOs impose no min/max on <code>capacity</code>, no length limits on <code>name</code>/<code>code</code>.</li> <li>The wire contract (envelope, pagination meta, error codes) follows <a href="rooms/../00-shared/07_API_Conventions.html">00-shared/07</a>.</li> </ol> <h2 id="10-assumptions--open-questions-1"><a class="header" href="#10-assumptions--open-questions-1">10. Assumptions & Open Questions</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Item</th><th>Status / Impact</th></tr></thead><tbody> <tr><td>OQ-1</td><td><strong>Update path has no duplicate-code check.</strong> <code>update()</code> blindly <code>$set</code>s (<code>rooms.service.ts:42-46</code>); changing <code>code</code> to an existing one hits the unique index (<code>room.schema.ts:38</code>) → Mongo <code>E11000</code> → generic 500. Pre-check + 409 <code>(planned)</code>.</td><td>Room editor (code change)</td></tr> <tr><td>OQ-2</td><td><strong>RBAC not enforced on the controller.</strong> Only <code>JwtAuthGuard</code> (<code>rooms.controller.ts:19</code>); <code>rooms.*</code> perms (<code>permissions.constants.ts:50-53</code>) are declared but no <code>@Permissions</code> guard. Client must gate UI; server guard <code>(planned)</code>.</td><td>All screens</td></tr> <tr><td>OQ-3</td><td><strong>List has no sort/filter/search.</strong> <code>findAll</code> accepts only <code>page</code>/<code>limit</code> (<code>rooms.controller.ts:32</code>); <code>PaginationQueryDto</code>'s <code>sort</code>/<code>q</code> (<code>pagination-query.dto.ts:21-29</code>) are unused. Default order is Mongo natural (<code>_id</code>) order — no <code>-createdAt</code> sort. Filters <code>(planned)</code>.</td><td>Room list</td></tr> <tr><td>OQ-4</td><td><strong>No in-use delete guard.</strong> <code>remove()</code> soft-deletes regardless of timetable/bookings references (<code>rooms.service.ts:48-51</code>). Guard on reference counts <code>(planned)</code> with bookings/timetable modules.</td><td>Delete dialog</td></tr> <tr><td>OQ-5</td><td><strong>No <code>status</code> (operational), <code>floor</code>, or <code>equipment</code> fields</strong> — schema is <code>name/code/capacity/type/building/facilities</code> (<code>room.schema.ts:18-34</code>); <code>facilities: string[]</code> is the closest to equipment. Additions <code>(proposed)</code>/<code>(planned)</code>; the client must not invent fields the API won't return.</td><td>Room model</td></tr> <tr><td>OQ-6</td><td><strong>No <code>update-room.dto.ts</code>.</strong> PATCH reuses <code>CreateRoomDto</code> (<code>rooms.controller.ts:44</code>) so <code>name</code>+<code>code</code> are mandatory on every update. A true partial DTO <code>(planned)</code>.</td><td>Room editor</td></tr> <tr><td>OQ-7</td><td><strong>Capacity unvalidated</strong> — <code>@IsNumber</code> only (<code>create-room.dto.ts:20-23</code>); negative/0/fractional capacities persist. Server <code>@Min(1)</code> <code>(planned)</code>; client blocks <code>< 1</code> <code>(proposed)</code> today.</td><td>Room form</td></tr> <tr><td>OQ-8</td><td><strong>Delete returns no payload</strong> — <code>remove()</code> is <code>Promise<void></code> (<code>rooms.service.ts:48</code>); client reconciles locally.</td><td>Delete dialog</td></tr> </tbody></table> </div> <h2 id="11-glossary-1"><a class="header" href="#11-glossary-1">11. Glossary</a></h2> <div class="table-wrapper"><table><thead><tr><th>Term</th><th>Meaning</th><th>Source</th></tr></thead><tbody> <tr><td>Room</td><td>Tenant-scoped physical space document (<code>rooms</code> collection)</td><td><code>room.schema.ts:16-35</code></td></tr> <tr><td>Room code</td><td>Short unique-per-tenant identifier (e.g. <code>LAB-02</code>, <code>A-101</code>)</td><td><code>room.schema.ts:21-22,38</code></td></tr> <tr><td>Room type</td><td><code>classroom/lab/library/office/hall/other</code></td><td><code>room.schema.ts:7-14</code></td></tr> <tr><td>Facilities</td><td>Free-text feature tags (projector, AC, smartboard …)</td><td><code>room.schema.ts:33-34</code></td></tr> <tr><td>Soft delete</td><td><code>isDeleted:true + deletedAt</code>; excluded from all queries</td><td><code>base.schema.ts:19-24</code>; <code>base.repository.ts:20-30</code></td></tr> <tr><td>Envelope</td><td><code>{success,message,data,meta?,timestamp,requestId}</code></td><td><a href="rooms/07_Component_Library.html">07_API_Conventions.md §2-3</a></td></tr> <tr><td>Permission</td><td><code>rooms.read</code> / <code>rooms.create</code> / <code>rooms.update</code> / <code>rooms.delete</code></td><td><code>permissions.constants.ts:50-53</code></td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-rooms-module"><a class="header" href="#02--user-personas-rooms-module">02 — User Personas (Rooms Module)</a></h1> <ul> <li><a href="rooms/02_User_Personas.html#1-persona-map">1. Persona map</a></li> <li><a href="rooms/02_User_Personas.html#2-org-admin--meera">2. Org Admin — "Meera"</a></li> <li><a href="rooms/02_User_Personas.html#3-office--timetable-coordinator--ravi">3. Office / Timetable Coordinator — "Ravi"</a></li> <li><a href="rooms/02_User_Personas.html#4-teacher--ananya">4. Teacher — "Ananya"</a></li> <li><a href="rooms/02_User_Personas.html#5-platform-super-admin--devops-dan">5. Platform Super Admin — "DevOps Dan"</a></li> <li><a href="rooms/02_User_Personas.html#6-student--parent--kabir--mother-planned-surface">6. Student / parent — "Kabir & mother" <code>(planned)</code> surface</a></li> <li><a href="rooms/02_User_Personas.html#7-persona--feature-priorities">7. Persona → feature priorities</a></li> </ul> <blockquote> <p>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 (<code>src/modules/rooms/**</code>) and the declared RBAC permissions (<code>permissions.constants.ts:50-53</code>).</p> </blockquote> <hr /> <h2 id="1-persona-map-1"><a class="header" href="#1-persona-map-1">1. Persona map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Persona</th><th>Primary actions</th><th>Rooms perms</th><th>Platform</th></tr></thead><tbody> <tr><td>Org Admin</td><td>create/edit/delete rooms, resolve duplicates</td><td><code>rooms.read/create/update/delete</code></td><td>web-first, tablet</td></tr> <tr><td>Office / Timetable Coordinator</td><td>browse + filter catalog, spot rooms by capacity/facilities</td><td><code>rooms.read</code> (+ update <code>(proposed)</code>)</td><td>desktop, tablet</td></tr> <tr><td>Teacher</td><td>look up room info before class</td><td><code>rooms.read</code></td><td>mobile, tablet</td></tr> <tr><td>Platform Super Admin</td><td>tenant-agnostic support/audit</td><td>platform role (never inside tenant)</td><td>web</td></tr> <tr><td>Student / parent</td><td>check venue/QR for a booked session</td><td><code>rooms.read</code> via booking surface <code>(planned)</code></td><td>mobile</td></tr> <tr><td>Facility manager <code>(proposed)</code></td><td>operational status, maintenance flags</td><td><code>rooms.update</code> (status field <code>(proposed)</code> — OQ-5)</td><td>mobile, web</td></tr> </tbody></table> </div> <h2 id="2-org-admin--meera"><a class="header" href="#2-org-admin--meera">2. Org Admin — "Meera"</a></h2> <ul> <li><strong>Goals:</strong> 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 (<code>LAB-02</code>, <code>A-101</code>).</li> <li><strong>Friction today:</strong> duplicate-code guard exists at create (409, <code>rooms.service.ts:19-21</code>) but <strong>not on update</strong> (OQ-1 — changing a code onto an existing one 500s); no delete in-use guard (OQ-4); no <code>status</code>/<code>floor</code> fields to model an out-of-service room (OQ-5).</li> <li><strong>Screens:</strong> Room Editor (<code>/rooms/new</code>, <code>/rooms/:id/edit</code>), Room List (<code>/rooms</code>).</li> <li><strong>Out of scope guardrails:</strong> client must gate create/update/delete UI to <code>rooms.create/update/delete</code> until the server RBAC guard lands (OQ-2).</li> </ul> <h2 id="3-office--timetable-coordinator--ravi"><a class="header" href="#3-office--timetable-coordinator--ravi">3. Office / Timetable Coordinator — "Ravi"</a></h2> <ul> <li><strong>Goals:</strong> during timetable planning (<code>(planned)</code> — <code>IMPLEMENTATION_PLAN.md:227</code>) assign classes to rooms that fit: right type, enough capacity, right building.</li> <li><strong>Needs from Rooms:</strong> filterable list (type, building, capacity) — <strong>filter/sort not yet on the API</strong> (OQ-3, <code>rooms.controller.ts:32</code>); client-side filtering <code>(proposed)</code> until then; capacity shown on every card.</li> <li><strong>Friction today:</strong> pagination only (<code>page</code>/<code>limit</code>); list is Mongo <code>_id</code> order, no <code>-createdAt</code> default sort; no search (<code>q</code>).</li> </ul> <h2 id="4-teacher--ananya"><a class="header" href="#4-teacher--ananya">4. Teacher — "Ananya"</a></h2> <ul> <li><strong>Goals:</strong> quickly confirm "where is the lab and does it have a projector?" before class.</li> <li><strong>Needs:</strong> room detail with building + facilities; room card on schedule entries (timetable <code>(planned)</code>).</li> <li><strong>Friction today:</strong> read-only surface is fine; facility tags are free text, so data hygiene depends on the admin.</li> </ul> <h2 id="5-platform-super-admin--devops-dan"><a class="header" href="#5-platform-super-admin--devops-dan">5. Platform Super Admin — "DevOps Dan"</a></h2> <ul> <li><strong>Goals:</strong> support tickets ("room missing"), verify tenant isolation, audit deletions.</li> <li><strong>Needs:</strong> soft-delete audit trail (<code>deletedAt</code>/<code>deletedBy</code> — <code>base.schema.ts:23-27</code>), structural impossibility of cross-tenant reads (<code>base.repository.ts:24-29</code>).</li> <li><strong>Friction today:</strong> no audit-log module surface for rooms (audit <code>(planned)</code> in <code>IMPLEMENTATION_PLAN.md</code> Phase 5 §"Permissions audit"); the platform admin <strong>bypasses</strong> tenant scope intentionally (<code>base.repository.ts:21-23</code>).</li> </ul> <h2 id="6-student--parent--kabir--mother-planned-surface"><a class="header" href="#6-student--parent--kabir--mother-planned-surface">6. Student / parent — "Kabir & mother" <code>(planned)</code> surface</a></h2> <ul> <li><strong>Goal (forward-looking):</strong> check-in via <strong>QR</strong> at the venue, see room info on the booking receipt.</li> <li><strong>Today:</strong> no student-facing room surface; bookings module <code>(planned)</code>, QR <code>(forward-looking)</code>.</li> </ul> <h2 id="7-persona--feature-priorities"><a class="header" href="#7-persona--feature-priorities">7. Persona → feature priorities</a></h2> <div class="table-wrapper"><table><thead><tr><th>Priority</th><th>Feature</th><th>Personas served</th></tr></thead><tbody> <tr><td>P0</td><td>Room CRUD with 409 duplicate guard</td><td>Admin</td></tr> <tr><td>P0</td><td>Paginated list + detail</td><td>All</td></tr> <tr><td>P1</td><td>Duplicate check on update (OQ-1)</td><td>Admin</td></tr> <tr><td>P1</td><td>Filter/sort/search on list (OQ-3)</td><td>Coordinator</td></tr> <tr><td>P1</td><td>Client permission gating (OQ-2)</td><td>All</td></tr> <tr><td>P2</td><td>Capacity bounds (OQ-7)</td><td>Coordinator</td></tr> <tr><td>P2</td><td>In-use delete guard (OQ-4)</td><td>Admin, coordinator</td></tr> <tr><td>P3</td><td>Status/floor/equipment fields (OQ-5)</td><td>Facility mgr</td></tr> <tr><td>P3</td><td>QR signage + check-in</td><td>Student, staff <code>(forward-looking)</code></td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journeys-rooms-module"><a class="header" href="#03--user-journeys-rooms-module">03 — User Journeys (Rooms Module)</a></h1> <ul> <li><a href="rooms/03_User_Journey.html#1-journey-create-a-room-org-admin">1. Journey: Create a room (Org Admin)</a></li> <li><a href="rooms/03_User_Journey.html#2-journey-find-a-room-for-timetable-planning-coordinator">2. Journey: Find a room for timetable planning (Coordinator)</a></li> <li><a href="rooms/03_User_Journey.html#3-journey-edit-a-room--code-conflict-admin">3. Journey: Edit a room — code conflict (Admin)</a></li> <li><a href="rooms/03_User_Journey.html#4-journey-delete-a-room-admin">4. Journey: Delete a room (Admin)</a></li> <li><a href="rooms/03_User_Journey.html#5-journey-book-a-room-planned--forward-looking">5. Journey: Book a room <code>(planned)</code> — forward-looking</a></li> <li><a href="rooms/03_User_Journey.html#6-journey-offline--error-handling-all">6. Journey: Offline / error handling (all)</a></li> </ul> <blockquote> <p>End-to-end journeys through the Rooms module, each step mapped to the implemented API (<code>src/modules/rooms/**</code>). Markings: <code>(planned)</code> = not in code yet, <code>(forward-looking)</code> = future surface, <code>(proposed)</code> = client-side contract.</p> </blockquote> <hr /> <h2 id="1-journey-create-a-room-org-admin"><a class="header" href="#1-journey-create-a-room-org-admin">1. Journey: Create a room (Org Admin)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Step</th><th>Screen</th><th>API / source</th></tr></thead><tbody> <tr><td>1</td><td>Admin opens Room List (<code>/rooms</code>), taps FAB "New room"</td><td>Room Editor (create)</td><td>—</td></tr> <tr><td>2</td><td>Fills name, code, capacity, type, building, facilities</td><td>Room Editor</td><td><code>CreateRoomDto</code> fields (<code>create-room.dto.ts:11-38</code>)</td></tr> <tr><td>3</td><td>Client validates: required <code>name</code>/<code>code</code>, capacity ≥ 1 <code>(proposed)</code></td><td>Room Editor</td><td>server: <code>@IsString</code>/<code>@IsNumber</code> only (OQ-7)</td></tr> <tr><td>4</td><td>Submits → <code>POST /api/v1/rooms</code></td><td>Room Editor (loading)</td><td><code>rooms.controller.ts:24-28</code></td></tr> <tr><td>5a</td><td>201 → navigates to Room Detail, snackbar "Room created"</td><td>Room Detail</td><td><code>rooms.service.ts:18-23</code></td></tr> <tr><td>5b</td><td>409 duplicate code → inline error under code field, form kept</td><td>Room Editor</td><td><code>rooms.service.ts:19-21</code></td></tr> <tr><td>5c</td><td>400 → field errors mapped from envelope <code>details[]</code></td><td>Room Editor</td><td><a href="rooms/../00-shared/07_API_Conventions.html">00-shared/07</a></td></tr> </tbody></table> </div> <h2 id="2-journey-find-a-room-for-timetable-planning-coordinator"><a class="header" href="#2-journey-find-a-room-for-timetable-planning-coordinator">2. Journey: Find a room for timetable planning (Coordinator)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Step</th><th>Screen</th><th>API / source</th></tr></thead><tbody> <tr><td>1</td><td>Opens <code>/rooms</code></td><td>Room List</td><td><code>GET /api/v1/rooms?page=1&limit=20</code> (<code>rooms.controller.ts:30-34</code>)</td></tr> <tr><td>2</td><td>Filters by type/building/capacity — <strong>client-side only today</strong> <code>(proposed)</code>; server filter <code>(planned)</code> (OQ-3)</td><td>Room List (filter chips)</td><td>no query params in code</td></tr> <tr><td>3</td><td>Scans cards for capacity badge; scrolls → <code>hasNext</code> → <code>page+1</code></td><td>Room List (infinite scroll)</td><td><code>pagination-query.dto.ts:32-39</code></td></tr> <tr><td>4</td><td>Taps room → detail with facilities list</td><td>Room Detail</td><td><code>GET /api/v1/rooms/:id</code> (<code>rooms.controller.ts:36-40</code>)</td></tr> <tr><td>5</td><td>Adds room to timetable entry</td><td>Timetable module <code>(planned)</code></td><td><code>IMPLEMENTATION_PLAN.md:227</code></td></tr> </tbody></table> </div> <h2 id="3-journey-edit-a-room--code-conflict-admin"><a class="header" href="#3-journey-edit-a-room--code-conflict-admin">3. Journey: Edit a room — code conflict (Admin)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Step</th><th>Screen</th><th>API / source</th></tr></thead><tbody> <tr><td>1</td><td>Opens <code>/:id/edit</code>, changes code to one that exists</td><td>Room Editor (edit)</td><td>PATCH <code>rooms.controller.ts:42-46</code></td></tr> <tr><td>2</td><td><strong>Gap:</strong> no 409 — <code>$set</code> runs, unique index raises E11000 → 500 (OQ-1)</td><td>Room Editor (error)</td><td><code>rooms.service.ts:42-46</code>; <code>room.schema.ts:38</code></td></tr> <tr><td>3</td><td>Server fix <code>(planned)</code>: pre-check code → 409 "already exists", inline field error</td><td>Room Editor</td><td>—</td></tr> <tr><td>4</td><td>Client today <code>(proposed)</code>: block submit on duplicate-code async check, show inline hint</td><td>Room Editor</td><td>—</td></tr> <tr><td>5</td><td>Success → snackbar + detail refreshed (<code>version</code> incremented, <code>base.repository.ts:57-66</code>)</td><td>Room Detail</td><td>—</td></tr> </tbody></table> </div> <h2 id="4-journey-delete-a-room-admin"><a class="header" href="#4-journey-delete-a-room-admin">4. Journey: Delete a room (Admin)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Step</th><th>Screen</th><th>API / source</th></tr></thead><tbody> <tr><td>1</td><td>Room Detail → menu → "Delete room"</td><td>Confirm dialog</td><td>—</td></tr> <tr><td>2</td><td>Typed/confirm → <code>DELETE /api/v1/rooms/:id</code></td><td>dialog (loading)</td><td><code>rooms.controller.ts:48-52</code></td></tr> <tr><td>3a</td><td>200 → row removed locally, snackbar "Room deleted" (soft delete)</td><td>Room List</td><td><code>rooms.service.ts:48-51</code>; <code>base.repository.ts:68-74</code></td></tr> <tr><td>3b</td><td>404 → snackbar "Room not found" (already deleted)</td><td>Room List</td><td><code>rooms.service.ts:50</code></td></tr> <tr><td>4</td><td><strong>Gap:</strong> no in-use guard — deleting a room referenced by timetable/bookings succeeds silently (OQ-4); warning <code>(planned)</code></td><td>—</td><td><code>rooms.service.ts:48-51</code></td></tr> <tr><td>5</td><td>Audit: <code>deletedAt</code>/<code>deletedBy</code> recorded</td><td>—</td><td><code>base.schema.ts:23-27</code></td></tr> </tbody></table> </div> <h2 id="5-journey-book-a-room-planned--forward-looking"><a class="header" href="#5-journey-book-a-room-planned--forward-looking">5. Journey: Book a room <code>(planned)</code> — forward-looking</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Step</th><th>Screen</th><th>API / source</th></tr></thead><tbody> <tr><td>1</td><td>Room Detail → "Book" (button <code>(planned)</code>)</td><td>Booking sheet</td><td>bookings module <code>(planned)</code></td></tr> <tr><td>2</td><td>Pick date/time slot; availability from booking module</td><td>Booking sheet</td><td><code>(planned)</code></td></tr> <tr><td>3</td><td>Attendee scans <strong>QR</strong> at venue door → room info + session</td><td>QR screen <code>(forward-looking)</code></td><td><code>(forward-looking)</code></td></tr> <tr><td>4</td><td>Analytics <code>(proposed)</code>: utilization per room → insights</td><td>Analytics <code>(proposed)</code></td><td><code>(proposed)</code></td></tr> </tbody></table> </div> <h2 id="6-journey-offline--error-handling-all"><a class="header" href="#6-journey-offline--error-handling-all">6. Journey: Offline / error handling (all)</a></h2> <ul> <li>Reads: last-good cache + offline banner; writes blocked with "You're offline" (no module offline queue) — per <a href="rooms/../00-shared/06_State_Management.html">00-shared/06 §3.6</a>.</li> <li>401 → refresh → replay → session expiry.</li> <li>403 (future server RBAC) → hide route/redirect to 403 screen (client already gates by <code>rooms.*</code> perms — OQ-2).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-rooms-module"><a class="header" href="#04--information-architecture-rooms-module">04 — Information Architecture (Rooms Module)</a></h1> <ul> <li><a href="rooms/04_Information_Architecture.html#1-placement-in-the-app">1. Placement in the app</a></li> <li><a href="rooms/04_Information_Architecture.html#2-screen-tree">2. Screen tree</a></li> <li><a href="rooms/04_Information_Architecture.html#3-domain-model-exact-from-schema">3. Domain model (exact from schema)</a></li> <li><a href="rooms/04_Information_Architecture.html#4-information-hierarchy">4. Information hierarchy</a></li> <li><a href="rooms/04_Information_Architecture.html#5-navigation-rules">5. Navigation rules</a></li> <li><a href="rooms/04_Information_Architecture.html#6-related-modules">6. Related modules</a></li> </ul> <blockquote> <p>Where Rooms sits in the app, the screen tree, the data model, and how it relates to other modules. Global IA baseline per <a href="rooms/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a>; data model derived exactly from <code>src/modules/rooms/schemas/room.schema.ts</code>.</p> </blockquote> <hr /> <h2 id="1-placement-in-the-app"><a class="header" href="#1-placement-in-the-app">1. Placement in the app</a></h2> <p>Rooms is a <strong>reference-data module</strong> inside the admin workspace: <code>Academics → Timetable → Rooms</code> in the long run (timetable <code>(planned)</code>), but today it stands alone under <code>Resources / Infrastructure</code> navigation (per <a href="rooms/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §2</a> admin destinations).</p> <pre><code>Home / Dashboard └── Admin workspace ├── …other modules… └── Rooms (/rooms) ← this module ├── Room List /rooms ├── Room Detail /rooms/:id └── Room Editor /rooms/new · /rooms/:id/edit </code></pre> <h2 id="2-screen-tree"><a class="header" href="#2-screen-tree">2. Screen tree</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Route</th><th>Parent</th><th>Permission (client gate)</th></tr></thead><tbody> <tr><td>Room List</td><td><code>/rooms</code></td><td>Admin workspace</td><td><code>rooms.read</code> (<code>permissions.constants.ts:50</code>)</td></tr> <tr><td>Room Detail</td><td><code>/rooms/:id</code></td><td>Room List</td><td><code>rooms.read</code></td></tr> <tr><td>Room Editor — create</td><td><code>/rooms/new</code></td><td>Room List (FAB)</td><td><code>rooms.create</code> (<code>permissions.constants.ts:51</code>)</td></tr> <tr><td>Room Editor — edit</td><td><code>/rooms/:id/edit</code></td><td>Room Detail</td><td><code>rooms.update</code> (<code>permissions.constants.ts:52</code>)</td></tr> <tr><td>Delete dialog</td><td>modal on Detail/List</td><td>Room Detail</td><td><code>rooms.delete</code> (<code>permissions.constants.ts:53</code>)</td></tr> <tr><td>Filter sheet</td><td>modal on List</td><td>Room List</td><td><code>rooms.read</code></td></tr> </tbody></table> </div> <p>Server-side RBAC guard is <strong>not implemented</strong> (only <code>JwtAuthGuard</code> — <code>rooms.controller.ts:19</code>); the client gates by permission — OQ-2 in <code>01</code>.</p> <h2 id="3-domain-model-exact-from-schema"><a class="header" href="#3-domain-model-exact-from-schema">3. Domain model (exact from schema)</a></h2> <pre><code>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) </code></pre> <p><strong>Not in the model today (gaps — OQ-5):</strong> operational <code>status</code>, <code>floor</code>, <code>equipment</code> (structured), capacity bounds. <code>facilities: string[]</code> is the free-text stand-in.</p> <h2 id="4-information-hierarchy"><a class="header" href="#4-information-hierarchy">4. Information hierarchy</a></h2> <ul> <li><strong>List card:</strong> name (title), code (label), type icon + label, capacity badge, building subtitle, facilities preview (first 2 chips + "+n").</li> <li><strong>Detail:</strong> header (name, type icon, code), meta grid (type, capacity, building), facilities section, audit footer (created/updated <code>(proposed)</code>), actions (Edit / Delete — permission-gated).</li> <li><strong>Editor:</strong> single section, ordered: name → code → type → capacity → building → facilities.</li> </ul> <h2 id="5-navigation-rules-1"><a class="header" href="#5-navigation-rules-1">5. Navigation rules</a></h2> <ul> <li>Detail is reachable only from List (no deep-link bookmarks today); deep links <code>studylyon://rooms/:id</code> <code>(forward-looking)</code>.</li> <li>Editor pops back to Detail (edit) or List (create).</li> <li>Delete success pops to List and removes the row locally (<code>remove()</code> is void — <code>rooms.service.ts:48</code>).</li> <li>Filter state persists within List session; reset on route pop.</li> </ul> <h2 id="6-related-modules"><a class="header" href="#6-related-modules">6. Related modules</a></h2> <div class="table-wrapper"><table><thead><tr><th>Module</th><th>Relationship</th><th>Status</th></tr></thead><tbody> <tr><td>Timetable</td><td>venue reference for class entries</td><td><code>(planned)</code> — <code>IMPLEMENTATION_PLAN.md:227</code></td></tr> <tr><td>Bookings</td><td>availability + reservations per room</td><td><code>(planned)</code> — see <code>01 §9</code></td></tr> <tr><td>Exams (seating)</td><td>exam room assignment</td><td><code>(planned)</code> — <code>IMPLEMENTATION_PLAN.md:213-220</code></td></tr> <tr><td>RBAC</td><td><code>rooms.*</code> permissions</td><td>declared (<code>permissions.constants.ts:50-53</code>), enforcement <code>(planned)</code></td></tr> <tr><td>Organizations</td><td>tenant root every room belongs to</td><td>implemented</td></tr> <tr><td>Settings</td><td>building/floor dictionaries <code>(proposed)</code></td><td>not in code</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-rooms-module"><a class="header" href="#05--screen-inventory-rooms-module">05 — Screen Inventory (Rooms Module)</a></h1> <ul> <li><a href="rooms/05_Screen_Inventory.html#legend">Legend</a></li> <li><a href="rooms/05_Screen_Inventory.html#1-room-list-screen-rooms">1. Room List Screen (<code>/rooms</code>)</a></li> <li><a href="rooms/05_Screen_Inventory.html#2-room-detail-screen-roomsid">2. Room Detail Screen (<code>/rooms/:id</code>)</a></li> <li><a href="rooms/05_Screen_Inventory.html#3-room-editor--create-roomsnew-and-edit-roomsidedit">3. Room Editor — Create (<code>/rooms/new</code>) and Edit (<code>/rooms/:id/edit</code>)</a></li> <li><a href="rooms/05_Screen_Inventory.html#4-delete-room--confirm-dialog">4. Delete Room — confirm dialog</a></li> <li><a href="rooms/05_Screen_Inventory.html#shared-components-used">Shared components used</a></li> <li><a href="rooms/05_Screen_Inventory.html#analytics-events-proposed">Analytics events (proposed)</a></li> <li><a href="rooms/05_Screen_Inventory.html#keyboard-landscape-tablet-desktop">Keyboard, landscape, tablet, desktop</a></li> </ul> <blockquote> <p>Every screen of the Rooms module, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in <a href="rooms/../00-shared/03_Component_Library.html">00-shared/03</a>; this file enumerates which ones each screen uses with module specifics.</p> </blockquote> <hr /> <h2 id="legend-15"><a class="header" href="#legend-15">Legend</a></h2> <p>States = idle / loading / success / empty / error(offline, rate, invalid) / disabled / permission. Analytics events follow <code>{module}.{screen}.{action}</code> (proposed; SDK open — <a href="rooms/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a>).</p> <hr /> <h2 id="1-room-list-screen-rooms"><a class="header" href="#1-room-list-screen-rooms">1. Room List Screen (<code>/rooms</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Browse, filter, and enter the tenant's room catalog</td></tr> <tr><td>Entry</td><td>admin workspace nav, deep link <code>/rooms</code></td></tr> <tr><td>Exit</td><td>tap row → <code>/rooms/:id</code>; FAB → <code>/rooms/new</code>; filter sheet toggle</td></tr> <tr><td>Source</td><td><code>GET /api/v1/rooms?page&limit</code> (<code>rooms.controller.ts:30-34</code>) — pagination only; <strong>no sort/filter/q</strong> (OQ-3, <code>rooms.controller.ts:32</code>)</td></tr> <tr><td>Widgets</td><td><code>AppAppBar</code> "Rooms" + count badge; <code>AppSearchBar</code> <code>(proposed)</code> (client-side filter until API lands); <code>AppChips</code> type/building filters <code>(proposed)</code>; <code>RoomCard</code> list (<code>ListView.builder</code>); <code>AppEmptyState</code>; <code>AppSkeleton</code>; <code>AppOfflineBanner</code>; <code>AppFAB</code> "New room" (gated <code>rooms.create</code>); <code>AppErrorState</code> retry</td></tr> <tr><td>States</td><td>loading (skeleton rows), success (cards), empty ("No rooms yet" + create CTA), error (retry), offline (banner + last-good cache), permission (<code>rooms.read</code> false → no nav entry)</td></tr> <tr><td>Pagination</td><td>page/limit + <code>meta {hasNext}</code> (<code>pagination-query.dto.ts:32-39</code>); infinite scroll; pull-to-refresh (<code>RefreshIndicator</code>)</td></tr> <tr><td>Permissions</td><td>read: <code>rooms.read</code>; create CTA: <code>rooms.create</code> (<code>permissions.constants.ts:50-51</code>) — client-gated (OQ-2)</td></tr> <tr><td>Analytics</td><td><code>rooms.list.open</code>, <code>rooms.list.filter.{type,building}</code>, <code>rooms.list.search</code>, <code>rooms.list.load_more</code>, <code>rooms.list.refresh</code> (proposed)</td></tr> <tr><td>Adaptive</td><td>phone: single column + FAB; tablet/desktop ≥840 dp: master-detail split (list + detail pane, <a href="rooms/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §3</a>)</td></tr> </tbody></table> </div> <h2 id="2-room-detail-screen-roomsid"><a class="header" href="#2-room-detail-screen-roomsid">2. Room Detail Screen (<code>/rooms/:id</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Full room profile: meta, facilities, actions</td></tr> <tr><td>Entry</td><td>Room List row, deep link <code>studylyon://rooms/:id</code> <code>(forward-looking)</code></td></tr> <tr><td>Exit</td><td>back → List; Edit → <code>/rooms/:id/edit</code>; Delete → confirm dialog → List</td></tr> <tr><td>Source</td><td><code>GET /api/v1/rooms/:id</code> (<code>rooms.controller.ts:36-40</code>); 404 → "Room not found"</td></tr> <tr><td>Widgets</td><td><code>AppCard</code> header (name, type icon, code chip); meta grid (type, capacity, building); <code>AppChips</code> facilities; <code>AppListTile</code> section rows; <code>AppButton</code> Edit / Delete (gated); <code>AppSnackbar</code> on mutations</td></tr> <tr><td>States</td><td>loading, success, error(404/offline/network), deleted (row gone → snackbar)</td></tr> <tr><td>Permissions</td><td>read <code>rooms.read</code>; Edit <code>rooms.update</code>; Delete <code>rooms.delete</code> (client-gated, OQ-2)</td></tr> <tr><td>Actions</td><td>Edit → editor; Delete → typed-confirm dialog (<code>DELETE /api/v1/rooms/:id</code> — <code>rooms.controller.ts:48-52</code>); <code>(planned)</code> Book CTA when bookings module lands</td></tr> <tr><td>Future</td><td>QR code card <code>(forward-looking)</code>: render <code>room.code</code> as QR for signage; check-in analytics <code>(proposed)</code></td></tr> <tr><td>Analytics</td><td><code>rooms.detail.open</code>, <code>rooms.detail.edit</code>, <code>rooms.detail.delete.{start,confirm,cancel}</code> (proposed)</td></tr> <tr><td>Adaptive</td><td>tablet/desktop: two-column (info + availability <code>(planned)</code>); sheet editor on narrow</td></tr> </tbody></table> </div> <h2 id="3-room-editor--create-roomsnew-and-edit-roomsidedit"><a class="header" href="#3-room-editor--create-roomsnew-and-edit-roomsidedit">3. Room Editor — Create (<code>/rooms/new</code>) and Edit (<code>/rooms/:id/edit</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Create or update a room document</td></tr> <tr><td>Entry</td><td>List FAB (create, gated <code>rooms.create</code>); Detail "Edit" (gated <code>rooms.update</code>)</td></tr> <tr><td>Exit</td><td>save → pop (Detail or List) + snackbar; cancel → discard confirm if dirty</td></tr> <tr><td>Source</td><td>create: <code>POST /api/v1/rooms</code> (<code>rooms.controller.ts:24-28</code>); edit: <code>PATCH /api/v1/rooms/:id</code> (<code>rooms.controller.ts:42-46</code>) — <strong>reuses <code>CreateRoomDto</code>, so <code>name</code>+<code>code</code> required even on PATCH</strong> (OQ-6, <code>rooms.controller.ts:44</code>)</td></tr> <tr><td>Form</td><td><code>AppTextField</code> name*, code* (with uniqueness hint), capacity (number, <code>(proposed)</code> min 1 — OQ-7), <code>AppDropdown</code> type (RoomType enum, default classroom), <code>AppTextField</code> building, <code>AppChips</code> facilities input</td></tr> <tr><td>States</td><td>idle, validating (async code check <code>(proposed)</code>), submitting (button), success (pop), error: 400 field errors, 409 duplicate code inline (create only today; update <code>(planned)</code> OQ-1), offline block</td></tr> <tr><td>Permissions</td><td><code>rooms.create</code> / <code>rooms.update</code></td></tr> <tr><td>Analytics</td><td><code>rooms.editor.{create,edit}.{open,submit,success,failure,duplicate}</code> (proposed)</td></tr> <tr><td>Adaptive</td><td>phone: single-column scroll + keyboard avoidance; tablet/desktop: centered card ≤ 640 dp</td></tr> </tbody></table> </div> <h2 id="4-delete-room--confirm-dialog"><a class="header" href="#4-delete-room--confirm-dialog">4. Delete Room — confirm dialog</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Confirm soft delete with explicit room name</td></tr> <tr><td>Source</td><td><code>DELETE /api/v1/rooms/:id</code> (<code>rooms.controller.ts:48-52</code>); void response (<code>rooms.service.ts:48</code>)</td></tr> <tr><td>Behaviour</td><td>typed-name confirm (matches <code>room.name</code>); loading state on confirm; success → pop to List + snackbar; 404 → "already deleted" snackbar</td></tr> <tr><td>Gap</td><td><strong>no in-use guard</strong> (OQ-4) — warn copy "room may be referenced by timetables" <code>(planned)</code> server truth</td></tr> <tr><td>Analytics</td><td><code>rooms.delete.confirm</code>, <code>rooms.delete.cancel</code> (proposed)</td></tr> </tbody></table> </div> <hr /> <h2 id="shared-components-used-18"><a class="header" href="#shared-components-used-18">Shared components used</a></h2> <p><code>AppAppBar</code>, <code>AppSearchBar</code>, <code>AppChips</code>, <code>AppCard</code>, <code>AppListTile</code>, <code>AppTextField</code>, <code>AppDropdown</code>, <code>AppButton</code>, <code>AppFAB</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppErrorState</code>, <code>AppOfflineBanner</code>, <code>AppSnackbar</code>, <code>AppDialog</code>, <code>AppMenu</code>, <code>RefreshIndicator</code>, <code>AppAvatar</code>/<code>AppIconBadge</code>. Module-specific: <code>RoomCard</code>, <code>RoomTypeIcon</code>, <code>CapacityBadge</code>, <code>FacilityChips</code> — defined in <a href="rooms/07_Component_Library.html">07_Component_Library.md</a>.</p> <h2 id="analytics-events-proposed-15"><a class="header" href="#analytics-events-proposed-15">Analytics events (proposed)</a></h2> <p><code>rooms.list.{open,filter,search,load_more,refresh}</code>, <code>rooms.detail.{open,edit,delete}</code>, <code>rooms.editor.{open,submit,success,failure,duplicate}</code>, <code>rooms.delete.{confirm,cancel}</code>. Future: <code>rooms.booking.{book,cancel}</code> <code>(planned)</code>, <code>rooms.checkin.scan</code> <code>(forward-looking)</code>, <code>rooms.analytics.utilization</code> <code>(proposed)</code>.</p> <h2 id="keyboard-landscape-tablet-desktop-15"><a class="header" href="#keyboard-landscape-tablet-desktop-15">Keyboard, landscape, tablet, desktop</a></h2> <ul> <li>Editor: portrait-first; landscape scrolls; tablet/desktop constrain width (≤ 640 dp card).</li> <li>List: master-detail at ≥ 840 dp; desktop hover highlights rows; filter chips wrap.</li> <li>All fields keyboard-avoidance + <code>ScrollView</code> <code>resizeToAvoidBottomInset</code>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-rooms-module"><a class="header" href="#06--screen-specifications-rooms-module">06 — Screen Specifications (Rooms Module)</a></h1> <ul> <li><a href="rooms/06_Screen_Specifications.html#s1--room-list-rooms">S1 — Room List (<code>/rooms</code>)</a> <ul> <li><a href="rooms/06_Screen_Specifications.html#11-purpose--entry">1.1 Purpose & entry</a></li> <li><a href="rooms/06_Screen_Specifications.html#12-layout-phone">1.2 Layout (phone)</a></li> <li><a href="rooms/06_Screen_Specifications.html#13-data-contract">1.3 Data contract</a></li> <li><a href="rooms/06_Screen_Specifications.html#14-states">1.4 States</a></li> <li><a href="rooms/06_Screen_Specifications.html#15-interactions">1.5 Interactions</a></li> <li><a href="rooms/06_Screen_Specifications.html#16-analytics-proposed">1.6 Analytics (proposed)</a></li> <li><a href="rooms/06_Screen_Specifications.html#17-a11y--motion">1.7 A11y & motion</a></li> </ul> </li> <li><a href="rooms/06_Screen_Specifications.html#s2--room-detail-roomsid">S2 — Room Detail (<code>/rooms/:id</code>)</a> <ul> <li><a href="rooms/06_Screen_Specifications.html#21-purpose--entry">2.1 Purpose & entry</a></li> <li><a href="rooms/06_Screen_Specifications.html#22-layout">2.2 Layout</a></li> <li><a href="rooms/06_Screen_Specifications.html#23-data-contract">2.3 Data contract</a></li> <li><a href="rooms/06_Screen_Specifications.html#24-states">2.4 States</a></li> <li><a href="rooms/06_Screen_Specifications.html#25-interactions">2.5 Interactions</a></li> <li><a href="rooms/06_Screen_Specifications.html#26-analytics-proposed">2.6 Analytics (proposed)</a></li> <li><a href="rooms/06_Screen_Specifications.html#27-a11y--motion">2.7 A11y & motion</a></li> </ul> </li> <li><a href="rooms/06_Screen_Specifications.html#s3--room-editor--create-roomsnew--edit-roomsidedit">S3 — Room Editor — Create (<code>/rooms/new</code>) / Edit (<code>/rooms/:id/edit</code>)</a> <ul> <li><a href="rooms/06_Screen_Specifications.html#31-purpose--entry">3.1 Purpose & entry</a></li> <li><a href="rooms/06_Screen_Specifications.html#32-layout-scroll-form">3.2 Layout (scroll form)</a></li> <li><a href="rooms/06_Screen_Specifications.html#33-data-contract">3.3 Data contract</a></li> <li><a href="rooms/06_Screen_Specifications.html#34-field-specs">3.4 Field specs</a></li> <li><a href="rooms/06_Screen_Specifications.html#35-states--interactions">3.5 States & interactions</a></li> <li><a href="rooms/06_Screen_Specifications.html#36-analytics-proposed">3.6 Analytics (proposed)</a></li> <li><a href="rooms/06_Screen_Specifications.html#37-a11y--motion">3.7 A11y & motion</a></li> </ul> </li> <li><a href="rooms/06_Screen_Specifications.html#s4--delete-room--confirm-dialog">S4 — Delete Room — confirm dialog</a> <ul> <li><a href="rooms/06_Screen_Specifications.html#41-spec">4.1 Spec</a></li> </ul> </li> <li><a href="rooms/06_Screen_Specifications.html#5-cross-screen-rules">5. Cross-screen rules</a></li> </ul> <blockquote> <p>Detailed screen-by-screen specifications. Data sources are the exact endpoints from <code>src/modules/rooms/rooms.controller.ts</code>; fields from <code>room.schema.ts</code> and <code>create-room.dto.ts</code>. Markings: <code>(planned)</code> backend gap, <code>(proposed)</code> client contract, <code>(forward-looking)</code> future surface. Motion per <a href="rooms/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>, a11y per <a href="rooms/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>.</p> </blockquote> <hr /> <h2 id="s1--room-list-rooms"><a class="header" href="#s1--room-list-rooms">S1 — Room List (<code>/rooms</code>)</a></h2> <h3 id="11-purpose--entry-1"><a class="header" href="#11-purpose--entry-1">1.1 Purpose & entry</a></h3> <p>Browse the tenant's rooms; filter; create; open detail. Entry: admin workspace nav (gated <code>rooms.read</code>), deep link <code>/rooms</code>. Exit: row → S2, FAB → S3-create, back → workspace.</p> <h3 id="12-layout-phone"><a class="header" href="#12-layout-phone">1.2 Layout (phone)</a></h3> <pre><code>┌─────────────────────────────────┐ │ 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 +] │ └─────────────────────────────────┘ </code></pre> <h3 id="13-data-contract"><a class="header" href="#13-data-contract">1.3 Data contract</a></h3> <div class="table-wrapper"><table><thead><tr><th>Item</th><th>Detail</th><th>Source</th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>GET /api/v1/rooms?page=1&limit=20</code></td><td><code>rooms.controller.ts:30-34</code></td></tr> <tr><td>Response</td><td>envelope <code>data: Room[]</code>, <code>meta: PaginationMeta {page,limit,totalItems,totalPages,hasNext,hasPrevious}</code></td><td><code>rooms.service.ts:25-34</code>; <code>pagination-query.dto.ts:32-39</code></td></tr> <tr><td>Page/limit</td><td><code>page</code> ≥ 1 default 1; <code>limit</code> default 20 (controller default; shared DTO caps 1–100 — <code>pagination-query.dto.ts:13-19</code> but <strong>not applied</strong> on this route)</td><td><code>rooms.controller.ts:32</code></td></tr> <tr><td>Sort/filter/search</td><td><strong>none</strong> — list is Mongo natural order (OQ-3); client-side filter/search <code>(proposed)</code>; server params <code>(planned)</code></td><td><code>rooms.controller.ts:32</code>; <code>rooms.service.ts:29-32</code></td></tr> </tbody></table> </div> <h3 id="14-states"><a class="header" href="#14-states">1.4 States</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>UI</th><th>Trigger</th></tr></thead><tbody> <tr><td>Loading</td><td>6 skeleton <code>RoomCard</code>s</td><td>first load / refresh</td></tr> <tr><td>Success</td><td>cards; count badge in AppBar</td><td>200</td></tr> <tr><td>Empty</td><td><code>AppEmptyState</code> "No rooms yet" + "Add room" (gated)</td><td><code>totalItems = 0</code></td></tr> <tr><td>Load-more</td><td>bottom spinner row</td><td>scroll hits 80% while <code>hasNext</code></td></tr> <tr><td>Error</td><td><code>AppErrorState</code> + Retry (re-emits <code>LoadFirst</code>)</td><td>non-401 failure</td></tr> <tr><td>Offline</td><td><code>AppOfflineBanner</code> + cached list</td><td>connectivity lost</td></tr> <tr><td>Permission</td><td>no nav entry; route guard → 403 screen</td><td><code>rooms.read</code> absent</td></tr> </tbody></table> </div> <h3 id="15-interactions"><a class="header" href="#15-interactions">1.5 Interactions</a></h3> <ul> <li>Row tap → S2 (push; on tablet master-detail select without push).</li> <li>Search field (debounce 300 ms <code>(proposed)</code>): client-side case-insensitive match on name/code/building; resets to page 1.</li> <li>Type chips (multi-select <code>(proposed)</code>): client filter on <code>type</code>; "All" chip resets.</li> <li>Building chips derived from loaded + cached items <code>(proposed)</code>.</li> <li>Pull-to-refresh: bypasses cache, reloads page 1.</li> <li>Infinite scroll: while <code>hasNext</code> fetch <code>page+1</code>, append, dedupe by <code>_id</code>.</li> <li>Delete row (menu) → S4 dialog; on success remove row + decrement count locally (<code>remove()</code> is void — <code>rooms.service.ts:48</code>).</li> </ul> <h3 id="16-analytics-proposed"><a class="header" href="#16-analytics-proposed">1.6 Analytics (proposed)</a></h3> <p><code>rooms.list.open</code>, <code>rooms.list.filter.{type,building}</code>, <code>rooms.list.search</code>, <code>rooms.list.load_more</code>, <code>rooms.list.refresh</code>, <code>rooms.list.row_tap</code>.</p> <h3 id="17-a11y--motion"><a class="header" href="#17-a11y--motion">1.7 A11y & motion</a></h3> <ul> <li>Cards: single semantics label "Lab 2, code LAB-02, capacity 40, building B".</li> <li>Loading announced via live region; <code>m-fast</code> fade-in of cards (<code>00-shared/08</code>).</li> <li>Focus order: search → chips → cards → FAB.</li> </ul> <hr /> <h2 id="s2--room-detail-roomsid"><a class="header" href="#s2--room-detail-roomsid">S2 — Room Detail (<code>/rooms/:id</code>)</a></h2> <h3 id="21-purpose--entry"><a class="header" href="#21-purpose--entry">2.1 Purpose & entry</a></h3> <p>Full room profile. Entry: S1 row, deep link <code>studylyon://rooms/:id</code> <code>(forward-looking)</code>. Exit: back; Edit → S3; Delete → S4 → S1.</p> <h3 id="22-layout"><a class="header" href="#22-layout">2.2 Layout</a></h3> <pre><code>┌───────────────────────────────────┐ │ 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) │ └───────────────────────────────────┘ </code></pre> <h3 id="23-data-contract"><a class="header" href="#23-data-contract">2.3 Data contract</a></h3> <div class="table-wrapper"><table><thead><tr><th>Item</th><th>Detail</th><th>Source</th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>GET /api/v1/rooms/:id</code></td><td><code>rooms.controller.ts:36-40</code></td></tr> <tr><td>Response</td><td>envelope <code>data: Room</code></td><td><code>rooms.service.ts:36-40</code></td></tr> <tr><td>404</td><td><code>RESOURCE_NOT_FOUND</code> "Room not found." — incl. cross-tenant id (no leak)</td><td><code>rooms.service.ts:38</code>; <code>base.repository.ts:24-29</code></td></tr> <tr><td>Bad id</td><td>400 <code>VALIDATION_ERROR</code> (CastError mapping)</td><td>shared filter</td></tr> </tbody></table> </div> <h3 id="24-states-2"><a class="header" href="#24-states-2">2.4 States</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>UI</th><th>Trigger</th></tr></thead><tbody> <tr><td>Loading</td><td>header skeleton</td><td>fetch</td></tr> <tr><td>Success</td><td>full profile</td><td>200</td></tr> <tr><td>NotFound</td><td><code>AppEmptyState</code> "Room not found" + back</td><td>404</td></tr> <tr><td>Error</td><td><code>AppErrorState</code> retry</td><td>network/5xx</td></tr> <tr><td>Offline</td><td>banner + cached doc</td><td>connectivity</td></tr> </tbody></table> </div> <h3 id="25-interactions"><a class="header" href="#25-interactions">2.5 Interactions</a></h3> <ul> <li>Edit → S3 prefilled (only when <code>rooms.update</code>).</li> <li>Delete → S4 dialog (only when <code>rooms.delete</code>).</li> <li>Menu <code>(proposed)</code>: "Copy code" (clipboard — powers future QR/scanner flows).</li> <li>Facilities: first 2 chips inline, tap "+n" → expand all (<code>AnimatedSize</code>).</li> <li>Availability section <code>(planned)</code>: shows upcoming bookings when bookings module exists.</li> <li>QR card <code>(forward-looking)</code>: renders <code>code</code> as QR for door signage; hidden until signage feature ships.</li> </ul> <h3 id="26-analytics-proposed"><a class="header" href="#26-analytics-proposed">2.6 Analytics (proposed)</a></h3> <p><code>rooms.detail.open</code>, <code>rooms.detail.edit</code>, <code>rooms.detail.menu.copy_code</code>, <code>rooms.detail.delete.start</code>.</p> <h3 id="27-a11y--motion"><a class="header" href="#27-a11y--motion">2.7 A11y & motion</a></h3> <ul> <li>Header is one semantics group; facilities chips individually tappable with labels.</li> <li>Delete/mutation feedback via <code>m-fast</code>; snackbar auto-dismiss with undo <code>(proposed)</code> (no undo API — restore = recreate).</li> </ul> <hr /> <h2 id="s3--room-editor--create-roomsnew--edit-roomsidedit"><a class="header" href="#s3--room-editor--create-roomsnew--edit-roomsidedit">S3 — Room Editor — Create (<code>/rooms/new</code>) / Edit (<code>/rooms/:id/edit</code>)</a></h2> <h3 id="31-purpose--entry"><a class="header" href="#31-purpose--entry">3.1 Purpose & entry</a></h3> <p>Create (from S1 FAB, gated <code>rooms.create</code>) or edit (from S2, gated <code>rooms.update</code>) a room. Exit: save success → pop + snackbar; cancel → discard confirm if dirty; back arrow same.</p> <h3 id="32-layout-scroll-form"><a class="header" href="#32-layout-scroll-form">3.2 Layout (scroll form)</a></h3> <pre><code>┌───────────────────────────────────┐ │ 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) │ └───────────────────────────────────┘ </code></pre> <h3 id="33-data-contract"><a class="header" href="#33-data-contract">3.3 Data contract</a></h3> <div class="table-wrapper"><table><thead><tr><th>Item</th><th>Detail</th><th>Source</th></tr></thead><tbody> <tr><td>Create</td><td><code>POST /api/v1/rooms</code> body <code>CreateRoomDto</code></td><td><code>rooms.controller.ts:24-28</code>; <code>create-room.dto.ts:11-38</code></td></tr> <tr><td>Update</td><td><code>PATCH /api/v1/rooms/:id</code> body <code>CreateRoomDto</code> — <strong>full DTO required</strong> (name+code mandatory on PATCH; no <code>update-room.dto.ts</code> — OQ-6)</td><td><code>rooms.controller.ts:42-46</code></td></tr> <tr><td>409</td><td>create: "Room code … already exists."; update: <strong>no check</strong> (OQ-1)</td><td><code>rooms.service.ts:19-21</code></td></tr> <tr><td>400</td><td><code>VALIDATION_ERROR</code> <code>details[]</code> (e.g. <code>type</code> not in enum)</td><td>shared filter</td></tr> <tr><td>404</td><td>update of unknown id: "Room not found."</td><td><code>rooms.service.ts:44</code></td></tr> </tbody></table> </div> <h3 id="34-field-specs"><a class="header" href="#34-field-specs">3.4 Field specs</a></h3> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Required</th><th>Type</th><th>Server validation (source)</th><th>Client rules <code>(proposed)</code></th></tr></thead><tbody> <tr><td>name</td><td>yes</td><td>string</td><td><code>@IsString</code>, <code>trim</code> (<code>create-room.dto.ts:12-14</code>; <code>room.schema.ts:18-19</code>)</td><td>non-empty; ≤ 120 chars; whitespace-trimmed</td></tr> <tr><td>code</td><td>yes</td><td>string</td><td><code>@IsString</code>, <code>trim</code> (<code>create-room.dto.ts:16-18</code>; <code>room.schema.ts:21-22</code>)</td><td>non-empty; unique hint; auto-uppercase suggestion; pattern <code>[A-Z0-9-]{2,24}</code> <code>(proposed)</code></td></tr> <tr><td>capacity</td><td>no</td><td>number</td><td><code>@IsNumber</code> only — <strong>no min/zero guard</strong> (<code>create-room.dto.ts:20-23</code>; OQ-7)</td><td>integer ≥ 1; < 1 blocked; > 10 000 warning <code>(proposed)</code></td></tr> <tr><td>type</td><td>no (default <code>classroom</code>)</td><td>enum</td><td><code>@IsEnum(RoomType)</code> (<code>create-room.dto.ts:25-28</code>; <code>room.schema.ts:7-14,27-28</code>)</td><td>dropdown from enum; default classroom</td></tr> <tr><td>building</td><td>no</td><td>string</td><td><code>@IsString</code> (<code>create-room.dto.ts:30-33</code>)</td><td>free text + suggestions from existing <code>(proposed)</code></td></tr> <tr><td>facilities</td><td>no</td><td>string[]</td><td><code>@IsArray</code> of strings (element type unchecked) (<code>create-room.dto.ts:35-38</code>)</td><td>chip input, dedupe, max 12 chips <code>(proposed)</code></td></tr> </tbody></table> </div> <h3 id="35-states--interactions"><a class="header" href="#35-states--interactions">3.5 States & interactions</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>UI</th></tr></thead><tbody> <tr><td>idle</td><td>form editable; Save enabled when required valid</td></tr> <tr><td>validating</td><td>async code-uniqueness check <code>(proposed)</code> — <code>GET</code> list client-side match; spinner under field</td></tr> <tr><td>submitting</td><td>Save spinner; fields disabled</td></tr> <tr><td>success (201/200)</td><td>pop; snackbar "Room created"/"Room updated"; emit <code>RoomChanged</code> to list/detail (cache invalidation)</td></tr> <tr><td>409</td><td>inline error under Code field; form kept; scroll-to-field (create). Update: blocked client-side until server check <code>(planned)</code></td></tr> <tr><td>400</td><td>field errors mapped from <code>details[]</code>; focus first invalid</td></tr> <tr><td>404 (edit)</td><td>pop + snackbar "Room not found"</td></tr> <tr><td>offline</td><td>write blocked + banner</td></tr> </tbody></table> </div> <ul> <li>Dirty tracking: compare against initial model; back-arrow with dirty → discard dialog.</li> <li>Facilities chip input: type + Enter → chip; tap × removes; duplicates ignored.</li> <li>Capacity field: numeric keyboard, comma/point filtered <code>(proposed)</code>.</li> </ul> <h3 id="36-analytics-proposed"><a class="header" href="#36-analytics-proposed">3.6 Analytics (proposed)</a></h3> <p><code>rooms.editor.create.open</code>, <code>rooms.editor.create.submit</code>, <code>rooms.editor.create.success</code>, <code>rooms.editor.create.duplicate</code>, <code>rooms.editor.edit.open</code>, <code>rooms.editor.edit.submit</code>, <code>rooms.editor.edit.success</code>, <code>rooms.editor.discard</code>.</p> <h3 id="37-a11y--motion"><a class="header" href="#37-a11y--motion">3.7 A11y & motion</a></h3> <ul> <li>Labels linked to fields; errors in live regions; <code>m-fast</code> focus transitions.</li> <li>Keyboard avoidance; next-field on Enter; Save via keyboard action <code>(proposed)</code>.</li> </ul> <hr /> <h2 id="s4--delete-room--confirm-dialog"><a class="header" href="#s4--delete-room--confirm-dialog">S4 — Delete Room — confirm dialog</a></h2> <h3 id="41-spec"><a class="header" href="#41-spec">4.1 Spec</a></h3> <div class="table-wrapper"><table><thead><tr><th>Item</th><th>Detail</th><th>Source</th></tr></thead><tbody> <tr><td>Trigger</td><td>Detail menu / list row menu (gated <code>rooms.delete</code>)</td><td><code>permissions.constants.ts:53</code></td></tr> <tr><td>Content</td><td>title "Delete room"; copy "Type <strong>{name}</strong> to confirm. This can't be undone." (soft-delete: actually recoverable via DB, not via UI); <code>AppTextField</code> confirm; buttons Cancel / Delete (disabled until text matches <code>room.name</code>)</td><td>—</td></tr> <tr><td>Endpoint</td><td><code>DELETE /api/v1/rooms/:id</code></td><td><code>rooms.controller.ts:48-52</code></td></tr> <tr><td>Response</td><td>200, <strong>no body payload</strong> (void — <code>rooms.service.ts:48</code>); client removes row locally</td><td><code>rooms.service.ts:48-51</code></td></tr> <tr><td>Errors</td><td>404 → snackbar "Room not found" (already deleted); network → snackbar Retry</td><td><code>rooms.service.ts:50</code></td></tr> <tr><td>Gap</td><td>no in-use guard (OQ-4): informational copy "This room may appear in timetables" <code>(proposed)</code>; server block <code>(planned)</code></td><td><code>rooms.service.ts:48-51</code></td></tr> <tr><td>Analytics</td><td><code>rooms.delete.confirm</code>, <code>rooms.delete.cancel</code> (proposed)</td><td>—</td></tr> </tbody></table> </div> <hr /> <h2 id="5-cross-screen-rules"><a class="header" href="#5-cross-screen-rules">5. Cross-screen rules</a></h2> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Value</th></tr></thead><tbody> <tr><td>Version/optimistic lock</td><td><code>PATCH</code> increments <code>version</code> (<code>base.repository.ts:57-66</code>); client does not send <code>version</code> today; concurrent-edit detection <code>(proposed)</code> via <code>If-Match</code> <code>(planned)</code></td></tr> <tr><td>401/refresh</td><td>single-flight refresh + replay, then session expiry (<code>00-shared/06 §3.6</code>)</td></tr> <tr><td>Offline</td><td>reads cached; writes blocked (no offline queue for this module)</td></tr> <tr><td>Empty list after filters</td><td><code>AppEmptyState</code> "No rooms match your filters" + clear-filters action</td></tr> <tr><td>Deep links</td><td><code>studylyon://rooms</code> , <code>studylyon://rooms/:id</code> <code>(forward-looking)</code></td></tr> <tr><td>Loading budgets</td><td>list ≤ 300 ms p95 target; detail ≤ 250 ms (<code>00-shared/10 §1</code>)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-rooms-module"><a class="header" href="#07--component-library-rooms-module">07 — Component Library (Rooms Module)</a></h1> <ul> <li><a href="rooms/07_Component_Library.html#1-roomcard">1. RoomCard</a></li> <li><a href="rooms/07_Component_Library.html#2-roomtypeicon">2. RoomTypeIcon</a></li> <li><a href="rooms/07_Component_Library.html#3-capacitybadge">3. CapacityBadge</a></li> <li><a href="rooms/07_Component_Library.html#4-facilitychips">4. FacilityChips</a></li> <li><a href="rooms/07_Component_Library.html#5-roomfilterbar">5. RoomFilterBar</a></li> <li><a href="rooms/07_Component_Library.html#6-roomtypepicker">6. RoomTypePicker</a></li> <li><a href="rooms/07_Component_Library.html#7-roomcodefield">7. RoomCodeField</a></li> <li><a href="rooms/07_Component_Library.html#8-facilityinputchips">8. FacilityInputChips</a></li> <li><a href="rooms/07_Component_Library.html#9-deleteroomdialog">9. DeleteRoomDialog</a></li> <li><a href="rooms/07_Component_Library.html#10-qrcodecard-forward-looking">10. QRCodeCard <code>(forward-looking)</code></a></li> <li><a href="rooms/07_Component_Library.html#11-roomavailabilitysection-planned">11. RoomAvailabilitySection <code>(planned)</code></a></li> <li><a href="rooms/07_Component_Library.html#12-component-usage-matrix">12. Component usage matrix</a></li> </ul> <blockquote> <p>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 <a href="rooms/../00-shared/03_Component_Library.html">00-shared/03</a> and are referenced by name only. All <code>(proposed)</code> Flutter design.</p> </blockquote> <hr /> <h2 id="1-roomcard"><a class="header" href="#1-roomcard">1. RoomCard</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>List-row unit for a room on S1 (also detail-pane row on tablet)</td></tr> <tr><td>Data</td><td><code>Room</code> (<code>room.schema.ts:16-35</code>): name, code, type, capacity?, building?, facilities[]</td></tr> <tr><td>Layout</td><td><code>AppCard</code>: leading <code>RoomTypeIcon</code> (type), title <code>name</code>, subtitle <code>building · capacity</code> ("Cap N" if present), trailing <code>code</code> chip; facilities preview: first 2 <code>AppChips</code> + "+n"</td></tr> <tr><td>States</td><td>default, pressed (hover on desktop), loading (skeleton variant)</td></tr> <tr><td>Semantics</td><td>single label "Lab 2, code LAB-02, capacity 40, Building B"</td></tr> <tr><td>Motion</td><td><code>m-fast</code> press feedback; cards fade-in staggered on load (<a href="rooms/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>)</td></tr> <tr><td>Source refs</td><td><code>room.schema.ts:18-34</code></td></tr> </tbody></table> </div> <h2 id="2-roomtypeicon"><a class="header" href="#2-roomtypeicon">2. RoomTypeIcon</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>Map <code>RoomType</code> enum → icon + label + color (<code>(proposed)</code> palette, <a href="rooms/../00-shared/02_Design_Tokens.html">00-shared/02</a>)</td></tr> <tr><td>Mapping</td><td><code>classroom</code> → chair/school icon; <code>lab</code> → science/flask; <code>library</code> → book; <code>office</code> → briefcase; <code>hall</code> → event seat; <code>other</code> → generic room</td></tr> <tr><td>Source refs</td><td><code>room.schema.ts:7-14</code></td></tr> <tr><td>Fallback</td><td>unknown value → <code>other</code> icon (defensive: enum may grow)</td></tr> </tbody></table> </div> <h2 id="3-capacitybadge"><a class="header" href="#3-capacitybadge">3. CapacityBadge</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>Compact capacity display "Cap 40"; supports <code>(proposed)</code> color states once capacity bounds land (OQ-7): normal ≥ 1; warning/error for 0/negative that slipped through server (defensive)</td></tr> <tr><td>Data</td><td><code>room.capacity</code> (optional) — hidden if absent</td></tr> <tr><td>Source refs</td><td><code>room.schema.ts:24-25</code>; <code>create-room.dto.ts:20-23</code></td></tr> </tbody></table> </div> <h2 id="4-facilitychips"><a class="header" href="#4-facilitychips">4. FacilityChips</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>Render <code>facilities: string[]</code> as chips; max 2 inline + "+n" expand (S2), full list (S2 expanded)</td></tr> <tr><td>Interaction</td><td>tap chip → <code>(forward-looking)</code> filter-by-facility (needs server param <code>(planned)</code> OQ-3)</td></tr> <tr><td>Source refs</td><td><code>room.schema.ts:33-34</code></td></tr> </tbody></table> </div> <h2 id="5-roomfilterbar"><a class="header" href="#5-roomfilterbar">5. RoomFilterBar</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>S1 filter controls: type chips (multi-select from enum) + building chips (derived) + search field</td></tr> <tr><td>Behaviour</td><td><strong>client-side filtering today</strong> <code>(proposed)</code> (OQ-3 — API has no filter params); becomes server-query UI when <code>(planned)</code> params land</td></tr> <tr><td>Empty state</td><td>"No rooms match your filters" + "Clear filters"</td></tr> <tr><td>Source refs</td><td><code>rooms.controller.ts:30-34</code>; <code>room.schema.ts:7-14,30-31</code></td></tr> </tbody></table> </div> <h2 id="6-roomtypepicker"><a class="header" href="#6-roomtypepicker">6. RoomTypePicker</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>S3 form control: <code>AppDropdown</code> of <code>RoomType</code> values, default <code>classroom</code></td></tr> <tr><td>Options</td><td>enum order as in <code>room.schema.ts:7-14</code>; label = capitalized value</td></tr> <tr><td>Source refs</td><td><code>room.schema.ts:7-14</code>; <code>create-room.dto.ts:25-28</code></td></tr> </tbody></table> </div> <h2 id="7-roomcodefield"><a class="header" href="#7-roomcodefield">7. RoomCodeField</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>S3 code input with uniqueness affordance: live trim + uppercase suggestion <code>(proposed)</code>; async duplicate check <code>(proposed)</code> (match against loaded list); on create shows ✓/✗ inline; on edit warns if changed onto existing (client can't fully verify — server check <code>(planned)</code> OQ-1)</td></tr> <tr><td>Source refs</td><td><code>rooms.service.ts:19-21</code> (409 path); <code>room.schema.ts:38</code> (unique index)</td></tr> </tbody></table> </div> <h2 id="8-facilityinputchips"><a class="header" href="#8-facilityinputchips">8. FacilityInputChips</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>S3 chip-input for <code>facilities</code>: type + Enter → chip; × to remove; dedupe; max 12 <code>(proposed)</code></td></tr> <tr><td>Source refs</td><td><code>create-room.dto.ts:35-38</code></td></tr> </tbody></table> </div> <h2 id="9-deleteroomdialog"><a class="header" href="#9-deleteroomdialog">9. DeleteRoomDialog</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>S4 typed-confirm dialog: title, room name echoed, confirm text field must equal <code>room.name</code> to enable Delete</td></tr> <tr><td>States</td><td>idle, mismatch (button disabled), submitting (spinner), error (snackbar retry), 404 (snackbar "already deleted" + pop)</td></tr> <tr><td>Source refs</td><td><code>rooms.controller.ts:48-52</code>; <code>rooms.service.ts:48-51</code></td></tr> </tbody></table> </div> <h2 id="10-qrcodecard-forward-looking"><a class="header" href="#10-qrcodecard-forward-looking">10. QRCodeCard <code>(forward-looking)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>S2 card rendering <code>room.code</code> as QR for door signage; scan → room info/check-in</td></tr> <tr><td>Status</td><td><code>(forward-looking)</code> — requires signage/check-in feature decision; data already present (<code>code</code>)</td></tr> <tr><td>Source refs</td><td><code>room.schema.ts:21-22</code></td></tr> </tbody></table> </div> <h2 id="11-roomavailabilitysection-planned"><a class="header" href="#11-roomavailabilitysection-planned">11. RoomAvailabilitySection <code>(planned)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Purpose</td><td>S2 "Availability" block driven by bookings module <code>(planned)</code>; skeleton + empty state "No upcoming bookings"</td></tr> <tr><td>Status</td><td><code>(planned)</code> — hidden until bookings module exists (<a href="rooms/01_Product_Overview.html">01 §9</a>)</td></tr> </tbody></table> </div> <h2 id="12-component-usage-matrix"><a class="header" href="#12-component-usage-matrix">12. Component usage matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Components</th></tr></thead><tbody> <tr><td>S1 List</td><td><code>RoomCard</code>, <code>RoomTypeIcon</code>, <code>CapacityBadge</code>, <code>FacilityChips</code>, <code>RoomFilterBar</code>, shared: <code>AppAppBar</code>, <code>AppSearchBar</code>, <code>AppFAB</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppErrorState</code>, <code>AppOfflineBanner</code></td></tr> <tr><td>S2 Detail</td><td><code>RoomTypeIcon</code>, <code>CapacityBadge</code>, <code>FacilityChips</code>, <code>QRCodeCard</code> <code>(forward-looking)</code>, <code>RoomAvailabilitySection</code> <code>(planned)</code>, shared: <code>AppCard</code>, <code>AppMenu</code>, <code>AppButton</code>, <code>AppSnackbar</code></td></tr> <tr><td>S3 Editor</td><td><code>RoomTypePicker</code>, <code>RoomCodeField</code>, <code>FacilityInputChips</code>, shared: <code>AppTextField</code>, <code>AppDropdown</code>, <code>AppButton</code>, <code>AppSnackbar</code>, <code>AppDialog</code> (discard)</td></tr> <tr><td>S4 Delete</td><td><code>DeleteRoomDialog</code></td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-rooms-module"><a class="header" href="#08--form-specifications-rooms-module">08 — Form Specifications (Rooms Module)</a></h1> <ul> <li><a href="rooms/08_Form_Specifications.html#1-form-overview">1. Form overview</a></li> <li><a href="rooms/08_Form_Specifications.html#2-field-matrix">2. Field matrix</a></li> <li><a href="rooms/08_Form_Specifications.html#3-validation-behaviour">3. Validation behaviour</a></li> <li><a href="rooms/08_Form_Specifications.html#4-submit-semantics">4. Submit semantics</a></li> <li><a href="rooms/08_Form_Specifications.html#5-dirty--discard">5. Dirty & discard</a></li> <li><a href="rooms/08_Form_Specifications.html#6-keyboard--a11y">6. Keyboard & a11y</a></li> <li><a href="rooms/08_Form_Specifications.html#7-error-code--copy-map-proposed">7. Error-code → copy map <code>(proposed)</code></a></li> </ul> <blockquote> <p>The single form of the module (S3 — Room Editor). Field rules derive from <code>create-room.dto.ts</code> (server truth) plus the schema (<code>room.schema.ts</code>); client-side rules are <code>(proposed)</code> where the server imposes none. Markings: <code>(planned)</code> = needs backend change, <code>(forward-looking)</code> = future.</p> </blockquote> <hr /> <h2 id="1-form-overview"><a class="header" href="#1-form-overview">1. Form overview</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th>Create</th><th>Edit</th></tr></thead><tbody> <tr><td>Route</td><td><code>/rooms/new</code></td><td><code>/rooms/:id/edit</code></td></tr> <tr><td>Endpoint</td><td><code>POST /api/v1/rooms</code> (<code>rooms.controller.ts:24-28</code>)</td><td><code>PATCH /api/v1/rooms/:id</code> (<code>rooms.controller.ts:42-46</code>)</td></tr> <tr><td>Payload DTO</td><td><code>CreateRoomDto</code> (<code>create-room.dto.ts:11-38</code>)</td><td><strong>same <code>CreateRoomDto</code></strong> — there is no <code>update-room.dto.ts</code>; <code>name</code>+<code>code</code> are required even on PATCH (OQ-6, <code>rooms.controller.ts:44</code>)</td></tr> <tr><td>Guard</td><td><code>JwtAuthGuard</code>; client gates by <code>rooms.create</code> / <code>rooms.update</code> (server RBAC <code>(planned)</code> — OQ-2)</td><td>same</td></tr> <tr><td>Prefill</td><td>none (empty form)</td><td>full <code>Room</code> doc from S2 (<code>GET /rooms/:id</code> — <code>rooms.controller.ts:36-40</code>)</td></tr> </tbody></table> </div> <h2 id="2-field-matrix"><a class="header" href="#2-field-matrix">2. Field matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Required</th><th>Server rule (source)</th><th>Client rule <code>(proposed)</code></th><th>Notes</th></tr></thead><tbody> <tr><td>F1</td><td>name</td><td>✅</td><td><code>@IsString</code> (<code>create-room.dto.ts:12-14</code>); <code>trim: true</code> (<code>room.schema.ts:18-19</code>)</td><td>non-empty; ≤ 120 chars; trimmed</td><td>—</td></tr> <tr><td>F2</td><td>code</td><td>✅</td><td><code>@IsString</code> (<code>create-room.dto.ts:16-18</code>); <code>trim</code> (<code>room.schema.ts:21-22</code>); <strong>unique per tenant</strong> (create 409 <code>rooms.service.ts:19-21</code>; unique index <code>room.schema.ts:38</code>)</td><td>non-empty; pattern <code>[A-Z0-9-]{2,24}</code>; uppercase suggestion; async duplicate hint <code>(proposed)</code></td><td>edit-path duplicate check <code>(planned)</code> OQ-1</td></tr> <tr><td>F3</td><td>capacity</td><td>❌</td><td><code>@IsNumber</code> only (<code>create-room.dto.ts:20-23</code>) — <strong>no bounds</strong> (OQ-7)</td><td>integer ≥ 1; reject <code>< 1</code> and non-integer input; warn > 10 000</td><td>server <code>@Min(1)</code> <code>(planned)</code></td></tr> <tr><td>F4</td><td>type</td><td>❌ (default <code>classroom</code>)</td><td><code>@IsEnum(RoomType)</code> (<code>create-room.dto.ts:25-28</code>); default <code>classroom</code> (<code>room.schema.ts:27-28</code>)</td><td>dropdown of <code>RoomType</code> values (<code>room.schema.ts:7-14</code>)</td><td>—</td></tr> <tr><td>F5</td><td>building</td><td>❌</td><td><code>@IsString</code> (<code>create-room.dto.ts:30-33</code>)</td><td>free text; suggestions from loaded list <code>(proposed)</code>; ≤ 60 chars</td><td>—</td></tr> <tr><td>F6</td><td>facilities</td><td>❌</td><td><code>@IsArray</code> (<code>create-room.dto.ts:35-38</code>) — element type unchecked; stored <code>[String]</code> (<code>room.schema.ts:33-34</code>)</td><td>chip input; dedupe; max 12; each ≤ 32 chars <code>(proposed)</code></td><td>free-text tags; <code>(forward-looking)</code> structured equipment/status fields (OQ-5)</td></tr> </tbody></table> </div> <h2 id="3-validation-behaviour"><a class="header" href="#3-validation-behaviour">3. Validation behaviour</a></h2> <div class="table-wrapper"><table><thead><tr><th>Layer</th><th>Rule</th><th>UX</th></tr></thead><tbody> <tr><td>Required (F1, F2)</td><td>validate on submit + on blur after first submit attempt</td><td>inline error under field; focus first invalid</td></tr> <tr><td>F3 range</td><td><code>< 1</code> blocked on input (<code>(proposed)</code>); <code>0</code>/negative impossible client-side</td><td>inline hint "Capacity must be at least 1"</td></tr> <tr><td>F2 uniqueness (create)</td><td>async check <code>(proposed)</code>: debounce 300 ms, match against <code>GET /rooms</code> client list; server 409 is authoritative</td><td>inline ✓ / ✗ under Code; 409 → scroll-to-field + error box</td></tr> <tr><td>F2 uniqueness (edit)</td><td><strong>not checkable client-side reliably</strong> (list may be paginated); server check <code>(planned)</code> OQ-1</td><td>warning copy "Code must be unique"; server 500 today → mapped to generic error + support note <code>(proposed)</code></td></tr> <tr><td>Enum (F4)</td><td>server 400 if out-of-range — impossible via dropdown</td><td>—</td></tr> <tr><td>Submit</td><td>full DTO serialized; unknown keys never sent (whitelist = DTO fields)</td><td>Save button loading; fields disabled</td></tr> </tbody></table> </div> <h2 id="4-submit-semantics"><a class="header" href="#4-submit-semantics">4. Submit semantics</a></h2> <ul> <li><strong>Create:</strong> on 201 → pop to S2 (detail) + snackbar "Room created" + cache invalidation.</li> <li><strong>Edit:</strong> on 200 → pop to S2 + snackbar "Room updated"; S2 refetches (or reconciles from response doc — <code>updateById</code> returns the updated doc, <code>base.repository.ts:57-66</code>).</li> <li><strong>409 (create):</strong> form kept, inline duplicate error, no reset.</li> <li><strong>400:</strong> map <code>details[]</code> → field errors (envelope convention, <a href="rooms/../00-shared/07_API_Conventions.html">00-shared/07</a>).</li> <li><strong>404 (edit):</strong> pop + snackbar "Room not found".</li> <li><strong>Offline:</strong> block submit, banner, draft kept in memory (no autosave).</li> <li><strong>Concurrent edit <code>(proposed)</code>:</strong> <code>version</code> increments server-side (<code>base.repository.ts:63</code>); client sends <code>baseVersion</code> <code>(planned)</code> for <code>If-Match</code>-style conflict → "Room was updated elsewhere" dialog with reload/discard.</li> </ul> <h2 id="5-dirty--discard"><a class="header" href="#5-dirty--discard">5. Dirty & discard</a></h2> <ul> <li>Dirty = any field differs from the initial model (or any chip added/removed).</li> <li>Back/close with dirty → <code>AppDialog</code> "Discard changes?" [Keep editing] [Discard].</li> <li>No autosave; draft lost on discard.</li> </ul> <h2 id="6-keyboard--a11y"><a class="header" href="#6-keyboard--a11y">6. Keyboard & a11y</a></h2> <ul> <li>Order: F1 → F2 → F4 (dropdown) → F3 (numeric) → F5 → F6 (chip input).</li> <li>F3 keyboard: <code>TextInputType.number</code>; F1/F2/F5: text with autocapitalize words (F2 <code>characters</code> + uppercase transform <code>(proposed)</code>).</li> <li>Labels linked; errors in live regions; scroll-to-first-invalid; keyboard avoidance (<a href="rooms/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>).</li> </ul> <h2 id="7-error-code--copy-map-proposed"><a class="header" href="#7-error-code--copy-map-proposed">7. Error-code → copy map <code>(proposed)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Code</th><th>Copy</th></tr></thead><tbody> <tr><td><code>DUPLICATE_RESOURCE</code> (409)</td><td>"A room with this code already exists." (code field)</td></tr> <tr><td><code>VALIDATION_ERROR</code> (400)</td><td>per-field messages from <code>details[]</code></td></tr> <tr><td><code>RESOURCE_NOT_FOUND</code> (404)</td><td>"This room no longer exists."</td></tr> <tr><td><code>RATE_LIMITED</code> (429)</td><td>"Too many requests — try again shortly."</td></tr> <tr><td>5xx</td><td>"Something went wrong (requestId …)."</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-rooms-module"><a class="header" href="#09--user-behaviour-rooms-module">09 — User Behaviour (Rooms Module)</a></h1> <ul> <li><a href="rooms/09_User_Behaviour.html#1-behaviours--patterns">1. Behaviours & patterns</a> <ul> <li><a href="rooms/09_User_Behaviour.html#11-browsing--filtering-s1">1.1 Browsing & filtering (S1)</a></li> <li><a href="rooms/09_User_Behaviour.html#12-code-hygiene-s3">1.2 Code hygiene (S3)</a></li> <li><a href="rooms/09_User_Behaviour.html#13-capacity-behaviour-oq-7">1.3 Capacity behaviour (OQ-7)</a></li> <li><a href="rooms/09_User_Behaviour.html#14-deletion-s4">1.4 Deletion (S4)</a></li> <li><a href="rooms/09_User_Behaviour.html#15-permission-denied">1.5 Permission-denied</a></li> <li><a href="rooms/09_User_Behaviour.html#16-offline">1.6 Offline</a></li> </ul> </li> <li><a href="rooms/09_User_Behaviour.html#2-analytics-events-proposed">2. Analytics events (proposed)</a></li> <li><a href="rooms/09_User_Behaviour.html#3-behavioural-rules">3. Behavioural rules</a></li> </ul> <blockquote> <p>Observed/expected user behaviours around the room catalog, and the <code>(proposed)</code> analytics events that measure them. Where a behaviour hits a backend gap, the gap is named (OQ-x, see <a href="rooms/01_Product_Overview.html">01 §10</a>).</p> </blockquote> <hr /> <h2 id="1-behaviours--patterns"><a class="header" href="#1-behaviours--patterns">1. Behaviours & patterns</a></h2> <h3 id="11-browsing--filtering-s1"><a class="header" href="#11-browsing--filtering-s1">1.1 Browsing & filtering (S1)</a></h3> <ul> <li>Users scan for <strong>capacity + type first</strong> ("which lab fits 40?"), building second.</li> <li>Without server filter/sort (OQ-3), users page through <code>_id</code>-ordered data — behaviour: client-side chips + search <code>(proposed)</code>; long-term server params <code>(planned)</code>.</li> <li>Pull-to-refresh is the trust gesture after admin edits elsewhere.</li> </ul> <h3 id="12-code-hygiene-s3"><a class="header" href="#12-code-hygiene-s3">1.2 Code hygiene (S3)</a></h3> <ul> <li>Users type lowercase/mixed codes ("Lab 2", "lab-2"); client uppercases + trims <code>(proposed)</code>.</li> <li>Duplicate attempts are common at term start (copy-paste of previous room). Create-time 409 (authoritative, <code>rooms.service.ts:19-21</code>) + inline hint resolve it.</li> <li><strong>Edit-path duplicate</strong> (OQ-1) is a real trap: rename onto an existing code → 500 today; users retry, get same 500 → ticket. Mitigation <code>(proposed)</code>: client warning + server pre-check <code>(planned)</code>.</li> </ul> <h3 id="13-capacity-behaviour-oq-7"><a class="header" href="#13-capacity-behaviour-oq-7">1.3 Capacity behaviour (OQ-7)</a></h3> <ul> <li>Server accepts negative/zero capacity (<code>@IsNumber</code> only — <code>create-room.dto.ts:20-23</code>); users have entered <code>0</code> for "unknown". Client blocks <code>< 1</code> <code>(proposed)</code> and shows "Capacity unknown" state for absent capacity — never a fake <code>0</code>.</li> </ul> <h3 id="14-deletion-s4"><a class="header" href="#14-deletion-s4">1.4 Deletion (S4)</a></h3> <ul> <li>Users delete rather than archive; they expect the room to disappear from lists (it does — soft delete, <code>rooms.service.ts:48-51</code>) and to be able to "undo" (they can't — restore is DB-level; snackbar copy says "deleted").</li> <li><strong>In-use rooms get deleted</strong> (OQ-4) — timetable planner later sees a missing venue. Copy warning <code>(proposed)</code>; server in-use guard <code>(planned)</code>.</li> <li>Typed-name confirm reduces accidental deletes; analytics track cancel-vs-confirm.</li> </ul> <h3 id="15-permission-denied"><a class="header" href="#15-permission-denied">1.5 Permission-denied</a></h3> <ul> <li>Without server RBAC (OQ-2), a read-only user with a crafted request can mutate rooms; client gates UI, but the <strong>server guard is the real fix</strong> <code>(planned)</code>. Client treats future 403 as route-hide + 403 screen.</li> </ul> <h3 id="16-offline"><a class="header" href="#16-offline">1.6 Offline</a></h3> <ul> <li>Reads: last-good cache + banner; stale list is acceptable (catalog changes rarely).</li> <li>Writes: blocked — no offline queue for this module (reference data; low urgency).</li> </ul> <h2 id="2-analytics-events-proposed"><a class="header" href="#2-analytics-events-proposed">2. Analytics events (proposed)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Trigger</th><th>Screen</th></tr></thead><tbody> <tr><td><code>rooms.list.open</code></td><td>screen shown</td><td>S1</td></tr> <tr><td><code>rooms.list.search</code> (term)</td><td>search debounce fires</td><td>S1</td></tr> <tr><td><code>rooms.list.filter.type</code> (type)</td><td>chip toggle</td><td>S1</td></tr> <tr><td><code>rooms.list.filter.building</code></td><td>chip toggle</td><td>S1</td></tr> <tr><td><code>rooms.list.filter.clear</code></td><td>"Clear filters"</td><td>S1</td></tr> <tr><td><code>rooms.list.load_more</code> (page)</td><td>infinite scroll fetch</td><td>S1</td></tr> <tr><td><code>rooms.list.refresh</code></td><td>pull-to-refresh</td><td>S1</td></tr> <tr><td><code>rooms.list.row_tap</code> (id)</td><td>row → detail</td><td>S1</td></tr> <tr><td><code>rooms.detail.open</code> (id)</td><td>screen shown</td><td>S2</td></tr> <tr><td><code>rooms.detail.edit</code></td><td>Edit pressed</td><td>S2</td></tr> <tr><td><code>rooms.detail.delete.start</code></td><td>Delete menu pressed</td><td>S2</td></tr> <tr><td><code>rooms.detail.copy_code</code></td><td>menu copy</td><td>S2</td></tr> <tr><td><code>rooms.editor.create.open</code> / <code>.edit.open</code></td><td>form shown</td><td>S3</td></tr> <tr><td><code>rooms.editor.create.submit</code> / <code>.edit.submit</code></td><td>Save pressed</td><td>S3</td></tr> <tr><td><code>rooms.editor.create.success</code> / <code>.edit.success</code></td><td>201/200</td><td>S3</td></tr> <tr><td><code>rooms.editor.create.duplicate</code></td><td>409</td><td>S3</td></tr> <tr><td><code>rooms.editor.discard</code></td><td>discard confirm</td><td>S3</td></tr> <tr><td><code>rooms.delete.confirm</code> / <code>rooms.delete.cancel</code></td><td>dialog action</td><td>S4</td></tr> <tr><td><code>rooms.checkin.scan</code></td><td>QR scan at venue</td><td><code>(forward-looking)</code></td></tr> <tr><td><code>rooms.booking.{book,cancel}</code></td><td>booking actions</td><td><code>(planned)</code></td></tr> <tr><td><code>rooms.analytics.utilization</code></td><td>utilization report view</td><td><code>(proposed)</code></td></tr> </tbody></table> </div> <h2 id="3-behavioural-rules"><a class="header" href="#3-behavioural-rules">3. Behavioural rules</a></h2> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Rationale</th></tr></thead><tbody> <tr><td>No optimistic writes on S3/S4 — always wait for server result</td><td>conflict/duplicate semantics must stay authoritative; <code>remove()</code> returns void so local removal happens only after 200</td></tr> <tr><td>Local row removal on delete success (no refetch)</td><td><code>DELETE</code> has no payload (<code>rooms.service.ts:48</code>)</td></tr> <tr><td>Filter state survives list-scroll within session, resets on route pop</td><td>predictable resets (<a href="rooms/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a>)</td></tr> <tr><td>Search debounce 300 ms; code-dup check debounce 300 ms</td><td>input budget (<a href="rooms/../00-shared/10_QA_Baseline.html">00-shared/10 §1</a>)</td></tr> <tr><td>Empty filter result keeps filter visible with "clear" CTA</td><td>recoverability over dead-ends</td></tr> <tr><td>429/rate-limit → backoff countdown on submit, no auto-retry</td><td>shared error handling (<a href="rooms/../00-shared/06_State_Management.html">00-shared/06 §5</a>)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-rooms-module"><a class="header" href="#10--interaction-specification-rooms-module">10 — Interaction Specification (Rooms Module)</a></h1> <ul> <li><a href="rooms/10_Interaction_Specification.html#1-s1--room-list">1. S1 — Room List</a></li> <li><a href="rooms/10_Interaction_Specification.html#2-s2--room-detail">2. S2 — Room Detail</a></li> <li><a href="rooms/10_Interaction_Specification.html#3-s3--room-editor">3. S3 — Room Editor</a></li> <li><a href="rooms/10_Interaction_Specification.html#4-s4--delete-dialog">4. S4 — Delete Dialog</a></li> <li><a href="rooms/10_Interaction_Specification.html#5-loading--feedback-matrix">5. Loading & feedback matrix</a></li> <li><a href="rooms/10_Interaction_Specification.html#6-offline--connectivity">6. Offline & connectivity</a></li> <li><a href="rooms/10_Interaction_Specification.html#7-motion-budget">7. Motion budget</a></li> </ul> <blockquote> <p>Concrete interactions per screen: gestures, focus, feedback, dialogs, motion, loading, and offline behaviour. Motion tokens per <a href="rooms/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>, interaction baseline per <a href="rooms/../00-shared/10_QA_Baseline.html">00-shared/10</a>. All client design is <code>(proposed)</code> unless marked.</p> </blockquote> <hr /> <h2 id="1-s1--room-list"><a class="header" href="#1-s1--room-list">1. S1 — Room List</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Spec</th></tr></thead><tbody> <tr><td>Row tap</td><td>push S2 (phone); select in master-detail pane (≥ 840 dp) without pushing; hover highlight on desktop</td></tr> <tr><td>Pull-to-refresh</td><td><code>RefreshIndicator</code>; bypasses cache; resets to page 1; error → snackbar with Retry</td></tr> <tr><td>Infinite scroll</td><td>trigger at 80% viewport; fetch <code>page+1</code> when <code>hasNext</code>; bottom spinner; dedupe by <code>_id</code>; failure → inline retry row (keep loaded items)</td></tr> <tr><td>Search</td><td>debounce 300 ms; client filter <code>(proposed)</code>; clears on X; no results → empty state with "Clear filters"</td></tr> <tr><td>Filter chips</td><td>multi-select type chips; building chips derived; chip tap toggles (not navigate); selected chips show removal ×</td></tr> <tr><td>FAB</td><td>"New room" — visible only with <code>rooms.create</code>; tap → push <code>/rooms/new</code></td></tr> <tr><td>Row menu <code>(proposed)</code></td><td>delete shortcut (gated <code>rooms.delete</code>) → S4 dialog</td></tr> <tr><td>Loading</td><td>6 skeleton <code>RoomCard</code>s, staggered <code>m-fast</code> fade-in</td></tr> <tr><td>Feedback</td><td>mutations from S2/S4 reflected: row removed + count badge decrement; snackbar shown by originating screen</td></tr> </tbody></table> </div> <p><strong>Motion:</strong> card entrance fade/slide 8 dp <code>m-fast</code>; refresh spinner standard; empty-state <code>m-base</code> fade (<a href="rooms/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>).</p> <h2 id="2-s2--room-detail"><a class="header" href="#2-s2--room-detail">2. S2 — Room Detail</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Spec</th></tr></thead><tbody> <tr><td>Back</td><td>pop to S1 (list state preserved incl. filters)</td></tr> <tr><td>Edit</td><td>gated <code>rooms.update</code>; push <code>/rooms/:id/edit</code> prefilled</td></tr> <tr><td>Delete</td><td>gated <code>rooms.delete</code>; opens S4 dialog (never inline)</td></tr> <tr><td>Menu</td><td><code>⋮</code> → "Copy code" (clipboard + snackbar "Code copied"); future "QR code" <code>(forward-looking)</code></td></tr> <tr><td>Facilities</td><td>first 2 chips + "+n" button; tap → expand full list (<code>AnimatedSize</code>, <code>m-fast</code>); collapse back</td></tr> <tr><td>Stale detail</td><td>if list refresh shows the room was deleted (404) → pop + snackbar "Room no longer exists"</td></tr> <tr><td>Offline</td><td>cached doc + banner; actions (edit/delete) disabled until online</td></tr> <tr><td>Availability <code>(planned)</code></td><td>section with skeleton; empty state "No upcoming bookings"; row tap → booking detail <code>(planned)</code></td></tr> </tbody></table> </div> <h2 id="3-s3--room-editor"><a class="header" href="#3-s3--room-editor">3. S3 — Room Editor</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Spec</th></tr></thead><tbody> <tr><td>Save (AppBar action)</td><td>validates → submit; spinner in place of icon; disabled while submitting</td></tr> <tr><td>Keyboard</td><td>Enter advances field; F3 numeric; save action <code>(proposed)</code> from keyboard on F6</td></tr> <tr><td>Code field</td><td>uppercase transform + trim on input; async uniqueness check (300 ms debounce) → ✓/✗; on create only (edit: warning <code>(proposed)</code>, server check <code>(planned)</code>)</td></tr> <tr><td>Type dropdown</td><td>modal/<code>AppDropdown</code>; selection closes and applies immediately; default <code>classroom</code></td></tr> <tr><td>Facilities chip input</td><td>type + Enter/<code>,</code> → chip; backspace on empty → remove last chip; × removes; duplicates ignored; max 12 then input disabled with hint</td></tr> <tr><td>Discard</td><td>dirty + back → dialog [Keep editing][Discard]; discard pops without saving</td></tr> <tr><td>Submit results</td><td>201/200 → pop + snackbar; 409 → inline error + scroll-to-field; 400 → field errors + focus first invalid; offline → block + banner (draft kept)</td></tr> <tr><td>Concurrent edit <code>(proposed)</code></td><td>server <code>version</code> bump (<code>base.repository.ts:57-66</code>); client conflict dialog on stale-base submit <code>(planned)</code></td></tr> </tbody></table> </div> <p><strong>Motion:</strong> field error → <code>m-fast</code> shake on the offending field; snackbar <code>m-base</code> slide.</p> <h2 id="4-s4--delete-dialog"><a class="header" href="#4-s4--delete-dialog">4. S4 — Delete Dialog</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Spec</th></tr></thead><tbody> <tr><td>Open</td><td>from S2 menu or S1 row menu (gated <code>rooms.delete</code>)</td></tr> <tr><td>Confirm</td><td>text field must equal <code>room.name</code> (case-sensitive) to enable Delete; mismatch → button disabled; match → enabled</td></tr> <tr><td>Submit</td><td>button spinner; fields disabled; no dismiss on tap-out while submitting</td></tr> <tr><td>Success</td><td>dialog closes; if on S2 → pop to S1; row removed; snackbar "Room deleted"</td></tr> <tr><td>404</td><td>dialog closes; snackbar "Room not found — it may already be deleted"</td></tr> <tr><td>Network fail</td><td>dialog stays; snackbar "Couldn't delete — Retry"</td></tr> <tr><td>Cancel / tap-out</td><td>closes; analytics <code>rooms.delete.cancel</code></td></tr> <tr><td>In-use warning <code>(proposed)</code></td><td>copy "This room may be referenced by timetables or bookings" — server in-use guard <code>(planned)</code> (OQ-4)</td></tr> </tbody></table> </div> <h2 id="5-loading--feedback-matrix"><a class="header" href="#5-loading--feedback-matrix">5. Loading & feedback matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th>Action</th><th>Optimistic?</th><th>Loading UI</th><th>Success</th><th>Failure</th></tr></thead><tbody> <tr><td>List load / load-more</td><td>no</td><td>skeletons / bottom row</td><td>cards</td><td>retry row / snackbar</td></tr> <tr><td>Detail load</td><td>no</td><td>skeleton</td><td>profile</td><td>error state + Retry</td></tr> <tr><td>Create / update</td><td><strong>no</strong> (server authoritative)</td><td>button spinner</td><td>pop + snackbar</td><td>inline errors / snackbar</td></tr> <tr><td>Delete</td><td>no</td><td>dialog spinner</td><td>pop + snackbar</td><td>snackbar</td></tr> <tr><td>Copy code</td><td>yes (clipboard)</td><td>—</td><td>snackbar "Copied"</td><td>—</td></tr> </tbody></table> </div> <h2 id="6-offline--connectivity"><a class="header" href="#6-offline--connectivity">6. Offline & connectivity</a></h2> <ul> <li>Reads: last-good cache + <code>AppOfflineBanner</code> (no module offline queue — reference data).</li> <li>Writes: blocked with inline banner "You're offline"; draft preserved in cubit memory.</li> <li>Reconnect: banner clears; user re-triggers (pull-to-refresh / re-submit).</li> </ul> <h2 id="7-motion-budget"><a class="header" href="#7-motion-budget">7. Motion budget</a></h2> <div class="table-wrapper"><table><thead><tr><th>Motion</th><th>Token</th><th>Use</th></tr></thead><tbody> <tr><td>Entrance</td><td><code>m-fast</code></td><td>cards, detail fade, chips</td></tr> <tr><td>Feedback</td><td><code>m-fast</code></td><td>press states, error shake</td></tr> <tr><td>Transitions</td><td><code>m-base</code></td><td>route push/pop, snackbar</td></tr> <tr><td>Expansion</td><td><code>m-base</code></td><td>facilities expand/collapse (<code>AnimatedSize</code>)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-rooms-module"><a class="header" href="#11--design-system-mapping-rooms-module">11 — Design System Mapping (Rooms Module)</a></h1> <ul> <li><a href="rooms/11_Design_System_Mapping.html#1-token-usage">1. Token usage</a></li> <li><a href="rooms/11_Design_System_Mapping.html#2-component-mapping">2. Component mapping</a></li> <li><a href="rooms/11_Design_System_Mapping.html#3-visual-rules">3. Visual rules</a></li> <li><a href="rooms/11_Design_System_Mapping.html#4-layout-system-00-shared05">4. Layout system (00-shared/05)</a></li> <li><a href="rooms/11_Design_System_Mapping.html#5-type--iconcolor-mapping-proposed">5. Type → icon/color mapping <code>(proposed)</code></a></li> <li><a href="rooms/11_Design_System_Mapping.html#6-accessibility-mapping-00-shared09">6. Accessibility mapping (00-shared/09)</a></li> </ul> <blockquote> <p>Maps the Rooms module onto the shared design system. Tokens, components, layouts, and a11y baselines live in <a href="rooms/../00-shared/02_Design_Tokens.html">00-shared/02</a> (Design Tokens), <a href="rooms/../00-shared/03_Component_Library.html">00-shared/03</a> (Component Library), <a href="rooms/../00-shared/04_Design_System_Mapping.html">00-shared/04</a> (Design System Mapping), and <a href="rooms/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a> (Global IA). This file is the module's mapping layer only — it does not re-specify shared rules.</p> </blockquote> <hr /> <h2 id="1-token-usage-2"><a class="header" href="#1-token-usage-2">1. Token usage</a></h2> <div class="table-wrapper"><table><thead><tr><th>Token group (<a href="rooms/../00-shared/02_Design_Tokens.html">00-shared/02</a>)</th><th>Module use</th></tr></thead><tbody> <tr><td>Color</td><td>type-tinted icons via <code>colorScheme.primaryContainer</code> variants <code>(proposed)</code>; error color reserved for capacity/duplicate errors; no literal colors in widgets</td></tr> <tr><td>Typography</td><td><code>titleMedium</code> card titles, <code>labelMedium</code> code chips, <code>bodyMedium</code> meta, <code>bodySmall</code> captions</td></tr> <tr><td>Shape</td><td><code>shapeMedium</code> cards, <code>shapeFull</code> chips/badges, <code>shapeSmall</code> icon containers</td></tr> <tr><td>Spacing</td><td>16 dp page padding, 8 dp between rows/chips, 24 dp section gaps</td></tr> <tr><td>Motion</td><td><code>m-fast</code> entrance/feedback, <code>m-base</code> transitions (see <a href="rooms/10_Interaction_Specification.html">10 §7</a>)</td></tr> <tr><td>Elevation</td><td>resting cards <code>level-1</code>, pressed <code>level-2</code>, dialog <code>level-3</code></td></tr> </tbody></table> </div> <h2 id="2-component-mapping-1"><a class="header" href="#2-component-mapping-1">2. Component mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Shared component (<a href="rooms/../00-shared/03_Component_Library.html">00-shared/03</a>)</th><th>Module usage</th></tr></thead><tbody> <tr><td><code>AppAppBar</code></td><td>S1/S2/S3 title + actions (Save in S3)</td></tr> <tr><td><code>AppSearchBar</code></td><td>S1 search (client filter <code>(proposed)</code>)</td></tr> <tr><td><code>AppChips</code></td><td>type/building filters, facilities, code chip</td></tr> <tr><td><code>AppCard</code></td><td>RoomCard, detail header</td></tr> <tr><td><code>AppListTile</code></td><td>meta rows, availability rows <code>(planned)</code></td></tr> <tr><td><code>AppTextField</code></td><td>S3 fields, S4 confirm</td></tr> <tr><td><code>AppDropdown</code></td><td>RoomTypePicker</td></tr> <tr><td><code>AppButton</code></td><td>Edit/Delete, dialog actions</td></tr> <tr><td><code>AppFAB</code></td><td>S1 "New room" (gated <code>rooms.create</code>)</td></tr> <tr><td><code>AppSkeleton</code></td><td>list/detail/availability loading</td></tr> <tr><td><code>AppEmptyState</code></td><td>no rooms, no filters match, not found</td></tr> <tr><td><code>AppErrorState</code></td><td>list/detail retry</td></tr> <tr><td><code>AppOfflineBanner</code></td><td>connectivity</td></tr> <tr><td><code>AppSnackbar</code></td><td>all mutation feedback</td></tr> <tr><td><code>AppDialog</code></td><td>S4 delete, discard, concurrent-edit <code>(proposed)</code></td></tr> <tr><td><code>AppMenu</code></td><td>row/detail actions</td></tr> </tbody></table> </div> <p>Module-specific components (<a href="rooms/07_Component_Library.html">07</a>): <code>RoomCard</code>, <code>RoomTypeIcon</code>, <code>CapacityBadge</code>, <code>FacilityChips</code>, <code>RoomFilterBar</code>, <code>RoomTypePicker</code>, <code>RoomCodeField</code>, <code>FacilityInputChips</code>, <code>DeleteRoomDialog</code>, <code>QRCodeCard</code> <code>(forward-looking)</code>, <code>RoomAvailabilitySection</code> <code>(planned)</code>.</p> <h2 id="3-visual-rules"><a class="header" href="#3-visual-rules">3. Visual rules</a></h2> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Spec</th></tr></thead><tbody> <tr><td>Type identity</td><td><code>RoomTypeIcon</code> per enum (<code>room.schema.ts:7-14</code>); consistent tint per type across list/detail/editor</td></tr> <tr><td>Code prominence</td><td>code chip = monospace <code>labelMedium</code> on <code>surfaceContainerHighest</code>; distinct from name</td></tr> <tr><td>Capacity display</td><td>"Cap N" <code>CapacityBadge</code>; absent capacity → no badge (never "Cap 0")</td></tr> <tr><td>Building</td><td>subtitle text with location icon when present</td></tr> <tr><td>Facilities</td><td>chips ≤ 2 inline; "+n" expander</td></tr> <tr><td>Permission gating</td><td>hidden UI, never disabled-looking buttons for missing perms (per <a href="rooms/../00-shared/04_Design_System_Mapping.html">00-shared/04</a>)</td></tr> <tr><td>Empty vs not-found</td><td>distinct art + copy: "No rooms yet" (list) vs "Room not found" (404 detail)</td></tr> </tbody></table> </div> <h2 id="4-layout-system-00-shared05"><a class="header" href="#4-layout-system-00-shared05">4. Layout system (<a href="rooms/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Breakpoint</th><th>S1</th><th>S2</th><th>S3</th></tr></thead><tbody> <tr><td>< 600 dp (phone)</td><td>single column + FAB</td><td>single column</td><td>single column, keyboard-avoiding</td></tr> <tr><td>600–839 dp (tablet portrait)</td><td>2-column grid of cards <code>(proposed)</code></td><td>column + sticky actions</td><td>≤ 640 dp centered card</td></tr> <tr><td>≥ 840 dp (tablet/desktop)</td><td>master-detail split (list + detail pane)</td><td>pane content</td><td>centered card</td></tr> </tbody></table> </div> <h2 id="5-type--iconcolor-mapping-proposed"><a class="header" href="#5-type--iconcolor-mapping-proposed">5. Type → icon/color mapping <code>(proposed)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th><code>RoomType</code></th><th>Icon</th><th>Tint</th></tr></thead><tbody> <tr><td><code>classroom</code></td><td>school/chair</td><td>primary</td></tr> <tr><td><code>lab</code></td><td>science/flask</td><td>secondary</td></tr> <tr><td><code>library</code></td><td>book</td><td>tertiary</td></tr> <tr><td><code>office</code></td><td>briefcase</td><td>secondary-variant</td></tr> <tr><td><code>hall</code></td><td>event-seat</td><td>tertiary-variant</td></tr> <tr><td><code>other</code></td><td>room/generic</td><td>neutral</td></tr> </tbody></table> </div> <h2 id="6-accessibility-mapping-00-shared09-2"><a class="header" href="#6-accessibility-mapping-00-shared09-2">6. Accessibility mapping (<a href="rooms/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Module application</th></tr></thead><tbody> <tr><td>Contrast</td><td>all status/capacity info uses standard tokens (≥ 4.5:1 body)</td></tr> <tr><td>Touch targets</td><td>chips ≥ 44 dp hit area; FAB standard; row menus 48 dp</td></tr> <tr><td>Semantics</td><td>RoomCard single label; facilities chips labeled individually on S2</td></tr> <tr><td>Live regions</td><td>loading announcements, field errors, snackbar content</td></tr> <tr><td>Focus order</td><td>S1: search → chips → cards → FAB; S3: F1→F6</td></tr> <tr><td>Reduced motion</td><td>respect <code>disableAnimations</code>; error shake → color-only <code>(proposed)</code></td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-rooms-module"><a class="header" href="#12--api-mapping-rooms-module">12 — API Mapping (Rooms Module)</a></h1> <ul> <li><a href="rooms/12_API_Mapping.html#e1--create-room">E1 — Create room</a></li> <li><a href="rooms/12_API_Mapping.html#e2--list-rooms">E2 — List rooms</a></li> <li><a href="rooms/12_API_Mapping.html#e3--get-room-by-id">E3 — Get room by ID</a></li> <li><a href="rooms/12_API_Mapping.html#e4--update-room">E4 — Update room</a></li> <li><a href="rooms/12_API_Mapping.html#e5--delete-room-soft">E5 — Delete room (soft)</a></li> <li><a href="rooms/12_API_Mapping.html#client-contract-summary-all-screens">Client contract summary (all screens)</a></li> <li><a href="rooms/12_API_Mapping.html#endpoint--screen-matrix">Endpoint → screen matrix</a></li> </ul> <blockquote> <p>Exact endpoints per screen. Wire contract per <a href="rooms/../00-shared/07_API_Conventions.html">00-shared/07</a>: base <code>/api/v1</code>, Bearer JWT, success <code>{success:true,message:"OK",data,meta?,timestamp,requestId}</code>, error envelope with codes. Only shapes in code are used. <code>(planned)</code>/<code>(forward-looking)</code> marked.</p> </blockquote> <hr /> <h2 id="e1--create-room"><a class="header" href="#e1--create-room">E1 — Create room</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>POST /api/v1/rooms</code> (<code>rooms.controller.ts:24-28</code>)</td></tr> <tr><td>Guard</td><td><code>JwtAuthGuard</code> only (<code>rooms.controller.ts:19</code>); <strong>RBAC <code>rooms.create</code> not enforced server-side</strong> (OQ-2, <code>permissions.constants.ts:51</code>); client gates UI</td></tr> <tr><td>Request</td><td><code>CreateRoomDto</code> (<code>create-room.dto.ts:11-38</code>) — name, code <strong>required</strong>; capacity, type (enum, default <code>classroom</code>), building, facilities optional</td></tr> <tr><td>Response</td><td>201 envelope, <code>data</code> = saved room doc (<code>rooms.service.ts:18-23</code>); <code>tenantId</code> injected from context, never from body (<code>base.repository.ts:32-36</code>)</td></tr> <tr><td>Errors</td><td>400 <code>VALIDATION_ERROR</code> (missing name/code, bad <code>type</code>); <strong>409 <code>ConflictException</code> "Room code "X" already exists."</strong> (<code>rooms.service.ts:19-21</code>); 401; 429 <code>RATE_LIMITED</code>; 5xx</td></tr> <tr><td>Client</td><td>S3 create; on success → detail + snackbar; 409 → inline under code field</td></tr> <tr><td>Cache</td><td>none (write); on success invalidate list cache key</td></tr> </tbody></table> </div> <h2 id="e2--list-rooms"><a class="header" href="#e2--list-rooms">E2 — List rooms</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>GET /api/v1/rooms?page&limit</code> (<code>rooms.controller.ts:30-34</code>)</td></tr> <tr><td>Params</td><td><code>page</code> ≥ 1 default 1; <code>limit</code> default 20 (<code>rooms.controller.ts:32</code>); <strong>no <code>sort</code>, <code>q</code>, or filters in code</strong> — <code>PaginationQueryDto.sort/q</code> (<code>pagination-query.dto.ts:21-29</code>) unused on this route (OQ-3); order = Mongo natural (<code>_id</code>) order</td></tr> <tr><td>Response</td><td>200 envelope: <code>data: Room[]</code> + <code>meta {page, limit, totalItems, totalPages, hasNext, hasPrevious}</code> (<code>rooms.service.ts:25-34</code>; <code>pagination-query.dto.ts:32-39,41-54</code>)</td></tr> <tr><td>Errors</td><td>401; 429; 5xx</td></tr> <tr><td>Client</td><td>S1; infinite scroll on <code>hasNext</code>; pull-to-refresh; client-side filter/search <code>(proposed)</code> until server params <code>(planned)</code></td></tr> <tr><td>Cache</td><td>client paginated cache <code>sl:{tenantId}:rooms:{page}:{limit}</code> TTL 5 min; invalidated on E1/E4/E5 success</td></tr> </tbody></table> </div> <h2 id="e3--get-room-by-id"><a class="header" href="#e3--get-room-by-id">E3 — Get room by ID</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>GET /api/v1/rooms/:id</code> (<code>rooms.controller.ts:36-40</code>)</td></tr> <tr><td>Response</td><td>200 envelope, <code>data</code> = room doc (<code>rooms.service.ts:36-40</code>)</td></tr> <tr><td>Errors</td><td>400 <code>VALIDATION_ERROR</code> (invalid ObjectId → CastError mapping, shared filter); 404 <code>RESOURCE_NOT_FOUND</code> "Room not found." (<code>rooms.service.ts:38</code>); cross-tenant id → 404, no existence leak (<code>base.repository.ts:24-29</code>); 401; 429</td></tr> <tr><td>Client</td><td>S2 detail + S3 edit prefill; stale-while-revalidate OK</td></tr> </tbody></table> </div> <h2 id="e4--update-room"><a class="header" href="#e4--update-room">E4 — Update room</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>PATCH /api/v1/rooms/:id</code> (<code>rooms.controller.ts:42-46</code>)</td></tr> <tr><td>Request</td><td><strong><code>CreateRoomDto</code></strong> (<code>rooms.controller.ts:44</code>) — there is no <code>update-room.dto.ts</code> (OQ-6); <code>name</code>+<code>code</code> remain required on PATCH; partial body → 400</td></tr> <tr><td>Behaviour</td><td><code>updateById</code> → <code>$set: dto</code> + <code>$inc: {version: 1}</code>, returns updated doc (<code>rooms.service.ts:42-46</code>; <code>base.repository.ts:57-66</code>)</td></tr> <tr><td>Errors</td><td>400 (missing required fields, bad enum); 404 (<code>rooms.service.ts:44</code>); <strong>duplicate-code update → uncaught E11000 unique-index error → 500 (OQ-1)</strong> (<code>room.schema.ts:38</code>); 401; 429</td></tr> <tr><td>Client</td><td>S3 edit; success → detail reconcile from response; <strong>client-side duplicate warning <code>(proposed)</code>; server pre-check <code>(planned)</code> OQ-1</strong></td></tr> <tr><td>Cache</td><td>invalidate list + detail keys on success</td></tr> </tbody></table> </div> <h2 id="e5--delete-room-soft"><a class="header" href="#e5--delete-room-soft">E5 — Delete room (soft)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>DELETE /api/v1/rooms/:id</code> (<code>rooms.controller.ts:48-52</code>)</td></tr> <tr><td>Behaviour</td><td>soft delete: <code>isDeleted:true, deletedAt, deletedBy</code> + <code>version</code> (<code>rooms.service.ts:48-51</code>; <code>base.repository.ts:68-74</code>); returns 200, <strong>no payload</strong> (void handler)</td></tr> <tr><td>Errors</td><td>404 (<code>rooms.service.ts:50</code>); 401; 429</td></tr> <tr><td>Gaps</td><td><strong>no in-use guard</strong> (OQ-4) — succeeds even if referenced by timetable/bookings; hard purge not scheduled (rooms never hard-deleted in code)</td></tr> <tr><td>Client</td><td>S4 typed-confirm dialog; on success remove row locally + snackbar; 404 → "already deleted"</td></tr> <tr><td>Audit</td><td><code>deletedAt</code>/<code>deletedBy</code> recorded (<code>base.schema.ts:23-27</code>) — future audit surface <code>(planned)</code> (<code>IMPLEMENTATION_PLAN.md</code> Phase 5)</td></tr> </tbody></table> </div> <hr /> <h2 id="client-contract-summary-all-screens-1"><a class="header" href="#client-contract-summary-all-screens-1">Client contract summary (all screens)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Concern</th><th>Rule</th></tr></thead><tbody> <tr><td>Auth</td><td>Bearer JWT; 401 → single-flight refresh → replay; fail → session expiry (<a href="rooms/../00-shared/06_State_Management.html">00-shared/06 §3.6</a>)</td></tr> <tr><td>RBAC</td><td><strong>server does not enforce <code>rooms.*</code> today</strong> (OQ-2); client gates by permission list from <code>permissions.constants.ts:50-53</code>; when server guard lands, treat 403 as route-hide</td></tr> <tr><td>Optimistic</td><td>none for mutations — server result always shown (delete is void; local removal after 200)</td></tr> <tr><td>Idempotency</td><td>PATCH/DELETE retry-safe; no <code>Idempotency-Key</code> support confirmed</td></tr> <tr><td>Offline</td><td>reads from last-good cache + banner; writes blocked (no module offline queue)</td></tr> <tr><td>Pagination</td><td><code>page/limit</code> + <code>meta</code> exact (<code>pagination-query.dto.ts:5-54</code>); infinite scroll driven by <code>hasNext</code></td></tr> <tr><td>Filtering</td><td>client-side <code>(proposed)</code>; server params <code>(planned)</code> OQ-3</td></tr> <tr><td>Realtime</td><td>no WS topics today; <code>(forward-looking)</code> <code>rooms.updated</code> for multi-device sync</td></tr> <tr><td>Error mapping</td><td><a href="rooms/../00-shared/06_State_Management.html">00-shared/06 §5</a>: 400 field errors, 403 hide/deny, 404 empty/not-found, 409 inline duplicate, 429 backoff, 5xx generic+requestId</td></tr> </tbody></table> </div> <h2 id="endpoint--screen-matrix"><a class="header" href="#endpoint--screen-matrix">Endpoint → screen matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th>Endpoint</th><th>S1 List</th><th>S2 Detail</th><th>S3 Editor</th><th>S4 Delete</th></tr></thead><tbody> <tr><td><code>POST /rooms</code></td><td>—</td><td>—</td><td>create submit</td><td>—</td></tr> <tr><td><code>GET /rooms?page&limit</code></td><td>load/load-more/refresh</td><td>—</td><td>duplicate hint <code>(proposed)</code></td><td>—</td></tr> <tr><td><code>GET /rooms/:id</code></td><td>—</td><td>load</td><td>edit prefill</td><td>—</td></tr> <tr><td><code>PATCH /rooms/:id</code></td><td>—</td><td>reconcile</td><td>edit submit</td><td>—</td></tr> <tr><td><code>DELETE /rooms/:id</code></td><td>row remove</td><td>menu trigger</td><td>—</td><td>confirm submit</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-rooms-module"><a class="header" href="#13--state-management-rooms-module">13 — State Management (Rooms Module)</a></h1> <ul> <li><a href="rooms/13_State_Management.html#1-cubit-map">1. Cubit map</a></li> <li><a href="rooms/13_State_Management.html#2-state-machine-generic-per-00-shared06-31">2. State machine (generic per 00-shared/06 §3.1)</a></li> <li><a href="rooms/13_State_Management.html#3-roomlistcubit">3. RoomListCubit</a></li> <li><a href="rooms/13_State_Management.html#4-roomdetailcubit">4. RoomDetailCubit</a></li> <li><a href="rooms/13_State_Management.html#5-roomeditorcubit">5. RoomEditorCubit</a></li> <li><a href="rooms/13_State_Management.html#6-caching--staleness-module-ttls">6. Caching & staleness (module TTLs)</a></li> <li><a href="rooms/13_State_Management.html#7-realtime--cross-cubit">7. Realtime & cross-cubit</a></li> <li><a href="rooms/13_State_Management.html#8-offline--connectivity">8. Offline & connectivity</a></li> <li><a href="rooms/13_State_Management.html#9-testing-hooks">9. Testing hooks</a></li> </ul> <blockquote> <p>Cubit architecture per <a href="rooms/../00-shared/06_State_Management.html">00-shared/06</a>. One cubit per screen; base <code>LoadState</code> (<code>Initial/Loading/Success/Error(ApiException)</code>), <code>PaginatedListMixin</code>, cache + SWR, optimistic updates, connectivity. All <code>(proposed)</code> client design.</p> </blockquote> <hr /> <h2 id="1-cubit-map-6"><a class="header" href="#1-cubit-map-6">1. Cubit map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Cubit</th><th>Screen(s)</th><th>Data sources</th><th>Notes</th></tr></thead><tbody> <tr><td><code>RoomListCubit</code></td><td>S1</td><td>E2 <code>GET /rooms?page&limit</code> (+ client filter <code>(proposed)</code>)</td><td><code>PaginatedListMixin<Room></code>; local filter/search state; cache invalidation on mutations</td></tr> <tr><td><code>RoomDetailCubit</code></td><td>S2</td><td>E3 <code>GET /rooms/:id</code></td><td>TTL 5 min SWR; reconciles after edit; pops on 404</td></tr> <tr><td><code>RoomEditorCubit</code></td><td>S3 create/edit</td><td>E1 <code>POST</code> / E4 <code>PATCH</code></td><td>form model + validation; 409 inline; dirty tracking</td></tr> <tr><td>(future) <code>RoomAvailabilityCubit</code></td><td>S2 availability</td><td>bookings module <code>(planned)</code></td><td><code>(planned)</code> — hidden until bookings lands</td></tr> <tr><td>(future) <code>RoomCheckinCubit</code></td><td>QR scan</td><td>QR endpoint <code>(forward-looking)</code></td><td><code>(forward-looking)</code></td></tr> </tbody></table> </div> <h2 id="2-state-machine-generic-per-00-shared06-31-1"><a class="header" href="#2-state-machine-generic-per-00-shared06-31-1">2. State machine (generic per <a href="rooms/../00-shared/06_State_Management.html">00-shared/06 §3.1</a>)</a></h2> <pre class="mermaid">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) </pre> <h2 id="3-roomlistcubit"><a class="header" href="#3-roomlistcubit">3. RoomListCubit</a></h2> <p>State: <code>page (1)</code>, <code>limit (20)</code>, <code>items: List<Room></code>, <code>hasNext</code>, <code>isLoadingMore</code>, <code>loadState</code>, <code>filters {types: Set<RoomType>, buildings: Set<String>, query}</code> <code>(proposed)</code> — client-side until server params land (OQ-3).</p> <p>Events: <code>LoadFirst()</code>, <code>LoadMore()</code>, <code>Refresh()</code>, <code>SetTypeFilter(t)</code>, <code>SetBuildingFilter(b)</code>, <code>SetQuery(q)</code>, <code>ClearFilters()</code>, <code>RoomChanged(room)</code> (post-mutation reconcile), <code>RoomDeleted(id)</code>.</p> <p>Load flow: cache hit (<code>sl:{tenant}:rooms:{page}:{limit}</code>, TTL 5 min) → Success(stale:true)</p> <ul> <li>background refetch; miss → Loading → Success/Error. On <code>LoadMore</code> while <code>hasNext</code> → append + dedupe by <code>_id</code>.</li> </ul> <pre class="mermaid">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 </pre> <h2 id="4-roomdetailcubit"><a class="header" href="#4-roomdetailcubit">4. RoomDetailCubit</a></h2> <p>State: <code>room</code>, <code>stale</code> (cache-served), <code>loadState</code>.</p> <p>Events: <code>LoadRoom(id)</code>, <code>Refresh()</code>, <code>RoomUpdated(room)</code> (reconcile from editor), <code>Deleted()</code>.</p> <p><strong>Key reducer:</strong> <code>Deleted()</code> → emit terminal state; UI pops to list (route-level), list cubit removes row via <code>RoomDeleted</code> event. <strong>404 → NotFound state</strong> (renders "Room not found" + back), not generic error — because cross-tenant and soft-deleted ids both 404 (<code>rooms.service.ts:38,44,50</code>).</p> <pre class="mermaid">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) </pre> <h2 id="5-roomeditorcubit"><a class="header" href="#5-roomeditorcubit">5. RoomEditorCubit</a></h2> <p>State: <code>form: RoomFormModel</code> (F1–F6), <code>initial: RoomFormModel</code> (dirty baseline), <code>submitting</code>, <code>fieldErrors: Map<String,String></code>, <code>duplicateHint: bool</code> <code>(proposed)</code>, <code>saveResult</code>.</p> <p>Events: <code>InitCreate()</code>, <code>InitEdit(room)</code>, <code>FieldChanged(field, value)</code>, <code>CodeChanged(code)</code> (triggers debounced duplicate hint against list cache), <code>Submit()</code>, <code>Discard()</code>.</p> <p>Reducers: submit → <code>submitting</code> → on 201/200 emit <code>RoomChanged(room)</code> (via module bus) + pop + snackbar; on 409 → <code>duplicateHint=true</code>, inline error, no reset; on 400 → <code>fieldErrors</code> mapped from envelope <code>details[]</code>; on 404 (edit) → pop + "Room not found".</p> <pre class="mermaid">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 </pre> <h2 id="6-caching--staleness-module-ttls"><a class="header" href="#6-caching--staleness-module-ttls">6. Caching & staleness (module TTLs)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Data</th><th>Cache key</th><th>TTL</th><th>Notes</th></tr></thead><tbody> <tr><td>Room list page</td><td><code>sl:{tenant}:rooms:{page}:{limit}</code></td><td>5 min SWR</td><td>invalidated on E1/E4/E5 success</td></tr> <tr><td>Room detail</td><td><code>sl:{tenant}:rooms:{id}</code></td><td>5 min SWR</td><td>reconcile after edit; pop on 404</td></tr> <tr><td>Duplicate-hint code set <code>(proposed)</code></td><td>derived from list cache</td><td>—</td><td>not authoritative; server 409 is</td></tr> <tr><td>Buildings/type dictionary <code>(proposed)</code></td><td>derived from list cache</td><td>—</td><td>refresh on list refresh</td></tr> </tbody></table> </div> <h2 id="7-realtime--cross-cubit"><a class="header" href="#7-realtime--cross-cubit">7. Realtime & cross-cubit</a></h2> <ul> <li>No WS topics today; <code>(forward-looking)</code>: subscribe <code>rooms.updated</code> → invalidate list + detail caches + refetch (multi-device admin edits).</li> <li>Cross-cubit invalidation: after E1/E4 success → bump <code>rooms:{id}</code> + list keys; <code>RoomDeleted</code> → list row removal without refetch (delete is void — <code>rooms.service.ts:48</code>).</li> <li>Module bus events: <code>RoomChanged(room)</code>, <code>RoomDeleted(id)</code> consumed by list; editor listens to nothing (form state is local).</li> <li>Future <code>(planned)</code>: bookings availability state lives in the bookings module's cubits, not here.</li> </ul> <h2 id="8-offline--connectivity"><a class="header" href="#8-offline--connectivity">8. Offline & connectivity</a></h2> <ul> <li>Reads: last-good cache + <code>AppOfflineBanner</code>; <code>LoadMore</code> blocked offline (no stale pages).</li> <li>Writes: blocked in <code>RoomEditorCubit.Submit()</code> (emit error "You're offline", draft kept).</li> <li>Reconnect: banner clears; user re-triggers.</li> </ul> <h2 id="9-testing-hooks-1"><a class="header" href="#9-testing-hooks-1">9. Testing hooks</a></h2> <ul> <li>Pure-Dart cubits, mocked repositories; widget tests per state machine (Loading/Success/Error/NotFound/Empty) + 409-inline + delete-flow (<code>00-shared/06 §6</code>).</li> <li>Golden: list with filters applied; editor with duplicate hint; typed-confirm disabled state.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-rooms-module"><a class="header" href="#14--qa-checklist-rooms-module">14 — QA Checklist (Rooms Module)</a></h1> <ul> <li><a href="rooms/14_QA_Checklist.html#1-crud--create-e1">1. CRUD — create (E1)</a></li> <li><a href="rooms/14_QA_Checklist.html#2-crud--list-e2">2. CRUD — list (E2)</a></li> <li><a href="rooms/14_QA_Checklist.html#3-crud--get-e3">3. CRUD — get (E3)</a></li> <li><a href="rooms/14_QA_Checklist.html#4-crud--update-e4">4. CRUD — update (E4)</a></li> <li><a href="rooms/14_QA_Checklist.html#5-crud--delete-e5">5. CRUD — delete (E5)</a></li> <li><a href="rooms/14_QA_Checklist.html#6-duplicates-create-path-contract">6. Duplicates (create-path contract)</a></li> <li><a href="rooms/14_QA_Checklist.html#7-capacity-validation">7. Capacity validation</a></li> <li><a href="rooms/14_QA_Checklist.html#8-permission--isolation">8. Permission & isolation</a></li> <li><a href="rooms/14_QA_Checklist.html#9-ux--offline--edge">9. UX / offline / edge</a></li> <li><a href="rooms/14_QA_Checklist.html#10-accessibility-spot-full-baseline-00-shared09">10. Accessibility (spot; full baseline 00-shared/09)</a></li> <li><a href="rooms/14_QA_Checklist.html#11-performance-budgets-00-shared10-1">11. Performance (budgets 00-shared/10 §1)</a></li> <li><a href="rooms/14_QA_Checklist.html#12-regression-suite-minimum">12. Regression suite (minimum)</a></li> </ul> <blockquote> <p>Feature-level QA for the Rooms module, derived from the implemented backend (<code>src/modules/rooms/**</code>) and the design docs <a href="rooms/01_Product_Overview.html">01–13</a>. Server gaps are marked <code>(planned)</code>; tests that would fail today are marked <strong>KNOWN-GAP</strong>. Baseline: <a href="rooms/../00-shared/10_QA_Baseline.html">00-shared/10</a> (QA Baseline), <a href="rooms/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a> (Accessibility).</p> </blockquote> <hr /> <h2 id="1-crud--create-e1"><a class="header" href="#1-crud--create-e1">1. CRUD — create (E1)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expected</th><th>Source</th></tr></thead><tbody> <tr><td>1.1</td><td>POST with valid full payload</td><td>201, doc with injected <code>tenantId</code>, defaults applied (<code>type=classroom</code>, <code>isDeleted=false</code>, <code>version=0</code>)</td><td><code>rooms.service.ts:18-23</code>; <code>room.schema.ts:27-28</code>; <code>base.repository.ts:32-36</code></td></tr> <tr><td>1.2</td><td>POST missing <code>name</code></td><td>400 <code>VALIDATION_ERROR</code></td><td><code>create-room.dto.ts:12-14</code></td></tr> <tr><td>1.3</td><td>POST missing <code>code</code></td><td>400</td><td><code>create-room.dto.ts:16-18</code></td></tr> <tr><td>1.4</td><td>POST duplicate <code>code</code> in same tenant</td><td><strong>409</strong> "Room code "X" already exists."</td><td><code>rooms.service.ts:19-21</code></td></tr> <tr><td>1.5</td><td>POST same <code>code</code> in <strong>different tenant</strong></td><td>201 (per-tenant uniqueness)</td><td><code>room.schema.ts:38</code> (<code>{tenantId, code}</code> index)</td></tr> <tr><td>1.6</td><td>POST invalid <code>type</code></td><td>400 (enum)</td><td><code>create-room.dto.ts:25-28</code></td></tr> <tr><td>1.7</td><td>POST <code>capacity: 0</code> / <code>-5</code> / <code>2.5</code></td><td><strong>KNOWN-GAP:</strong> accepted (no bounds — OQ-7); client blocks <code><1</code> <code>(proposed)</code>; server <code>@Min(1)</code> <code>(planned)</code></td><td><code>create-room.dto.ts:20-23</code></td></tr> <tr><td>1.8</td><td>POST body includes <code>tenantId</code>/<code>isDeleted</code></td><td>ignored — overridden by context/scope (never trust body)</td><td><code>base.repository.ts:32-36</code></td></tr> <tr><td>1.9</td><td>POST without JWT</td><td>401</td><td><code>rooms.controller.ts:19</code></td></tr> </tbody></table> </div> <h2 id="2-crud--list-e2"><a class="header" href="#2-crud--list-e2">2. CRUD — list (E2)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expected</th><th>Source</th></tr></thead><tbody> <tr><td>2.1</td><td>GET with defaults</td><td>200, <code>data</code> ≤ 20, <code>meta {page:1,limit:20,totalItems,totalPages,hasNext,hasPrevious}</code></td><td><code>rooms.controller.ts:32</code>; <code>rooms.service.ts:29-33</code>; <code>pagination-query.dto.ts:32-39</code></td></tr> <tr><td>2.2</td><td><code>page=2</code>, <code>limit=10</code></td><td>correct slice; <code>hasNext</code> reflects remainder</td><td><code>rooms.service.ts:29-32</code></td></tr> <tr><td>2.3</td><td><code>page=0</code> / negative</td><td><strong>KNOWN-GAP:</strong> no DTO validation on this route — raw <code>Number()</code> cast (<code>rooms.controller.ts:32-33</code>); <code>page=0</code> → <code>skip=-10</code> (Mongoose clamps, but contract unguarded). Shared DTO caps 1–100, <strong>not applied here</strong> (OQ-3). Server validation <code>(planned)</code></td><td><code>rooms.controller.ts:32-33</code>; <code>pagination-query.dto.ts:5-19</code></td></tr> <tr><td>2.4</td><td><code>sort</code> / <code>q</code> params</td><td><strong>KNOWN-GAP:</strong> ignored — list is Mongo natural order (OQ-3)</td><td><code>rooms.controller.ts:32</code></td></tr> <tr><td>2.5</td><td>Deleted rooms in list</td><td>excluded (<code>isDeleted:false</code> scope)</td><td><code>base.repository.ts:20-30</code></td></tr> <tr><td>2.6</td><td>Cross-tenant rows</td><td>never visible (tenant scope)</td><td><code>base.repository.ts:24-29</code></td></tr> <tr><td>2.7</td><td>Empty tenant</td><td>200, <code>data: []</code>, <code>totalItems: 0</code></td><td><code>rooms.service.ts:29-33</code></td></tr> </tbody></table> </div> <h2 id="3-crud--get-e3"><a class="header" href="#3-crud--get-e3">3. CRUD — get (E3)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expected</th><th>Source</th></tr></thead><tbody> <tr><td>3.1</td><td>Existing id</td><td>200 full doc</td><td><code>rooms.service.ts:36-40</code></td></tr> <tr><td>3.2</td><td>Unknown / soft-deleted id</td><td>404 "Room not found."</td><td><code>rooms.service.ts:38</code>; <code>base.repository.ts:20-30</code></td></tr> <tr><td>3.3</td><td>Other tenant's id</td><td>404 (no leak)</td><td><code>base.repository.ts:24-29</code></td></tr> <tr><td>3.4</td><td>Malformed id (not ObjectId)</td><td>400 <code>VALIDATION_ERROR</code></td><td>shared CastError filter</td></tr> </tbody></table> </div> <h2 id="4-crud--update-e4"><a class="header" href="#4-crud--update-e4">4. CRUD — update (E4)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expected</th><th>Source</th></tr></thead><tbody> <tr><td>4.1</td><td>PATCH full valid body</td><td>200 updated doc, <code>version</code> +1</td><td><code>base.repository.ts:57-66</code></td></tr> <tr><td>4.2</td><td>PATCH <strong>partial</strong> body (only <code>building</code>)</td><td><strong>KNOWN-GAP:</strong> 400 — <code>CreateRoomDto</code> requires <code>name</code>+<code>code</code> on PATCH (OQ-6, <code>rooms.controller.ts:44</code>); true partial DTO <code>(planned)</code></td><td><code>rooms.controller.ts:44</code></td></tr> <tr><td>4.3</td><td>PATCH code → existing code</td><td><strong>KNOWN-GAP:</strong> 500 (E11000) — no service pre-check (OQ-1); 409 <code>(planned)</code></td><td><code>rooms.service.ts:42-46</code>; <code>room.schema.ts:38</code></td></tr> <tr><td>4.4</td><td>PATCH unknown id</td><td>404</td><td><code>rooms.service.ts:44</code></td></tr> <tr><td>4.5</td><td>PATCH <code>type</code> invalid</td><td>400</td><td><code>create-room.dto.ts:25-28</code></td></tr> <tr><td>4.6</td><td>Update reflection in list/detail</td><td>fresh GET shows change (no stale cache server-side)</td><td><code>base.repository.ts:57-66</code></td></tr> </tbody></table> </div> <h2 id="5-crud--delete-e5"><a class="header" href="#5-crud--delete-e5">5. CRUD — delete (E5)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expected</th><th>Source</th></tr></thead><tbody> <tr><td>5.1</td><td>DELETE existing</td><td>200, no payload; doc <code>isDeleted=true, deletedAt</code> set, <code>version</code> +1</td><td><code>rooms.service.ts:48-51</code>; <code>base.repository.ts:68-74</code></td></tr> <tr><td>5.2</td><td>GET after delete</td><td>404 (soft-deleted hidden)</td><td><code>base.repository.ts:20-30</code></td></tr> <tr><td>5.3</td><td>DELETE twice</td><td>second → 404</td><td><code>rooms.service.ts:50</code></td></tr> <tr><td>5.4</td><td>DELETE unknown / other-tenant id</td><td>404</td><td><code>rooms.service.ts:50</code>; <code>base.repository.ts:24-29</code></td></tr> <tr><td>5.5</td><td>DELETE room referenced by timetable/booking</td><td><strong>KNOWN-GAP:</strong> succeeds — no in-use guard (OQ-4); guard <code>(planned)</code></td><td><code>rooms.service.ts:48-51</code></td></tr> <tr><td>5.6</td><td>Re-create same <code>code</code> after delete</td><td>201 (unique index only blocks live docs; <code>isDeleted</code> doc remains)</td><td><code>room.schema.ts:38</code>; <code>base.repository.ts:20-30</code></td></tr> </tbody></table> </div> <h2 id="6-duplicates-create-path-contract"><a class="header" href="#6-duplicates-create-path-contract">6. Duplicates (create-path contract)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expected</th></tr></thead><tbody> <tr><td>6.1</td><td>Same code, same tenant, different case (<code>LAB-02</code> vs <code>lab-02</code>)</td><td><strong>KNOWN-GAP:</strong> both persist — index is case-sensitive; client uppercases <code>(proposed)</code>; normalized index <code>(planned)</code> (OQ-5/1)</td></tr> <tr><td>6.2</td><td>Trimmed equality (<code>" A-1 "</code> vs <code>"A-1"</code>)</td><td>both persist (trim happens at write: <code>room.schema.ts:21-22</code> — DB stores trimmed; two docs only if created pre-trim) — verify via API</td></tr> </tbody></table> </div> <h2 id="7-capacity-validation"><a class="header" href="#7-capacity-validation">7. Capacity validation</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expected</th></tr></thead><tbody> <tr><td>7.1</td><td>Client input <code>< 1</code></td><td>blocked with hint <code>(proposed)</code> (OQ-7)</td></tr> <tr><td>7.2</td><td>Client non-numeric</td><td>blocked via numeric keyboard + regex <code>(proposed)</code></td></tr> <tr><td>7.3</td><td>Server accepts negative today</td><td><strong>KNOWN-GAP</strong> (documented; <code>@Min(1)</code> <code>(planned)</code>)</td></tr> <tr><td>7.4</td><td>Absent capacity</td><td>no badge shown; never "Cap 0"</td></tr> </tbody></table> </div> <h2 id="8-permission--isolation"><a class="header" href="#8-permission--isolation">8. Permission & isolation</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expected</th></tr></thead><tbody> <tr><td>8.1</td><td><code>rooms.read/create/update/delete</code> present in permission catalog</td><td>yes — <code>permissions.constants.ts:50-53</code></td></tr> <tr><td>8.2</td><td>Server enforces them</td><td><strong>KNOWN-GAP:</strong> only <code>JwtAuthGuard</code> (OQ-2, <code>rooms.controller.ts:19</code>); RBAC guard <code>(planned)</code></td></tr> <tr><td>8.3</td><td>Client hides create/edit/delete without perms</td><td>yes <code>(proposed)</code> — per 12's client contract</td></tr> <tr><td>8.4</td><td>Tenant isolation on all 5 endpoints</td><td>structural (scope in <code>base.repository.ts:20-30</code>) — test with two tenants</td></tr> <tr><td>8.5</td><td>Platform admin scope bypass</td><td>intentional (<code>base.repository.ts:21-23</code>) — platform-only tooling</td></tr> </tbody></table> </div> <h2 id="9-ux--offline--edge"><a class="header" href="#9-ux--offline--edge">9. UX / offline / edge</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expected</th></tr></thead><tbody> <tr><td>9.1</td><td>Infinite scroll dedupe</td><td>no duplicate rows across pages (dedupe by <code>_id</code>)</td></tr> <tr><td>9.2</td><td>Pull-to-refresh bypasses cache</td><td>fresh <code>page=1</code></td></tr> <tr><td>9.3</td><td>Offline list</td><td>cached rows + banner; load-more blocked</td></tr> <tr><td>9.4</td><td>Offline submit</td><td>blocked + draft kept</td></tr> <tr><td>9.5</td><td>404 on detail</td><td>"Room not found" state, not generic error</td></tr> <tr><td>9.6</td><td>Empty list</td><td>"No rooms yet" + create CTA (gated)</td></tr> <tr><td>9.7</td><td>Filter no-match</td><td>"No rooms match your filters" + clear</td></tr> <tr><td>9.8</td><td>Delete typed-confirm mismatch</td><td>button disabled until exact name match</td></tr> <tr><td>9.9</td><td>Discard dirty form</td><td>confirm dialog; no silent data loss</td></tr> </tbody></table> </div> <h2 id="10-accessibility-spot-full-baseline-00-shared09"><a class="header" href="#10-accessibility-spot-full-baseline-00-shared09">10. Accessibility (spot; full baseline <a href="rooms/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th></tr></thead><tbody> <tr><td>10.1</td><td>RoomCard single semantics label; chips labeled</td></tr> <tr><td>10.2</td><td>Field errors in live regions; focus first invalid</td></tr> <tr><td>10.3</td><td>Contrast on chips/badges ≥ 4.5:1; touch targets ≥ 44 dp</td></tr> <tr><td>10.4</td><td><code>disableAnimations</code> honored (no essential motion)</td></tr> </tbody></table> </div> <h2 id="11-performance-budgets-00-shared10-1"><a class="header" href="#11-performance-budgets-00-shared10-1">11. Performance (budgets <a href="rooms/../00-shared/10_QA_Baseline.html">00-shared/10 §1</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Target</th></tr></thead><tbody> <tr><td>11.1</td><td>List p95 (server)</td><td>< 300 ms (find + count on indexed scope)</td></tr> <tr><td>11.2</td><td>Detail p95</td><td>< 250 ms</td></tr> <tr><td>11.3</td><td>Infinite scroll jank</td><td>no layout jank; <code>ListView.builder</code></td></tr> <tr><td>11.4</td><td>Debounces</td><td>search + code-hint 300 ms</td></tr> </tbody></table> </div> <h2 id="12-regression-suite-minimum"><a class="header" href="#12-regression-suite-minimum">12. Regression suite (minimum)</a></h2> <ul> <li>Unit: <code>RoomsService</code> — dup create (409), 404s (findById/update/remove), pagination math (<code>buildPaginationMeta</code> — <code>pagination-query.dto.ts:41-54</code>).</li> <li>E2E: create → list → get → update → delete → get(404) → list excludes; two-tenant isolation; duplicate code 409.</li> <li>Widget <code>(proposed)</code>: list load/empty/error, editor 409 inline, delete typed-confirm, permission-gated FAB.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-rooms-module"><a class="header" href="#15--flutter-implementation-guide-rooms-module">15 — Flutter Implementation Guide (Rooms Module)</a></h1> <ul> <li><a href="rooms/15_Flutter_Implementation_Guide.html#1-structure">1. Structure</a></li> <li><a href="rooms/15_Flutter_Implementation_Guide.html#2-models--enums-exact-from-schema">2. Models & enums (exact from schema)</a></li> <li><a href="rooms/15_Flutter_Implementation_Guide.html#3-repositories">3. Repositories</a></li> <li><a href="rooms/15_Flutter_Implementation_Guide.html#4-cubits-see-13">4. Cubits (see <code>13</code>)</a></li> <li><a href="rooms/15_Flutter_Implementation_Guide.html#5-navigation-go_router">5. Navigation (go_router)</a></li> <li><a href="rooms/15_Flutter_Implementation_Guide.html#6-theme">6. Theme</a></li> <li><a href="rooms/15_Flutter_Implementation_Guide.html#7-extensions">7. Extensions</a></li> <li><a href="rooms/15_Flutter_Implementation_Guide.html#8-localization">8. Localization</a></li> <li><a href="rooms/15_Flutter_Implementation_Guide.html#9-testing">9. Testing</a></li> <li><a href="rooms/15_Flutter_Implementation_Guide.html#10-performance">10. Performance</a></li> <li><a href="rooms/15_Flutter_Implementation_Guide.html#11-open-items-to-wire-when-backend-lands">11. Open items to wire when backend lands</a></li> </ul> <blockquote> <p>Module extension of <a href="rooms/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a> (Flutter App Architecture). Structure, widgets, cubits, repositories, DTOs, models, navigation, theme, extensions, localization, testing, performance. Forward-looking.</p> </blockquote> <hr /> <h2 id="1-structure-1"><a class="header" href="#1-structure-1">1. Structure</a></h2> <pre><code>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 </code></pre> <h2 id="2-models--enums-exact-from-schema-1"><a class="header" href="#2-models--enums-exact-from-schema-1">2. Models & enums (exact from schema)</a></h2> <pre><code class="language-dart">enum RoomType { classroom, lab, library, office, hall, other } // room.schema.ts:7-14 </code></pre> <p><code>Room</code> mirrors <code>room.schema.ts:16-35</code> + <code>base.schema.ts</code>: <code>id</code>, <code>tenantId</code>, <code>name</code>, <code>code</code>, <code>capacity?</code>, <code>type</code> (default <code>classroom</code>), <code>building?</code>, <code>facilities: List<String></code>, <code>createdBy?/updatedBy?</code>, <code>isDeleted</code>, <code>deletedAt?</code>, <code>deletedBy?</code>, <code>version</code>, <code>createdAt</code>, <code>updatedAt</code>. Ids <code>String</code>; dates <code>DateTime</code> (parse ISO). <strong>Never send DTOs to widgets</strong> (<code>00-shared/11 §4</code>).</p> <h2 id="3-repositories-1"><a class="header" href="#3-repositories-1">3. Repositories</a></h2> <p><code>RoomsRepository</code> (dio via <code>AppDio</code> bearer/refresh/error interceptors — <code>00-shared/11 §5</code>):</p> <pre><code class="language-dart">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!) </code></pre> <ul> <li>E5 returns void because the handler is void (<code>rooms.service.ts:48</code>) — local row removal after 200, never a payload.</li> <li>Typed exceptions: <code>ApiException(code, status, fieldDetails, message)</code> from the error interceptor (<code>00-shared/06 §5</code>); <code>DUPLICATE_RESOURCE</code> (409) handled as field error in the editor.</li> </ul> <h2 id="4-cubits-see-13-1"><a class="header" href="#4-cubits-see-13-1">4. Cubits (see <code>13</code>)</a></h2> <p><code>RoomListCubit</code> (PaginatedListMixin + client filters <code>(proposed)</code>), <code>RoomDetailCubit</code> (404 → NotFound state), <code>RoomEditorCubit</code> (form + dirty + 409-inline). All pure-Dart, DI via <code>get_it</code> lazy factories (<code>00-shared/11 §2</code>).</p> <h2 id="5-navigation-go_router-2"><a class="header" href="#5-navigation-go_router-2">5. Navigation (go_router)</a></h2> <pre><code class="language-dart">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')]), </code></pre> <p>Permission guards mirror <code>permissions.constants.ts:50-53</code> (<code>rooms.read/create/update/delete</code>). <strong>Server does not enforce these today</strong> (OQ-2 — <code>rooms.controller.ts:19</code>); the client guard is the only gate until the backend RBAC guard lands; server remains authoritative later (403 → 403 screen). Master-detail via <code>StatefulShellBranch</code> at ≥ 840 dp (<code>00-shared/05 §3</code>). Deep links <code>(forward-looking)</code>: <code>studylyon://rooms</code>, <code>studylyon://rooms/:id</code>.</p> <h2 id="6-theme-4"><a class="header" href="#6-theme-4">6. Theme</a></h2> <p>Standard <code>AppTheme</code> tokens (<code>00-shared/02</code>, <code>11</code>); type-tinted icons via <code>colorScheme.*Container</code> variants <code>(proposed)</code>. No literal colors in widgets (<code>02 §10</code>, <code>04 §7</code>).</p> <h2 id="7-extensions-4"><a class="header" href="#7-extensions-4">7. Extensions</a></h2> <p>Reuse shared (<code>00-shared/11 §8</code>): <code>DateTime.toDisplayDate</code>, <code>context.showAppSnackbar</code>, etc. Module additions: <code>RoomType.displayName</code>, <code>RoomType.iconData</code> (→ <code>RoomTypeIcon</code>), <code>Room.capacityLabel</code> ("Cap 40" / absent), <code>String.normalizeRoomCode</code> (uppercase + trim — wraps <code>domain/room_code.dart</code>).</p> <h2 id="8-localization-3"><a class="header" href="#8-localization-3">8. Localization</a></h2> <p>Keys under <code>features/rooms/</code> namespace in <code>.arb</code> (en + fr + hi smoke): <code>rooms.title</code>, <code>rooms.list.empty</code>, <code>rooms.list.filter.empty</code>, <code>rooms.detail.notFound</code>, <code>rooms.editor.save</code>, <code>rooms.editor.code.duplicate</code>, <code>rooms.editor.capacity.min</code>, <code>rooms.delete.confirm.title</code>, <code>rooms.delete.confirm.typeName</code>, <code>rooms.snackbar.{created,updated,deleted,notFound}</code>, <code>rooms.offline</code>. Server messages rendered via error-code→key map with business-4xx fallback (<code>00-shared/11 §9</code>, <code>07 §11</code>).</p> <h2 id="9-testing-2"><a class="header" href="#9-testing-2">9. Testing</a></h2> <div class="table-wrapper"><table><thead><tr><th>Layer</th><th>Coverage</th></tr></thead><tbody> <tr><td>Unit</td><td><code>room_code</code> normalization vectors ("lab 2" → "LAB 2"); enum mapping; <code>capacityLabel</code>; pagination meta mapping (<code>pagination-query.dto.ts:41-54</code>)</td></tr> <tr><td>Cubit</td><td><code>RoomListCubit</code> pagination + dedupe + filter reset; <code>RoomDetailCubit</code> 404→NotFound; <code>RoomEditorCubit</code> 409-inline + dirty/discard</td></tr> <tr><td>Widget</td><td>S1 loading/empty/error + filter chips; S3 code-duplicate hint; S4 typed-confirm disabled state; permission-gated FAB hidden</td></tr> <tr><td>Golden</td><td>3 screens × light/dark × 3 sizes; new components (<code>07 §Golden</code>)</td></tr> <tr><td>Integration</td><td>create → list → detail → edit → delete → 404; duplicate-code journey</td></tr> <tr><td>E2E (device cloud)</td><td>P0: admin creates room, edits, deletes; read-only role sees list without FAB</td></tr> </tbody></table> </div> <p>Run: <code>flutter analyze</code>, <code>flutter test</code>, <code>flutter test integration_test</code> (<code>00-shared/11 §12</code>).</p> <h2 id="10-performance-2"><a class="header" href="#10-performance-2">10. Performance</a></h2> <ul> <li><code>ListView.builder</code> for S1; <code>RepaintBoundary</code> around RoomCards; <code>AnimatedSize</code> for facility expand.</li> <li>Debounces: search 300 ms, code-duplicate hint 300 ms.</li> <li>Caches keyed <code>sl:{tenant}:rooms:...</code> with TTLs from <code>13 §6</code>; <code>RefreshIndicator</code> bypasses cache.</li> <li>Profile against <code>00-shared/10 §1</code> budgets (list ≤ 300 ms p95, detail ≤ 250 ms).</li> </ul> <h2 id="11-open-items-to-wire-when-backend-lands-1"><a class="header" href="#11-open-items-to-wire-when-backend-lands-1">11. Open items to wire when backend lands</a></h2> <ol> <li><strong>Server filter/sort/search on <code>GET /rooms</code></strong> (OQ-3) — drop client-side <code>RoomFilterBar</code> filtering, move to query params; add <code>sort</code>/<code>q</code> support.</li> <li><strong>Update-path duplicate check</strong> (OQ-1) — remove client-only warning; rely on 409; map <code>DUPLICATE_RESOURCE</code> inline on edit.</li> <li><strong>RBAC guard on controller</strong> (OQ-2) — server becomes authoritative; keep client guards as UX, handle real 403.</li> <li><strong><code>update-room.dto.ts</code></strong> (OQ-6) — partial PATCH; editor sends only dirty fields.</li> <li><strong>Capacity bounds</strong> (OQ-7) — server <code>@Min(1)</code>; client min mirrors exactly.</li> <li><strong>In-use delete guard</strong> (OQ-4) — delete dialog shows "in use by N timetables/bookings" with server truth; blocked delete → snackbar.</li> <li><strong>Bookings module</strong> <code>(planned)</code> — availability section + Book CTA on S2; bookings cubit + endpoints.</li> <li><strong>QR signage</strong> <code>(forward-looking)</code> — <code>QRCodeCard</code> on S2 + scan route; data already present (<code>room.code</code>, <code>room.schema.ts:21-22</code>).</li> <li><strong>Utilization analytics</strong> <code>(proposed)</code> — reports surface reading bookings aggregates.</li> <li><strong>WS <code>rooms.updated</code></strong> <code>(forward-looking)</code> — cache invalidation on multi-device edits.</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-scheduler-module"><a class="header" href="#01--product-overview-scheduler-module">01 — Product Overview (Scheduler Module)</a></h1> <ul> <li><a href="scheduler/01_Product_Overview.html#1-what-the-module-is">1. What the module is</a></li> <li><a href="scheduler/01_Product_Overview.html#2-core-domain-facts-from-source">2. Core domain facts (from source)</a></li> <li><a href="scheduler/01_Product_Overview.html#3-who-uses-it">3. Who uses it</a></li> <li><a href="scheduler/01_Product_Overview.html#4-scope-in--out">4. Scope in / out</a></li> <li><a href="scheduler/01_Product_Overview.html#5-prd-native-app-exclusion-flagged">5. PRD native-app exclusion (flagged)</a></li> <li><a href="scheduler/01_Product_Overview.html#6-client-surface-screens--see-0506">6. Client surface (screens) — see 05/06</a></li> <li><a href="scheduler/01_Product_Overview.html#7-tenant-context-handling">7. Tenant context handling</a></li> </ul> <blockquote> <p>The Scheduler is the <strong>recurring-job engine</strong> of StudyLyon. It registers BullMQ <strong>repeatable jobs</strong> at boot and fans them out to per-domain queues. Derived from <code>src/modules/scheduler/**</code> (module, service, controller, DTO, <code>jobs/*.job.ts</code>), <code>src/infrastructure/bullmq/</code> (queue constants, module, DLQ setup), <code>src/infrastructure/workers/*.worker.ts</code>, and <code>docs/IMPLEMENTATION_PLAN.md</code>. Nothing here is invented; plan-only capability is marked <code>(planned)</code>, roadmap-only <code>(forward-looking)</code>, analytics <code>(proposed)</code>. Shared platform context: <a href="scheduler/../00-shared/01_Product_Overview.html">00-shared/01</a>.</p> </blockquote> <hr /> <h2 id="1-what-the-module-is-3"><a class="header" href="#1-what-the-module-is-3">1. What the module is</a></h2> <p>An <strong>internal infrastructure module</strong> 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 <strong>no user-facing feature surface</strong> — its "users" are other modules' queues and the platform operator who keeps it healthy.</p> <p>The mechanism is <strong>BullMQ repeatable jobs, not <code>@Cron</code></strong>: on module init the service registers 10 default repeatable jobs onto 10 queues (<code>scheduler.service.ts:47-126</code>), each <code>queue.add(..., { repeat: { pattern, tz } })</code> (<code>scheduler.service.ts:135-147</code>). Registration is <strong>idempotent</strong> — an existing job with the same name+pattern is skipped (<code>scheduler.service.ts:128-133</code>).</p> <h2 id="2-core-domain-facts-from-source-1"><a class="header" href="#2-core-domain-facts-from-source-1">2. Core domain facts (from source)</a></h2> <ul> <li><strong>Trigger model</strong> — a repeatable job is <code>{ name, pattern, queue, queueName, tz }</code> (<code>scheduler.service.ts:11-18</code>). Every job runs in <strong>UTC</strong> (<code>:54</code>, <code>:62</code>, <code>:70</code>, <code>:76</code>, <code>:82</code>, <code>:89</code>, <code>:96</code>, <code>:103</code>, <code>:110</code>, <code>:117</code>).</li> <li><strong>Job payload convention</strong> — every trigger enqueues <code>{ eventType, tenantId: 'system', correlationId: crypto.randomUUID(), actorId: 'scheduler' }</code> (<code>scheduler.service.ts:136-142</code>). <code>tenantId: 'system'</code> means triggers are tenant-neutral; tenant scoping happens in the <strong>workers</strong> (see §7).</li> <li><strong>Retention on triggers</strong> — <code>removeOnComplete: { age: 3600, count: 100 }</code>, <code>removeOnFail: { age: 86400 * 7 }</code> (<code>scheduler.service.ts:145-146</code>).</li> <li><strong>Default schedules</strong> (all UTC, <code>scheduler.service.ts:48-119</code>):</li> </ul> <div class="table-wrapper"><table><thead><tr><th>Job name</th><th>Cron pattern</th><th>Meaning</th><th>Target queue</th></tr></thead><tbody> <tr><td><code>overdue-scan</code></td><td><code>0 6 * * *</code></td><td>daily 06:00</td><td><code>invoice-generate</code></td></tr> <tr><td><code>daily-digest</code></td><td><code>0 9 * * *</code></td><td>daily 09:00</td><td><code>emails</code></td></tr> <tr><td><code>dashboard-rebuild</code></td><td><code>*/5 * * * *</code></td><td>every 5 min</td><td><code>cache-rebuild</code></td></tr> <tr><td><code>biometric-sync</code></td><td><code>*/15 * * * *</code></td><td>every 15 min</td><td><code>biometric-sync</code></td></tr> <tr><td><code>audit-flush</code></td><td><code>*/1 * * * *</code></td><td>every minute</td><td><code>audit-write</code></td></tr> <tr><td><code>retention-archive</code></td><td><code>0 2 * * 0</code></td><td>Sun 02:00</td><td><code>tenant-purge</code></td></tr> <tr><td><code>fee-reminder</code></td><td><code>0 8 * * *</code></td><td>daily 08:00</td><td><code>payment-reminder</code></td></tr> <tr><td><code>attendance-report-daily</code></td><td><code>0 7 * * *</code></td><td>daily 07:00</td><td><code>report-generate</code></td></tr> <tr><td><code>admission-reminder-scan</code></td><td><code>0 8 * * *</code></td><td>daily 08:00</td><td><code>admission-reminder</code></td></tr> <tr><td><code>admission-expiry-scan</code></td><td><code>0 2 * * *</code></td><td>daily 02:00</td><td><code>admission-expiry</code></td></tr> </tbody></table> </div> <ul> <li><strong>Fan-out jobs</strong> — four job classes exist (<code>scheduler.module.ts:15-22</code>): <code>OverdueScanJob</code> (<code>jobs/overdue-scan.job.ts:12-19</code>, enqueues <code>check-overdue</code>), <code>DailyDigestJob</code> (<code>jobs/daily-digest.job.ts:12-25</code>, <code>send-daily-digest</code>), <code>FeeReminderJob</code> (<code>jobs/fee-reminder.job.ts:16-47</code>, one <code>send-payment-reminder</code> per invoice due within 3 days), <code>AttendanceReportJob</code> (<code>jobs/attendance-report.job.ts:13-28</code>, <code>generate-attendance-report</code>, type <code>daily|weekly</code>).</li> <li><strong>Custom schedules</strong> — any 5-field cron on 11 whitelisted queues (<code>dto/create-schedule.dto.ts:4-28</code>), created via <code>POST /scheduler</code> (<code>scheduler.service.ts:198-208</code>).</li> <li><strong>Admission lifecycle</strong> — two dedicated queues exist today: <code>admission-reminder</code> and <code>admission-expiry</code> (<code>queue.constants.ts:15-16</code>), with workers <code>AdmissionReminderWorker</code> / <code>AdmissionExpiryWorker</code> (<code>bullmq.module.ts:18-20</code>); admission workflow states (documents pending, interview) are <code>(planned)</code> per <code>IMPLEMENTATION_PLAN.md:53-78</code>.</li> </ul> <h2 id="3-who-uses-it"><a class="header" href="#3-who-uses-it">3. Who uses it</a></h2> <div class="table-wrapper"><table><thead><tr><th>Actor</th><th>Relationship</th></tr></thead><tbody> <tr><td><strong>Platform operator / superadmin</strong></td><td>Owns scheduler health: list jobs, remove broken schedules, create custom schedules (<code>scheduler.controller.ts:23-46</code>)</td></tr> <tr><td><strong>School IT admin</strong></td><td>Receives scheduled outputs (daily digest, attendance reports, fee reminders) for their tenant</td></tr> <tr><td><strong>Other modules</strong></td><td>Their workers consume scheduler-triggered jobs off the shared queues</td></tr> <tr><td><strong>End users (students/parents/teachers)</strong></td><td>Only via delivered outputs (emails, reports, reminders) — never touch the scheduler</td></tr> </tbody></table> </div> <h2 id="4-scope-in--out-1"><a class="header" href="#4-scope-in--out-1">4. Scope in / out</a></h2> <div class="table-wrapper"><table><thead><tr><th>In scope (implemented)</th><th>Out of scope / gaps</th></tr></thead><tbody> <tr><td>10 default repeatable jobs registered at boot</td><td>Per-tenant cron scheduling (triggers are <code>tenantId: 'system'</code>, <code>scheduler.service.ts:139</code>)</td></tr> <tr><td>List / create / remove repeatable jobs (<code>scheduler.controller.ts:23-46</code>)</td><td>Run history, last-run/failed timestamps, logs — console <code>(proposed)</code>, no endpoint</td></tr> <tr><td>Manual trigger of a schedule</td><td>Job logs / DLQ viewer UI — <code>(proposed)</code></td></tr> <tr><td>Custom cron + queue whitelist (<code>create-schedule.dto.ts:4-16</code>)</td><td><code>whatsapp</code>, <code>in-app</code>, <code>attendance-process</code>, <code>webhook-deliver</code> are queues but not in the create whitelist (<code>dto:4-16</code>)</td></tr> <tr><td>Retry + DLQ on queues (<code>bullmq.module.ts:60-65</code>, <code>dlq.setup.ts:5-27</code>)</td><td>Report template scheduling — <code>(planned)</code> (<code>IMPLEMENTATION_PLAN.md:233</code>)</td></tr> <tr><td>Retention trigger (<code>retention-archive</code>)</td><td>Retention of <strong>documents</strong> (soft-delete cleanup, GDPR erasure, cold storage) — <code>(planned)</code> (<code>IMPLEMENTATION_PLAN.md:176</code>)</td></tr> <tr><td>Admission reminder/expiry scans</td><td>Webhook <code>/retry /test /metrics /pause</code> — <code>(planned)</code> (<code>IMPLEMENTATION_PLAN.md:177</code>)</td></tr> </tbody></table> </div> <h2 id="5-prd-native-app-exclusion-flagged-1"><a class="header" href="#5-prd-native-app-exclusion-flagged-1">5. PRD native-app exclusion (flagged)</a></h2> <p>Per <a href="scheduler/../00-shared/01_Product_Overview.html">00-shared/01 §9</a>: <code>PRODUCT_REQUIREMENTS_DOCUMENT.md:144</code> 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 <strong>these docs specify a Flutter admin console now</strong>, 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.</p> <h2 id="6-client-surface-screens--see-0506-1"><a class="header" href="#6-client-surface-screens--see-0506-1">6. Client surface (screens) — see 05/06</a></h2> <p>Scheduled Jobs console — <strong>all <code>(proposed)</code></strong>: 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 (<code>scheduler.controller.ts:23-46</code>).</p> <h2 id="7-tenant-context-handling"><a class="header" href="#7-tenant-context-handling">7. Tenant context handling</a></h2> <p>Workers restore tenant context with <code>tenantContext.run({...}, () => work)</code> (<code>report.worker.ts:18-30</code>, <code>finance.worker.ts:27-29</code>, <code>attendance.worker.ts:27-29</code>, <code>admission-reminder.worker.ts:30-32</code>, <code>admission-expiry.worker.ts</code>, <code>inapp.worker.ts:19-21</code>). <strong>Gap:</strong> <code>FeeReminderJob</code> queries <code>InvoiceRepository</code> directly (<code>fee-reminder.job.ts:20-23</code>) without wrapping itself in <code>tenantContext.run</code> — tenant scoping of that query depends on whatever context is ambient in the scheduler process, while trigger payloads carry <code>tenantId: 'system'</code>. See 14_QA (tenant isolation).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-scheduler-module"><a class="header" href="#02--user-personas-scheduler-module">02 — User Personas (Scheduler Module)</a></h1> <ul> <li><a href="scheduler/02_User_Personas.html#persona-1--the-platform-operator-nadia">Persona 1 — The Platform Operator ("Nadia")</a></li> <li><a href="scheduler/02_User_Personas.html#persona-2--the-school-it-admin-mr-okonkwo">Persona 2 — The School IT Admin ("Mr. Okonkwo")</a></li> <li><a href="scheduler/02_User_Personas.html#persona-3--the-on-call-engineer-priya">Persona 3 — The On-Call Engineer ("Priya")</a></li> <li><a href="scheduler/02_User_Personas.html#persona-4--the-domain-module-owner-dev">Persona 4 — The Domain Module Owner ("Dev")</a></li> <li><a href="scheduler/02_User_Personas.html#non-persona-end-users-students--parents--teachers">Non-persona: End Users (students / parents / teachers)</a></li> <li><a href="scheduler/02_User_Personas.html#shared-profile-notes">Shared profile notes</a></li> </ul> <blockquote> <p>The scheduler is infrastructure: its users are the operators who keep recurring work running. Personas are derived from the access model (<code>scheduler.controller.ts:16-24</code> — <code>JwtAuthGuard</code> + <code>Permissions</code>) and from how the module is consumed (<code>scheduler.service.ts</code>, <code>jobs/*.job.ts</code>, <code>infrastructure/workers/*</code>). No end-user persona exists by design — the PRD native-app exclusion applies (see 01 §5).</p> </blockquote> <hr /> <h2 id="persona-1--the-platform-operator-nadia"><a class="header" href="#persona-1--the-platform-operator-nadia">Persona 1 — The Platform Operator ("Nadia")</a></h2> <p><strong>Role:</strong> Platform superadmin at StudyLyon HQ. Owns the multi-tenant deployment; the only actor with cross-tenant visibility.</p> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Goal</td><td>All recurring work fires on time, every tenant</td></tr> <tr><td>Pain</td><td>Silent failures: a digest that never sends is noticed by schools, not by logs</td></tr> <tr><td>Tools today</td><td><code>GET /scheduler</code> list + Redis/BullMQ CLI inspection; no run history (gap → console <code>(proposed)</code>)</td></tr> <tr><td>Authority</td><td><code>scheduler.read / scheduler.create / scheduler.delete</code> permissions (<code>scheduler.controller.ts:24,31,38</code>)</td></tr> <tr><td>Behaviors</td><td>Checks the job list at deploy time; watches for missed runs after deploys or Redis restarts</td></tr> <tr><td>Wants</td><td>Last-run/failed timestamps, one-click manual trigger, DLQ visibility, alerting on missed runs</td></tr> <tr><td>Fears</td><td>BullMQ repeatable jobs silently deregistered on a redeploy; backlog storms after a long outage</td></tr> </tbody></table> </div> <h2 id="persona-2--the-school-it-admin-mr-okonkwo"><a class="header" href="#persona-2--the-school-it-admin-mr-okonkwo">Persona 2 — The School IT Admin ("Mr. Okonkwo")</a></h2> <p><strong>Role:</strong> IT administrator of one school tenant. Does not touch the platform scheduler itself; consumes its <strong>outputs</strong>.</p> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Goal</td><td>Parents/teachers get the daily digest, attendance reports, and fee reminders they expect</td></tr> <tr><td>Pain</td><td>Reports arrive late or not at all after school-hours (07:00 UTC is early for some timezones)</td></tr> <tr><td>Surface</td><td>None directly — receives emails (<code>emails</code> queue), report outputs (<code>report-generate</code>)</td></tr> <tr><td>Behaviors</td><td>Files tickets like "today's attendance report never arrived"</td></tr> <tr><td>Wants</td><td>A per-tenant "scheduled reports" view with delivery status <code>(proposed)</code></td></tr> <tr><td>Fears</td><td>Missed report = parents complaining; fee reminder missed = cashflow friction</td></tr> </tbody></table> </div> <h2 id="persona-3--the-on-call-engineer-priya"><a class="header" href="#persona-3--the-on-call-engineer-priya">Persona 3 — The On-Call Engineer ("Priya")</a></h2> <p><strong>Role:</strong> Backend engineer on rotation. Primary responder for queue/DLQ incidents.</p> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Goal</td><td>Diagnose a missed or failed job in minutes</td></tr> <tr><td>Pain</td><td>Repeatable triggers are fire-and-forget; failure detail lives in BullMQ + DLQ internals (<code>dlq.setup.ts:5-27</code>)</td></tr> <tr><td>Tools today</td><td>BullMQ dashboard, Redis CLI, logs</td></tr> <tr><td>Behaviors</td><td>Greps job names (<code>check-overdue</code>, <code>send-daily-digest</code>, …); checks <code>attemptsMade</code> vs <code>attempts</code></td></tr> <tr><td>Wants</td><td>Job logs screen, DLQ viewer with retry, idempotency check info</td></tr> <tr><td>Fears</td><td>Replaying a job that was already half-processed (double sends)</td></tr> </tbody></table> </div> <h2 id="persona-4--the-domain-module-owner-dev"><a class="header" href="#persona-4--the-domain-module-owner-dev">Persona 4 — The Domain Module Owner ("Dev")</a></h2> <p><strong>Role:</strong> Developer of fees/attendance/admissions modules that <strong>register jobs and consume the queues</strong>.</p> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Goal</td><td>Their module's async work is triggered reliably without re-inventing cron</td></tr> <tr><td>Surface</td><td>Register via <code>SchedulerService</code> injection or repeatable <code>queue.add</code>; consume via workers</td></tr> <tr><td>Behaviors</td><td>Adds <code>eventType</code> to <code>event-queue-map.ts</code> for event-driven paths, or a repeatable job for scheduled paths</td></tr> <tr><td>Wants</td><td>A documented contract: payload envelope (<code>JobData</code>), tenant restoration in workers, DLQ on failure</td></tr> </tbody></table> </div> <h2 id="non-persona-end-users-students--parents--teachers"><a class="header" href="#non-persona-end-users-students--parents--teachers">Non-persona: End Users (students / parents / teachers)</a></h2> <p>No scheduler surface. They interact only with <strong>delivered artifacts</strong> (digests, reminders, reports). Any direct scheduling capability in their apps is <code>(forward-looking)</code>.</p> <h2 id="shared-profile-notes"><a class="header" href="#shared-profile-notes">Shared profile notes</a></h2> <p>All authenticated personas share: bearer JWT (<code>scheduler.controller.ts:17</code>), RBAC via <code>Permissions</code> decorator, and response envelopes per <a href="scheduler/../00-shared/07_API_Conventions.html">00-shared/07</a>. Analytics for console screens is <code>(proposed)</code> — no events exist today (<a href="scheduler/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a>).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journeys-scheduler-module"><a class="header" href="#03--user-journeys-scheduler-module">03 — User Journeys (Scheduler Module)</a></h1> <ul> <li><a href="scheduler/03_User_Journey.html#journey-1--first-deployment-bring-up-console-free">Journey 1 — First Deployment Bring-Up (console-free)</a></li> <li><a href="scheduler/03_User_Journey.html#journey-2--daily-health-check">Journey 2 — Daily Health Check</a></li> <li><a href="scheduler/03_User_Journey.html#journey-3--missed-run-recovery-after-outage--redeploy">Journey 3 — Missed-Run Recovery (after outage / redeploy)</a></li> <li><a href="scheduler/03_User_Journey.html#journey-4--fee-reminder-pipeline-check-console-free">Journey 4 — Fee Reminder Pipeline Check (console-free)</a></li> <li><a href="scheduler/03_User_Journey.html#journey-5--custom-schedule-lifecycle-operator-created">Journey 5 — Custom Schedule Lifecycle (operator-created)</a></li> <li><a href="scheduler/03_User_Journey.html#journey-6--incident-postmortem">Journey 6 — Incident Postmortem</a></li> <li><a href="scheduler/03_User_Journey.html#journey-map">Journey map</a></li> <li><a href="scheduler/03_User_Journey.html#happiness-criteria-all-journeys">Happiness criteria (all journeys)</a></li> </ul> <blockquote> <p>Journeys are written for the <strong>operator/admin personas</strong> (02). Screens referenced are the console <code>(proposed)</code> set (05/06); steps backed by real endpoints are tagged with their <code>file:line</code>. Flows that need no UI today (journey 1, 4) are marked "console-free".</p> </blockquote> <hr /> <h2 id="journey-1--first-deployment-bring-up-console-free"><a class="header" href="#journey-1--first-deployment-bring-up-console-free">Journey 1 — First Deployment Bring-Up (console-free)</a></h2> <p><strong>Persona:</strong> Platform Operator · <strong>Trigger:</strong> fresh deploy of the API</p> <ol> <li><code>SchedulerModule</code> boots; <code>SchedulerService.onModuleInit</code> fires (<code>scheduler.service.ts:43-45</code>).</li> <li><code>registerDefaults()</code> iterates 10 jobs; each is skipped if an identical <code>name+pattern</code> repeatable already exists (<code>scheduler.service.ts:128-133</code>).</li> <li>New jobs are added with <code>repeat: { pattern, tz: 'UTC' }</code> (<code>scheduler.service.ts:135-147</code>).</li> <li>Operator verifies: <code>GET /scheduler</code> returns the 10 jobs (<code>scheduler.controller.ts:23-28</code>).</li> <li>Success = log line <code>Registered 10 default repeatable jobs</code> (<code>scheduler.service.ts:125</code>).</li> </ol> <p><strong>Exit:</strong> all 10 rows visible; next occurrence times sane in UTC.</p> <h2 id="journey-2--daily-health-check"><a class="header" href="#journey-2--daily-health-check">Journey 2 — Daily Health Check</a></h2> <p><strong>Persona:</strong> Platform Operator · <strong>Trigger:</strong> start of day</p> <ol> <li>Open Jobs list screen <code>(proposed)</code> — <code>GET /scheduler</code> today (<code>scheduler.controller.ts:23-28</code>), plus last-run/failed timestamps <code>(proposed)</code>.</li> <li>Scan status badges: 10 default jobs + any custom ones.</li> <li>Spot-check <code>audit-flush</code> (<code>*/1 * * * *</code>) — a missed minute mark signals Redis/worker trouble.</li> <li>Drill into a failed row → Job detail with run history <code>(proposed)</code>.</li> <li>If a run failed 3× it lands in the DLQ (<code>dlq.setup.ts:8-9</code>) → DLQ viewer <code>(proposed)</code>.</li> </ol> <p><strong>Exit:</strong> all green, or a ticket opened with <code>failedReason</code> from the DLQ.</p> <h2 id="journey-3--missed-run-recovery-after-outage--redeploy"><a class="header" href="#journey-3--missed-run-recovery-after-outage--redeploy">Journey 3 — Missed-Run Recovery (after outage / redeploy)</a></h2> <p><strong>Persona:</strong> On-Call Engineer · <strong>Trigger:</strong> "schools say no digest today"</p> <ol> <li>Confirm the repeatable survived the redeploy: <code>GET /scheduler</code> (<code>scheduler.controller.ts:23-28</code>) — repeatables can vanish if Redis was flushed, so re-registration depends on boot order (<code>scheduler.service.ts:43-45</code>).</li> <li>Check BullMQ/Redis for the <code>0 9 * * *</code> <code>daily-digest</code> entry (<code>scheduler.service.ts:56-62</code>).</li> <li>Manual trigger <code>(proposed)</code> — today only possible by re-adding or <code>queue.add('send-daily-digest', ...)</code> from a script (<code>daily-digest.job.ts:12-25</code>).</li> <li>Confirm the worker picked it up and restored tenant context (<code>report.worker.ts:18-30</code> pattern).</li> <li><strong>Idempotency risk:</strong> a manual re-trigger must not double-send — worker idempotency via <code>IdempotencyService</code> (<code>bullmq.module.ts:76</code>) is the guard.</li> </ol> <p><strong>Exit:</strong> digest delivered once; root cause (Redis flush, worker down) recorded; alerting rule <code>(proposed)</code> added.</p> <h2 id="journey-4--fee-reminder-pipeline-check-console-free"><a class="header" href="#journey-4--fee-reminder-pipeline-check-console-free">Journey 4 — Fee Reminder Pipeline Check (console-free)</a></h2> <p><strong>Persona:</strong> Platform Operator · <strong>Trigger:</strong> end of month</p> <ol> <li><code>fee-reminder</code> fires at 08:00 UTC (<code>scheduler.service.ts:92-97</code>).</li> <li><code>FeeReminderJob</code> scans invoices due within 3 days, status <code>ISSUED|PARTIAL</code> (<code>fee-reminder.job.ts:16-23</code>).</li> <li>One <code>send-payment-reminder</code> per invoice enqueued (<code>fee-reminder.job.ts:25-41</code>) with <code>amount = totalAmount − paidAmount</code> (<code>:37</code>).</li> <li>Worker sends; failures retry 3× exponential 5 s (<code>bullmq.module.ts:60-65</code>), then DLQ (<code>dlq.setup.ts:8-21</code>).</li> </ol> <p><strong>Exit:</strong> reminder count matches due-invoice count; DLQ empty.</p> <h2 id="journey-5--custom-schedule-lifecycle-operator-created"><a class="header" href="#journey-5--custom-schedule-lifecycle-operator-created">Journey 5 — Custom Schedule Lifecycle (operator-created)</a></h2> <p><strong>Persona:</strong> Platform Operator · <strong>Trigger:</strong> "rebuild dashboards off-peak"</p> <ol> <li>Create: <code>POST /scheduler</code> with <code>{pattern, queue, jobName, payload?, tz?}</code> (<code>scheduler.controller.ts:30-35</code>); cron validated as 5 fields (<code>create-schedule.dto.ts:20-24</code>), queue whitelisted (<code>dto:26-28</code>).</li> <li>Verify: <code>GET /scheduler</code> shows the new row (<code>scheduler.controller.ts:23-28</code>).</li> <li>Later, remove: <code>DELETE /scheduler?queue=&name=&pattern=</code> (<code>scheduler.controller.ts:37-46</code>) → <code>removeRepeatable(..., { pattern, tz: 'UTC' })</code> (<code>scheduler.service.ts:188-196</code>).</li> </ol> <p><strong>Exit:</strong> row gone; no ghost repeats (BullMQ dedupes by name+pattern+queue).</p> <h2 id="journey-6--incident-postmortem"><a class="header" href="#journey-6--incident-postmortem">Journey 6 — Incident Postmortem</a></h2> <p><strong>Persona:</strong> On-Call Engineer · <strong>Trigger:</strong> a tenant missed <code>attendance-report-daily</code></p> <ol> <li>Identify schedule: <code>0 7 * * *</code> → <code>report-generate</code> (<code>scheduler.service.ts:98-104</code>).</li> <li>Job detail <code>(proposed)</code>: last runs, <code>failedReason</code>.</li> <li>DLQ record: <code>originalQueue, originalJobId, failedReason, attemptsMade</code> (<code>dlq.setup.ts:12-21</code>).</li> <li>Replay from DLQ <code>(proposed)</code>; verify idempotency before retry.</li> <li>Patch the root cause; amend QA checklist (14).</li> </ol> <p><strong>Exit:</strong> documented timeline + fix; report re-delivered.</p> <h2 id="journey-map-2"><a class="header" href="#journey-map-2">Journey map</a></h2> <pre><code>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 </code></pre> <h2 id="happiness-criteria-all-journeys"><a class="header" href="#happiness-criteria-all-journeys">Happiness criteria (all journeys)</a></h2> <ul> <li>Any job's next run is predictable from the schedule table (01 §2).</li> <li>A failed job is visible within one polling cycle (<code>audit-flush</code> runs every minute, <code>scheduler.service.ts:78-83</code>).</li> <li>Recovery never double-delivers (idempotency gate).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-scheduler-module"><a class="header" href="#04--information-architecture-scheduler-module">04 — Information Architecture (Scheduler Module)</a></h1> <ul> <li><a href="scheduler/04_Information_Architecture.html#1-backend-ia--module-composition">1. Backend IA — module composition</a></li> <li><a href="scheduler/04_Information_Architecture.html#2-job-taxonomy">2. Job taxonomy</a></li> <li><a href="scheduler/04_Information_Architecture.html#3-console-ia--scheduled-jobs-superadmin-proposed">3. Console IA — Scheduled Jobs (superadmin, <code>(proposed)</code>)</a></li> <li><a href="scheduler/04_Information_Architecture.html#4-relationship-to-other-modules">4. Relationship to other modules</a></li> <li><a href="scheduler/04_Information_Architecture.html#5-navigation--routing">5. Navigation & routing</a></li> </ul> <blockquote> <p>Backend: the module's runtime structure (queues ⇄ triggers). Console: the operator-facing IA of the Scheduled Jobs admin area — <code>(proposed)</code> unless an endpoint is cited. The console is a <strong>platform-level (superadmin)</strong> surface, sibling of Security/API Keys in the admin settings cluster.</p> </blockquote> <hr /> <h2 id="1-backend-ia--module-composition"><a class="header" href="#1-backend-ia--module-composition">1. Backend IA — module composition</a></h2> <pre><code>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) </code></pre> <p>Queue registry: 15 queues + DLQ registered in <code>BullMqModule</code> (<code>bullmq.module.ts:28-45</code>); names in <code>queue.constants.ts:1-17</code>; event-driven routes in <code>event-queue-map.ts:6-43</code> (scheduler triggers bypass the map and enqueue directly by <code>queue.add</code>).</p> <h2 id="2-job-taxonomy"><a class="header" href="#2-job-taxonomy">2. Job taxonomy</a></h2> <div class="table-wrapper"><table><thead><tr><th>Kind</th><th>Example</th><th>Source</th></tr></thead><tbody> <tr><td><strong>Default repeatable (10)</strong></td><td><code>overdue-scan</code>, <code>daily-digest</code>, <code>dashboard-rebuild</code>, <code>biometric-sync</code>, <code>audit-flush</code>, <code>retention-archive</code>, <code>fee-reminder</code>, <code>attendance-report-daily</code>, <code>admission-reminder-scan</code>, <code>admission-expiry-scan</code></td><td><code>scheduler.service.ts:48-119</code></td></tr> <tr><td><strong>Custom repeatable (operator)</strong></td><td>any 5-field cron on 11 whitelisted queues</td><td><code>create-schedule.dto.ts:4-16</code></td></tr> <tr><td><strong>Fan-out child jobs</strong></td><td><code>check-overdue</code>, <code>send-daily-digest</code>, <code>send-payment-reminder</code>, <code>generate-attendance-report</code></td><td><code>jobs/*.job.ts</code></td></tr> <tr><td><strong>Event-driven jobs</strong></td><td><code>send-welcome-email</code>, <code>log-login</code>, …</td><td><code>event-queue-map.ts:6-43</code></td></tr> </tbody></table> </div> <h2 id="3-console-ia--scheduled-jobs-superadmin-proposed"><a class="header" href="#3-console-ia--scheduled-jobs-superadmin-proposed">3. Console IA — Scheduled Jobs (superadmin, <code>(proposed)</code>)</a></h2> <pre><code>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) </code></pre> <p>Content model of a <strong>job row</strong> (from <code>scheduler.service.ts:152-186</code>):</p> <pre><code>queue • name • pattern • tz + (proposed) lastRunAt • lastStatus • nextRunAt • runs24h • avgDurationMs </code></pre> <p>Content model of a <strong>DLQ row</strong> (from <code>dlq.setup.ts:12-21</code>):</p> <pre><code>originalQueue • originalJobId • originalJobName • data failedReason • attemptsMade • failedAt </code></pre> <h2 id="4-relationship-to-other-modules"><a class="header" href="#4-relationship-to-other-modules">4. Relationship to other modules</a></h2> <div class="table-wrapper"><table><thead><tr><th>Module</th><th>Through</th><th>Direction</th></tr></thead><tbody> <tr><td>Fees</td><td><code>invoice-generate</code>, <code>payment-reminder</code> queues; <code>FeesModule</code> import (<code>scheduler.module.ts:13</code>)</td><td>scheduler triggers, fees processes</td></tr> <tr><td>Attendance / Reports</td><td><code>attendance-process</code>, <code>report-generate</code></td><td>scheduler triggers, workers process</td></tr> <tr><td>Notifications</td><td><code>emails</code>, <code>in-app</code>, <code>push</code></td><td>scheduler triggers digests/reminders</td></tr> <tr><td>CRM</td><td><code>admission-reminder</code>, <code>admission-expiry</code></td><td>scheduler scans lifecycle</td></tr> <tr><td>Audit</td><td><code>audit-write</code></td><td><code>audit-flush</code> cadence</td></tr> <tr><td>Infra</td><td>Redis (BullMQ), DLQ (<code>dlq.setup.ts</code>), <code>IdempotencyService</code> (<code>bullmq.module.ts:76</code>)</td><td>delivery guarantees</td></tr> </tbody></table> </div> <h2 id="5-navigation--routing"><a class="header" href="#5-navigation--routing">5. Navigation & routing</a></h2> <ul> <li>Console root: <code>/admin/scheduler</code> (nested under admin settings).</li> <li>Job detail: <code>/admin/scheduler/jobs/:queue/:name</code> (queue+name disambiguate — <code>fee-reminder</code> ≠ <code>attendance-report-daily</code>, both daily patterns <code>scheduler.service.ts:92-104</code>).</li> <li>DLQ: <code>/admin/scheduler/dlq</code> + <code>/admin/scheduler/dlq/:jobId</code>.</li> <li>Existing endpoints stay under <code>/api/v1/scheduler</code> (<code>scheduler.controller.ts:19</code> — URI versioning from <code>main.ts</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-scheduler-module"><a class="header" href="#05--screen-inventory-scheduler-module">05 — Screen Inventory (Scheduler Module)</a></h1> <ul> <li><a href="scheduler/05_Screen_Inventory.html#s1--scheduled-jobs-list-adminscheduler-proposed">S1 — Scheduled Jobs List (<code>/admin/scheduler</code>) <code>(proposed)</code></a></li> <li><a href="scheduler/05_Screen_Inventory.html#s2--job-detail--run-history-adminschedulerjobsqueuename-proposed">S2 — Job Detail / Run History (<code>/admin/scheduler/jobs/:queue/:name</code>) <code>(proposed)</code></a></li> <li><a href="scheduler/05_Screen_Inventory.html#s3--create-custom-schedule-sheet-proposed">S3 — Create Custom Schedule (sheet) <code>(proposed)</code></a></li> <li><a href="scheduler/05_Screen_Inventory.html#s4--job-logs-adminschedulerjobsqueuenamelogs-proposed">S4 — Job Logs (<code>/admin/scheduler/jobs/:queue/:name/logs</code>) <code>(proposed)</code></a></li> <li><a href="scheduler/05_Screen_Inventory.html#s5--manual-trigger-dialog-proposed">S5 — Manual Trigger (dialog) <code>(proposed)</code></a></li> <li><a href="scheduler/05_Screen_Inventory.html#s6--dead-letter-queue-adminschedulerdlq-proposed">S6 — Dead Letter Queue (<code>/admin/scheduler/dlq</code>) <code>(proposed)</code></a></li> <li><a href="scheduler/05_Screen_Inventory.html#shared-components-used">Shared components used</a></li> <li><a href="scheduler/05_Screen_Inventory.html#analytics-events-proposed">Analytics events (proposed)</a></li> <li><a href="scheduler/05_Screen_Inventory.html#keyboard-landscape-tablet-desktop">Keyboard, landscape, tablet, desktop</a></li> </ul> <blockquote> <p>Every screen of the Scheduled Jobs admin console, its intent, route, composition, states, permissions and events. <strong>All console screens are <code>(proposed)</code></strong> — they are specified for the operator UX; only the three endpoints behind them exist (<code>scheduler.controller.ts:23-46</code>). Authoritative components in <a href="scheduler/../00-shared/03_Component_Library.html">00-shared/03</a>; this file enumerates which each screen uses with module specifics. Legend — States: idle / loading / success / empty / error(offline, rate, invalid) / disabled / permission. Analytics: <code>scheduler.{screen}.{action}</code> <code>(proposed)</code> (<a href="scheduler/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a>).</p> </blockquote> <hr /> <h2 id="s1--scheduled-jobs-list-adminscheduler-proposed"><a class="header" href="#s1--scheduled-jobs-list-adminscheduler-proposed">S1 — Scheduled Jobs List (<code>/admin/scheduler</code>) <code>(proposed)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Inventory of every repeatable job across all queues</td></tr> <tr><td>Entry</td><td>admin settings → Scheduled Jobs; cold app deep link</td></tr> <tr><td>Exit</td><td>→ job detail (S2); → create sheet (S3); → DLQ (S6)</td></tr> <tr><td>Source</td><td><code>GET /scheduler</code> → <code>{queue, name, pattern, tz}[]</code> (<code>scheduler.service.ts:152-186</code>) — plus <code>(proposed)</code> lastRun/status enrichment</td></tr> <tr><td>Widgets</td><td><code>AppListTile</code> rows: job name, queue chip, cron chip, tz chip, status badge; summary header counts</td></tr> <tr><td>Row actions</td><td>Tap → S2; <code>AppMenu</code>: "Run now" (S5), "View logs" (S4), "Remove" (confirm)</td></tr> <tr><td>States</td><td>loading (<code>AppSkeleton</code>), empty ("No scheduled jobs — defaults register at boot" <code>scheduler.service.ts:43-45</code>), error (offline / 403 permission <code>scheduler.read</code> <code>scheduler.controller.ts:24</code>)</td></tr> <tr><td>Analytics</td><td><code>scheduler.list.open</code>, <code>scheduler.list.refresh</code>, <code>scheduler.job.open</code></td></tr> <tr><td>Refresh</td><td><code>RefreshIndicator</code>; auto-poll every 60 s (audit-flush cadence <code>scheduler.service.ts:78-83</code>)</td></tr> <tr><td>a11y</td><td>badge colors never sole signal (icon + text), live region on status change</td></tr> </tbody></table> </div> <h2 id="s2--job-detail--run-history-adminschedulerjobsqueuename-proposed"><a class="header" href="#s2--job-detail--run-history-adminschedulerjobsqueuename-proposed">S2 — Job Detail / Run History (<code>/admin/scheduler/jobs/:queue/:name</code>) <code>(proposed)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>One job's schedule, definition, and recent runs</td></tr> <tr><td>Source</td><td>schedule from <code>GET /scheduler</code>; runs <code>(proposed)</code> <code>GET /scheduler/runs?queue=&name=</code></td></tr> <tr><td>Composition</td><td>header (name, pattern, tz, queue), run-history list (startedAt, status, duration, error), schedule meta (<code>removeOnComplete/removeOnFail</code> <code>scheduler.service.ts:145-146</code>)</td></tr> <tr><td>States</td><td>empty ("no runs in retention window — triggers keep 1 h / 100 completed <code>scheduler.service.ts:145</code>")</td></tr> <tr><td>CTAs</td><td>"Run now" (S5), "View logs" (S4), "Remove schedule"</td></tr> <tr><td>Analytics</td><td><code>scheduler.detail.open</code>, <code>scheduler.detail.run</code></td></tr> </tbody></table> </div> <h2 id="s3--create-custom-schedule-sheet-proposed"><a class="header" href="#s3--create-custom-schedule-sheet-proposed">S3 — Create Custom Schedule (sheet) <code>(proposed)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Operator-defined cron on a whitelisted queue</td></tr> <tr><td>Source</td><td><code>POST /scheduler</code> (<code>scheduler.controller.ts:30-35</code>); DTO contract <code>create-schedule.dto.ts:18-41</code></td></tr> <tr><td>Form</td><td>queue (enum of 11, <code>dto:4-16</code>), jobName (free text), pattern (5-field cron, regex <code>dto:20-24</code>), tz (default <code>UTC</code>), payload (JSON, optional)</td></tr> <tr><td>States</td><td>inline cron error ("Invalid cron pattern (5 fields required)" <code>dto:22</code>), success → row appears in S1</td></tr> <tr><td>Analytics</td><td><code>scheduler.create.submit</code>, <code>scheduler.create.success</code>, <code>scheduler.create.failure(reason)</code></td></tr> </tbody></table> </div> <h2 id="s4--job-logs-adminschedulerjobsqueuenamelogs-proposed"><a class="header" href="#s4--job-logs-adminschedulerjobsqueuenamelogs-proposed">S4 — Job Logs (<code>/admin/scheduler/jobs/:queue/:name/logs</code>) <code>(proposed)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Per-run log stream for a job (operator + on-call diagnosis)</td></tr> <tr><td>Source</td><td><code>(proposed)</code> <code>GET /scheduler/jobs/:queue/:name/logs</code></td></tr> <tr><td>Composition</td><td><code>AppListTile</code> log lines (timestamp, level, message, jobId), filter by level, tail-live toggle</td></tr> <tr><td>States</td><td>empty ("no logs kept — job data retention is 1 h completed / 7 d failed <code>scheduler.service.ts:145-146</code>"), error</td></tr> <tr><td>Analytics</td><td><code>scheduler.logs.open</code>, <code>scheduler.logs.filter</code></td></tr> </tbody></table> </div> <h2 id="s5--manual-trigger-dialog-proposed"><a class="header" href="#s5--manual-trigger-dialog-proposed">S5 — Manual Trigger (dialog) <code>(proposed)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Fire a repeatable job's child job now, once</td></tr> <tr><td>Source</td><td><code>(proposed)</code> <code>POST /scheduler/:queue/:name/trigger</code> → re-enqueues child job per <code>jobs/*.job.ts</code> contract</td></tr> <tr><td>Composition</td><td>confirm dialog: job name, target queue, warning text "deliveries may duplicate — verify idempotency"</td></tr> <tr><td>States</td><td>success → snackbar + row lastRun updates; disabled while a run is in flight</td></tr> <tr><td>Analytics</td><td><code>scheduler.trigger.submit</code>, <code>scheduler.trigger.success</code>, <code>scheduler.trigger.failure</code></td></tr> </tbody></table> </div> <h2 id="s6--dead-letter-queue-adminschedulerdlq-proposed"><a class="header" href="#s6--dead-letter-queue-adminschedulerdlq-proposed">S6 — Dead Letter Queue (<code>/admin/scheduler/dlq</code>) <code>(proposed)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Jobs that exhausted retries: <code>attemptsMade >= attempts</code> → DLQ (<code>dlq.setup.ts:8-9</code>)</td></tr> <tr><td>Source</td><td><code>(proposed)</code> <code>GET /scheduler/dlq</code>; record shape <code>dlq.setup.ts:12-21</code></td></tr> <tr><td>Widgets</td><td><code>AppCard</code> per DLQ record: originalQueue, originalJobName, failedReason, attemptsMade, failedAt; actions Replay / Delete (confirm)</td></tr> <tr><td>Empty</td><td>"No dead letters" — success state</td></tr> <tr><td>Analytics</td><td><code>scheduler.dlq.open</code>, <code>scheduler.dlq.replay</code>, <code>scheduler.dlq.delete</code></td></tr> </tbody></table> </div> <hr /> <h2 id="shared-components-used-19"><a class="header" href="#shared-components-used-19">Shared components used</a></h2> <p><code>AppListTile</code>, <code>AppCard</code>, <code>AppChips</code> (queue/cron), <code>AppBadge</code> (status), <code>AppButton</code>, <code>AppMenu</code>, <code>AppDialog</code>, <code>AppBottomSheet</code>, <code>AppSnackbar</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppOfflineBanner</code>, <code>AppFAB</code> ("New schedule"), <code>AppTextField</code> (cron/jobName), <code>AppDropdown</code> (queue), <code>AppSwitch</code> (tail-live). Module-specific: <code>JobStatusBadge</code>, <code>CronChip</code>, <code>RunHistoryRow</code>, <code>DLQCard</code> — defined in <a href="scheduler/07_Component_Library.html">07_Component_Library.md</a>.</p> <h2 id="analytics-events-proposed-16"><a class="header" href="#analytics-events-proposed-16">Analytics events (proposed)</a></h2> <p><code>scheduler.list.{open,refresh}</code>, <code>scheduler.job.{open,remove}</code>, <code>scheduler.detail.{open,run}</code>, <code>scheduler.create.{submit,success,failure}</code>, <code>scheduler.logs.{open,filter}</code>, <code>scheduler.trigger.{submit,success,failure}</code>, <code>scheduler.dlq.{open,replay,delete}</code>.</p> <h2 id="keyboard-landscape-tablet-desktop-16"><a class="header" href="#keyboard-landscape-tablet-desktop-16">Keyboard, landscape, tablet, desktop</a></h2> <ul> <li>Console is desktop-first (admin tool); tablet uses two-column list/detail.</li> <li>Forms in <code>AppBottomSheet</code> (max-width 640 dp) on all breakpoints.</li> <li>Cron input: monospace, auto-validate on blur, hint from schedule table (01 §2).</li> <li>All fields keyboard-avoidance + <code>ScrollView</code> <code>resizeToAvoidBottomInset</code>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-scheduler-module"><a class="header" href="#06--screen-specifications-scheduler-module">06 — Screen Specifications (Scheduler Module)</a></h1> <ul> <li><a href="scheduler/06_Screen_Specifications.html#s1--scheduled-jobs-list">S1 — Scheduled Jobs List</a></li> <li><a href="scheduler/06_Screen_Specifications.html#s2--job-detail--run-history">S2 — Job Detail / Run History</a></li> <li><a href="scheduler/06_Screen_Specifications.html#s3--create-custom-schedule">S3 — Create Custom Schedule</a></li> <li><a href="scheduler/06_Screen_Specifications.html#s4--job-logs">S4 — Job Logs</a></li> <li><a href="scheduler/06_Screen_Specifications.html#s5--manual-trigger-dialog">S5 — Manual Trigger Dialog</a></li> <li><a href="scheduler/06_Screen_Specifications.html#s6--dead-letter-queue">S6 — Dead Letter Queue</a></li> </ul> <blockquote> <p>Detailed specs for the Scheduled Jobs console. <code>(proposed)</code> markers mean the screen/endpoint does not exist in source; everything else cites <code>file:line</code>. Data shapes are quoted from <code>scheduler.service.ts</code> and <code>dlq.setup.ts</code>.</p> </blockquote> <hr /> <h2 id="s1--scheduled-jobs-list"><a class="header" href="#s1--scheduled-jobs-list">S1 — Scheduled Jobs List</a></h2> <p><strong>Layout (desktop, ≥ 1024 dp)</strong></p> <pre><code>┌──────────────────────────────────────────────────────────────┐ │ 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 * * * │ └──────────────────────────────────────────────────────────────┘ </code></pre> <div class="table-wrapper"><table><thead><tr><th>Spec</th><th>Detail</th></tr></thead><tbody> <tr><td>Data</td><td><code>GET /scheduler</code> → <code>{queue, name, pattern, tz}</code> (<code>scheduler.service.ts:152-186</code>); enrich <code>(proposed)</code> with <code>lastRunAt, lastStatus, nextRunAt</code></td></tr> <tr><td>Sort</td><td>default: name asc; toggle group by queue (drop-down)</td></tr> <tr><td>Status derivation</td><td><code>(proposed)</code> — from BullMQ job states of last run; today the API has no status field</td></tr> <tr><td>Row tap</td><td>→ S2 (<code>/admin/scheduler/jobs/:queue/:name</code>)</td></tr> <tr><td>Menu</td><td>Run now (S5) · View logs (S4) · Remove — remove calls <code>DELETE /scheduler?queue=&name=&pattern=</code> (<code>scheduler.controller.ts:37-46</code>, <code>scheduler.service.ts:188-196</code>)</td></tr> <tr><td>Empty</td><td>"No scheduled jobs — the 10 defaults register on boot (<code>scheduler.service.ts:43-45</code>)"</td></tr> <tr><td>Refresh</td><td>pull + auto-poll 60 s; offline → <code>AppOfflineBanner</code></td></tr> <tr><td>Permission</td><td><code>scheduler.read</code> (403 → <code>AppEmptyState</code> permission variant)</td></tr> </tbody></table> </div> <h2 id="s2--job-detail--run-history"><a class="header" href="#s2--job-detail--run-history">S2 — Job Detail / Run History</a></h2> <div class="table-wrapper"><table><thead><tr><th>Spec</th><th>Detail</th></tr></thead><tbody> <tr><td>Header</td><td>name, <code>CronChip(pattern)</code>, tz chip, queue chip, status badge</td></tr> <tr><td>Definition block</td><td>retention: <code>removeOnComplete {age:3600, count:100}</code>, <code>removeOnFail {age: 7d}</code> (<code>scheduler.service.ts:145-146</code>); global retry: <code>attempts: 3, exponential backoff 5 s</code> (<code>bullmq.module.ts:60-65</code>); trigger payload envelope <code>{eventType, tenantId:'system', correlationId, actorId:'scheduler'}</code> (<code>scheduler.service.ts:136-142</code>)</td></tr> <tr><td>Run history</td><td><code>(proposed)</code> table: startedAt · status (ok/failed/in-progress) · duration · error excerpt. Source <code>(proposed)</code> <code>GET /scheduler/runs?queue=&name=</code></td></tr> <tr><td>Retention note</td><td>history window is 1 h / 100 completed jobs for the <em>trigger</em>, and 14 d for failed jobs globally (<code>bullmq.module.ts:63-64</code>) — display accordingly</td></tr> <tr><td>CTAs</td><td>Run now · View logs · Remove schedule (confirm)</td></tr> </tbody></table> </div> <h2 id="s3--create-custom-schedule"><a class="header" href="#s3--create-custom-schedule">S3 — Create Custom Schedule</a></h2> <div class="table-wrapper"><table><thead><tr><th>Spec</th><th>Detail</th></tr></thead><tbody> <tr><td>Trigger</td><td>FAB "+ New" (S1) → <code>AppBottomSheet</code></td></tr> <tr><td>Fields</td><td>queue (<code>AppDropdown</code>, 11 whitelisted: <code>create-schedule.dto.ts:4-16</code>), jobName (required, <code>dto:30-32</code>), pattern (5-field cron, <code>dto:20-24</code>; monospace; hint row of preset chips e.g. <code>0 6 * * *</code>), tz (default UTC <code>dto:38-41</code>), payload (JSON textarea, optional)</td></tr> <tr><td>Validation</td><td>inline per field; server re-validates (<code>dto:19-41</code>); pattern regex error message "Invalid cron pattern (5 fields required)" (<code>dto:22</code>)</td></tr> <tr><td>Submit</td><td><code>POST /scheduler</code> (<code>scheduler.controller.ts:30-35</code>) → <code>queue.add</code> with <code>repeat {pattern, tz}</code> (<code>scheduler.service.ts:198-208</code>)</td></tr> <tr><td>Success</td><td>sheet closes, snackbar, new row at top (optimistic, rollback on fail)</td></tr> <tr><td>Duplicate</td><td><code>registerRepeatable</code> skips existing name+pattern (<code>scheduler.service.ts:128-133</code>) — UI mirrors: warn if identical row exists</td></tr> </tbody></table> </div> <h2 id="s4--job-logs"><a class="header" href="#s4--job-logs">S4 — Job Logs</a></h2> <div class="table-wrapper"><table><thead><tr><th>Spec</th><th>Detail</th></tr></thead><tbody> <tr><td>Route</td><td><code>/admin/scheduler/jobs/:queue/:name/logs</code></td></tr> <tr><td>Source</td><td><code>(proposed)</code> <code>GET /scheduler/jobs/:queue/:name/logs?level=&tail=</code></td></tr> <tr><td>Content</td><td>timestamp · level · message · jobId; monospace; auto-scroll; "tail live" <code>AppSwitch</code></td></tr> <tr><td>Filter</td><td>level chips (debug/info/warn/error); default info</td></tr> <tr><td>Empty</td><td>"No logs kept — completed trigger jobs live 1 h (<code>scheduler.service.ts:145</code>)"</td></tr> <tr><td>Note</td><td>worker-side logs exist via nestjs-pino (<code>AGENTS.md</code> stack); surfacing them is <code>(proposed)</code></td></tr> </tbody></table> </div> <h2 id="s5--manual-trigger-dialog"><a class="header" href="#s5--manual-trigger-dialog">S5 — Manual Trigger Dialog</a></h2> <div class="table-wrapper"><table><thead><tr><th>Spec</th><th>Detail</th></tr></thead><tbody> <tr><td>Trigger</td><td>row menu "Run now" or S2 CTA</td></tr> <tr><td>Content</td><td>confirm: "Trigger <code>daily-digest</code> → <code>send-daily-digest</code> on queue <code>emails</code> now? (Deliveries may duplicate — worker idempotency must hold.)"</td></tr> <tr><td>Action</td><td><code>(proposed)</code> <code>POST /scheduler/:queue/:name/trigger</code> → re-enqueues via job class (<code>daily-digest.job.ts:12-25</code> pattern)</td></tr> <tr><td>States</td><td>in-flight (button spinner, dialog not dismissible twice), success snackbar "Triggered — watch run history", failure inline</td></tr> <tr><td>Idempotency guard</td><td><code>IdempotencyService</code> (<code>bullmq.module.ts:76</code>) must de-dupe on replay — QA item 14 §6</td></tr> </tbody></table> </div> <h2 id="s6--dead-letter-queue"><a class="header" href="#s6--dead-letter-queue">S6 — Dead Letter Queue</a></h2> <div class="table-wrapper"><table><thead><tr><th>Spec</th><th>Detail</th></tr></thead><tbody> <tr><td>Route</td><td><code>/admin/scheduler/dlq</code></td></tr> <tr><td>Source</td><td><code>(proposed)</code> <code>GET /scheduler/dlq</code> — records written by <code>setupDlqListener</code> (<code>dlq.setup.ts:5-27</code>)</td></tr> <tr><td>Card fields</td><td>originalQueue, originalJobName, originalJobId, failedReason, attemptsMade, failedAt, data (JSON expandable)</td></tr> <tr><td>Actions</td><td><strong>Replay</strong> → <code>(proposed)</code> <code>POST /scheduler/dlq/:id/retry</code> (re-add to original queue with same data); <strong>Delete</strong> → confirm dialog</td></tr> <tr><td>Empty</td><td>"No dead letters"</td></tr> <tr><td>Note</td><td>DLQ condition: <code>attemptsMade >= (job.opts.attempts ?? 3)</code> (<code>dlq.setup.ts:8-9</code>); replay must preserve <code>correlationId</code> for idempotency</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-scheduler-module"><a class="header" href="#07--component-library-scheduler-module">07 — Component Library (Scheduler Module)</a></h1> <ul> <li><a href="scheduler/07_Component_Library.html#c1--jobstatusbadge">C1 — JobStatusBadge</a></li> <li><a href="scheduler/07_Component_Library.html#c2--cronchip">C2 — CronChip</a></li> <li><a href="scheduler/07_Component_Library.html#c3--queuechip">C3 — QueueChip</a></li> <li><a href="scheduler/07_Component_Library.html#c4--schedulesummarystrip">C4 — ScheduleSummaryStrip</a></li> <li><a href="scheduler/07_Component_Library.html#c5--runhistoryrow">C5 — RunHistoryRow</a></li> <li><a href="scheduler/07_Component_Library.html#c6--jobdefinitioncard">C6 — JobDefinitionCard</a></li> <li><a href="scheduler/07_Component_Library.html#c7--dlqcard">C7 — DLQCard</a></li> <li><a href="scheduler/07_Component_Library.html#c8--presetcronchips-form-helper">C8 — PresetCronChips (form helper)</a></li> <li><a href="scheduler/07_Component_Library.html#composition-map">Composition map</a></li> <li><a href="scheduler/07_Component_Library.html#reuse-rules">Reuse rules</a></li> </ul> <blockquote> <p>Module-specific components for the Scheduled Jobs console, mapped to authoritative components in <a href="scheduler/../00-shared/03_Component_Library.html">00-shared/03</a>. Every component here is <code>(proposed)</code> (console-only); none exist in the Flutter app today.</p> </blockquote> <hr /> <h2 id="c1--jobstatusbadge"><a class="header" href="#c1--jobstatusbadge">C1 — JobStatusBadge</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Base</td><td><code>AppBadge</code> (<a href="scheduler/../00-shared/03_Component_Library.html">00-shared/03</a>)</td></tr> <tr><td>Variants</td><td><code>ok</code> (green, check icon) · <code>running</code> (blue, pulse) · <code>failed</code> (red, alert icon) · <code>missed</code> (amber, clock icon — no run in expected window) · <code>unknown</code> (grey, "—")</td></tr> <tr><td>a11y</td><td>icon + text always (never color alone); tooltip with last run timestamp</td></tr> <tr><td>Data</td><td><code>(proposed)</code> from run-history endpoint; today undeterminable from <code>GET /scheduler</code> (<code>scheduler.service.ts:152-186</code> returns no status)</td></tr> </tbody></table> </div> <h2 id="c2--cronchip"><a class="header" href="#c2--cronchip">C2 — CronChip</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Base</td><td><code>AppChips</code> (<a href="scheduler/../00-shared/03_Component_Library.html">00-shared/03</a>)</td></tr> <tr><td>Content</td><td>monospace pattern + humanized summary ("daily 06:00 UTC"), tooltip with full meaning</td></tr> <tr><td>Humanizer map</td><td><code>0 6 * * *</code> → daily 06:00 · <code>0 9 * * *</code> → daily 09:00 · <code>*/5 * * * *</code> → every 5 min · <code>*/15 * * * *</code> → every 15 min · <code>*/1 * * * *</code> → every minute · <code>0 2 * * 0</code> → Sunday 02:00 · <code>0 7 * * *</code> → daily 07:00 · <code>0 8 * * *</code> → daily 08:00 · <code>0 2 * * *</code> → daily 02:00 (all UTC, <code>scheduler.service.ts:48-119</code>)</td></tr> <tr><td>Invalid</td><td>red border + tooltip "Invalid cron pattern (5 fields required)" (<code>create-schedule.dto.ts:22</code>)</td></tr> </tbody></table> </div> <h2 id="c3--queuechip"><a class="header" href="#c3--queuechip">C3 — QueueChip</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Base</td><td><code>AppChips</code></td></tr> <tr><td>Content</td><td>queue name (e.g. <code>report-generate</code>); color-coded per family: delivery (<code>emails</code>, <code>push</code>, <code>whatsapp</code>, <code>in-app</code>), processing (<code>attendance-process</code>, <code>biometric-sync</code>, <code>invoice-generate</code>, <code>payment-reminder</code>, <code>report-generate</code>), system (<code>audit-write</code>, <code>tenant-purge</code>, <code>cache-rebuild</code>, <code>webhook-deliver</code>, <code>admission-reminder</code>, <code>admission-expiry</code>) — names from <code>queue.constants.ts:1-17</code></td></tr> </tbody></table> </div> <h2 id="c4--schedulesummarystrip"><a class="header" href="#c4--schedulesummarystrip">C4 — ScheduleSummaryStrip</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Base</td><td><code>AppCard</code> (<a href="scheduler/../00-shared/03_Component_Library.html">00-shared/03</a>)</td></tr> <tr><td>Content</td><td>counts: default (10 — <code>scheduler.service.ts:48-119</code>), custom, dead letters (→ S6). Tap navigates to filtered list</td></tr> </tbody></table> </div> <h2 id="c5--runhistoryrow"><a class="header" href="#c5--runhistoryrow">C5 — RunHistoryRow</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Base</td><td><code>AppListTile</code> (<a href="scheduler/../00-shared/03_Component_Library.html">00-shared/03</a>)</td></tr> <tr><td>Content</td><td>startedAt (relative + absolute), <code>JobStatusBadge</code>, duration ms, error excerpt (2 lines, <code>error</code> color, monospace)</td></tr> <tr><td>Data</td><td><code>(proposed)</code>; fields from BullMQ job state (attemptsMade, finishedOn, failedReason)</td></tr> </tbody></table> </div> <h2 id="c6--jobdefinitioncard"><a class="header" href="#c6--jobdefinitioncard">C6 — JobDefinitionCard</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Base</td><td><code>AppCard</code></td></tr> <tr><td>Content</td><td>trigger payload envelope spec <code>{eventType, tenantId:'system', correlationId, actorId:'scheduler'}</code> (<code>scheduler.service.ts:136-142</code>); retention <code>removeOnComplete {age:3600, count:100}</code>, <code>removeOnFail {age: 7d}</code> (<code>scheduler.service.ts:145-146</code>); retry <code>attempts: 3, exponential 5 s</code> (<code>bullmq.module.ts:60-65</code>)</td></tr> <tr><td>Use</td><td>S2 definition block — read-only, for operators/on-call</td></tr> </tbody></table> </div> <h2 id="c7--dlqcard"><a class="header" href="#c7--dlqcard">C7 — DLQCard</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Base</td><td><code>AppCard</code></td></tr> <tr><td>Content</td><td>originalQueue, originalJobName, originalJobId, failedReason (prominent), attemptsMade, failedAt, expandable JSON <code>data</code> (<code>dlq.setup.ts:12-21</code>)</td></tr> <tr><td>Actions</td><td>Replay (<code>AppButton</code>), Delete (opens <code>AppDialog</code> confirm)</td></tr> </tbody></table> </div> <h2 id="c8--presetcronchips-form-helper"><a class="header" href="#c8--presetcronchips-form-helper">C8 — PresetCronChips (form helper)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Base</td><td><code>AppChips</code> selectable</td></tr> <tr><td>Content</td><td>8 presets matching the defaults (<code>scheduler.service.ts:48-119</code>) — one-tap fill for S3's pattern field; free entry still allowed</td></tr> </tbody></table> </div> <h2 id="composition-map"><a class="header" href="#composition-map">Composition map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Components</th></tr></thead><tbody> <tr><td>S1 Jobs list</td><td>C4 summary, <code>AppListTile</code> + C1 + C2 + C3, <code>AppMenu</code>, <code>AppFAB</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppOfflineBanner</code></td></tr> <tr><td>S2 Detail</td><td>C6 definition, C5 history rows, C1/C2/C3 header</td></tr> <tr><td>S3 Create</td><td>C8 presets, <code>AppTextField</code>, <code>AppDropdown</code>, <code>AppBottomSheet</code>, <code>AppSnackbar</code></td></tr> <tr><td>S4 Logs</td><td>monospace log tiles, <code>AppSwitch</code> tail-live, level chips</td></tr> <tr><td>S5 Trigger</td><td><code>AppDialog</code> confirm</td></tr> <tr><td>S6 DLQ</td><td>C7 cards, <code>AppDialog</code>, <code>AppEmptyState</code></td></tr> </tbody></table> </div> <h2 id="reuse-rules"><a class="header" href="#reuse-rules">Reuse rules</a></h2> <ul> <li>No new design tokens: colors/spacing/type per <a href="scheduler/../00-shared/02_Design_Tokens.html">00-shared/02</a>.</li> <li>All components stateless + controlled (state lives in Blocs, 13_State_Management).</li> <li>Everything <code>(proposed)</code> until the console build phase starts.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-scheduler-module"><a class="header" href="#08--form-specifications-scheduler-module">08 — Form Specifications (Scheduler Module)</a></h1> <ul> <li><a href="scheduler/08_Form_Specifications.html#f1--create-custom-schedule-s3">F1 — Create Custom Schedule (S3)</a></li> <li><a href="scheduler/08_Form_Specifications.html#f2--manual-trigger-confirm-s5-proposed">F2 — Manual Trigger Confirm (S5, <code>(proposed)</code>)</a></li> <li><a href="scheduler/08_Form_Specifications.html#f3--remove-schedule-confirm-s1s2">F3 — Remove Schedule Confirm (S1/S2)</a></li> <li><a href="scheduler/08_Form_Specifications.html#f4--dlq-replay--delete-confirms-s6-proposed">F4 — DLQ Replay / Delete Confirms (S6, <code>(proposed)</code>)</a></li> <li><a href="scheduler/08_Form_Specifications.html#form-inventory">Form inventory</a></li> </ul> <blockquote> <p>Forms of the Scheduled Jobs console. Only S3 maps to a real endpoint (<code>POST /scheduler</code>); all other forms belong to <code>(proposed)</code> screens. Validation rules quoted from <code>dto/create-schedule.dto.ts</code>. Baseline form behavior per <a href="scheduler/../00-shared/03_Component_Library.html">00-shared/03</a> (AppTextField/AppDropdown/AppBottomSheet).</p> </blockquote> <hr /> <h2 id="f1--create-custom-schedule-s3"><a class="header" href="#f1--create-custom-schedule-s3">F1 — Create Custom Schedule (S3)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Widget</th><th>Validation (server)</th><th>Notes</th></tr></thead><tbody> <tr><td>Queue</td><td><code>AppDropdown</code></td><td><code>@IsIn(QUEUE_NAMES)</code> (<code>dto:26-28</code>)</td><td>11 whitelisted queues: <code>emails</code>, <code>push</code>, <code>in-app</code>, <code>attendance-process</code>, <code>biometric-sync</code>, <code>invoice-generate</code>, <code>payment-reminder</code>, <code>report-generate</code>, <code>audit-write</code>, <code>tenant-purge</code>, <code>cache-rebuild</code> (<code>dto:4-16</code>)</td></tr> <tr><td>Job name</td><td><code>AppTextField</code></td><td><code>@IsString()</code>, required (<code>dto:30-32</code>)</td><td>free text; visible in S1 rows</td></tr> <tr><td>Cron pattern</td><td><code>AppTextField</code> (monospace)</td><td><code>@Matches(/^(\S+\s+){4}\S+$/)</code> — error "Invalid cron pattern (5 fields required)" (<code>dto:20-24</code>)</td><td>client mirrors regex on blur; preset chips C8 fill from defaults (<code>scheduler.service.ts:48-119</code>)</td></tr> <tr><td>Timezone</td><td><code>AppDropdown</code></td><td><code>@IsString()</code>, optional, default <code>UTC</code> (<code>dto:38-41</code>)</td><td>only UTC used by defaults (<code>scheduler.service.ts:54,62,70,…</code>); tz aware display in S1</td></tr> <tr><td>Payload</td><td>JSON textarea</td><td>optional <code>Record<string, unknown></code> (<code>dto:34-36</code>)</td><td>passed to <code>queue.add(dto.jobName, dto.payload ?? {}, {repeat})</code> (<code>scheduler.service.ts:200-204</code>)</td></tr> </tbody></table> </div> <p><strong>Behavior</strong></p> <ul> <li>Submit → <code>POST /scheduler</code> (<code>scheduler.controller.ts:30-35</code>) → <code>createJob</code> (<code>scheduler.service.ts:198-208</code>); sheet closes on 201/200; row appears in S1 (optimistic, rollback on failure).</li> <li><strong>Duplicate warn:</strong> <code>registerRepeatable</code> skips an existing <code>name+pattern</code> (<code>scheduler.service.ts:128-133</code>) — UI shows a confirm before submitting a row that already exists in the S1 list.</li> <li>Validation errors inline per field; server errors surface in the sheet (envelope per <a href="scheduler/../00-shared/07_API_Conventions.html">00-shared/07</a>).</li> <li>a11y: each field labelled, error messages in live regions, first invalid field focused on submit.</li> </ul> <h2 id="f2--manual-trigger-confirm-s5-proposed"><a class="header" href="#f2--manual-trigger-confirm-s5-proposed">F2 — Manual Trigger Confirm (S5, <code>(proposed)</code>)</a></h2> <p>Not a form — a confirmation dialog:</p> <ul> <li>Content: "Trigger <code><jobName></code> → <code><child job></code> on queue <code><queue></code> now? Deliveries may duplicate — worker idempotency must hold." (child-job map from <code>jobs/*.job.ts</code>: <code>check-overdue</code>, <code>send-daily-digest</code>, <code>send-payment-reminder</code>, <code>generate-attendance-report</code>).</li> <li>Confirm disabled while a run is in flight; success → snackbar + S2 run-history refresh.</li> <li>No input fields; risk is disclosed, not edited (idempotency is the guard, <code>bullmq.module.ts:76</code>).</li> </ul> <h2 id="f3--remove-schedule-confirm-s1s2"><a class="header" href="#f3--remove-schedule-confirm-s1s2">F3 — Remove Schedule Confirm (S1/S2)</a></h2> <ul> <li>Dialog: "Remove repeatable <code><name></code> (<code><pattern></code>, <code><queue></code>)?" — matches <code>removeRepeatable(jobName, {pattern, tz: 'UTC'})</code> (<code>scheduler.service.ts:194</code>).</li> <li>Shows pattern+queue read-only so the operator confirms the exact triple (BullMQ dedupes by name+pattern+queue).</li> <li>Success → row removed from list (optimistic).</li> </ul> <h2 id="f4--dlq-replay--delete-confirms-s6-proposed"><a class="header" href="#f4--dlq-replay--delete-confirms-s6-proposed">F4 — DLQ Replay / Delete Confirms (S6, <code>(proposed)</code>)</a></h2> <ul> <li>Replay: "Re-queue <code><originalJobName></code> from <code><originalQueue></code>? It failed <code><attemptsMade></code>× — verify idempotency before retry." (<code>dlq.setup.ts:8-9,12-21</code>).</li> <li>Delete: standard destructive confirm; note "record will be lost".</li> </ul> <h2 id="form-inventory"><a class="header" href="#form-inventory">Form inventory</a></h2> <div class="table-wrapper"><table><thead><tr><th>Form</th><th>Screen</th><th>Endpoint</th><th>Status</th></tr></thead><tbody> <tr><td>F1 Create schedule</td><td>S3</td><td><code>POST /scheduler</code></td><td>implemented</td></tr> <tr><td>F2 Trigger confirm</td><td>S5</td><td><code>(proposed)</code></td><td>proposed</td></tr> <tr><td>F3 Remove confirm</td><td>S1/S2</td><td><code>DELETE /scheduler</code></td><td>implemented</td></tr> <tr><td>F4 DLQ replay/delete</td><td>S6</td><td><code>(proposed)</code></td><td>proposed</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-scheduler-module"><a class="header" href="#09--user-behaviour-scheduler-module">09 — User Behaviour (Scheduler Module)</a></h1> <ul> <li><a href="scheduler/09_User_Behaviour.html#b1--operators-scan-they-dont-search-first">B1 — Operators scan, they don't search first</a></li> <li><a href="scheduler/09_User_Behaviour.html#b2--status-is-derived-never-guessed">B2 — Status is derived, never guessed</a></li> <li><a href="scheduler/09_User_Behaviour.html#b3--refresh-cadence-follows-the-fastest-job">B3 — Refresh cadence follows the fastest job</a></li> <li><a href="scheduler/09_User_Behaviour.html#b4--reads-are-cheap-writes-are-gated">B4 — Reads are cheap; writes are gated</a></li> <li><a href="scheduler/09_User_Behaviour.html#b5--destructive-acts-require-exact-confirmation">B5 — Destructive acts require exact confirmation</a></li> <li><a href="scheduler/09_User_Behaviour.html#b6--recovery-paths-are-explicit">B6 — Recovery paths are explicit</a></li> <li><a href="scheduler/09_User_Behaviour.html#b7--offline-behaviour">B7 — Offline behaviour</a></li> <li><a href="scheduler/09_User_Behaviour.html#b8--formatting-rules">B8 — Formatting rules</a></li> <li><a href="scheduler/09_User_Behaviour.html#b9--never-auto-mutate">B9 — Never auto-mutate</a></li> <li><a href="scheduler/09_User_Behaviour.html#b10--analytics-proposed">B10 — Analytics (proposed)</a></li> </ul> <blockquote> <p>Behaviour rules for the operator/admin console — how users read, refresh, act and recover. All console behaviours are <code>(proposed)</code> except those anchored to <code>scheduler.controller.ts:23-46</code>. Baseline behaviours per <a href="scheduler/../00-shared/04_Design_System_Mapping.html">00-shared/04</a>/<a href="scheduler/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>; conflicts resolve in favour of <code>00-shared</code>.</p> </blockquote> <hr /> <h2 id="b1--operators-scan-they-dont-search-first"><a class="header" href="#b1--operators-scan-they-dont-search-first">B1 — Operators scan, they don't search first</a></h2> <p>The job list is ≤ a few dozen rows (10 defaults <code>scheduler.service.ts:48-119</code></p> <ul> <li>custom). Default view = full list grouped by queue, status badges on every row (<a href="scheduler/07_Component_Library.html">07</a> C1). Search only when > 50 rows (not expected).</li> </ul> <h2 id="b2--status-is-derived-never-guessed"><a class="header" href="#b2--status-is-derived-never-guessed">B2 — Status is derived, never guessed</a></h2> <p>Today <code>GET /scheduler</code> returns <code>{queue, name, pattern, tz}</code> only (<code>scheduler.service.ts:152-186</code>) — <strong>no status field</strong>. Until the <code>(proposed)</code> run-history endpoint exists, the UI must not fabricate statuses: unknown rows render the grey <code>unknown</code> badge (C1), and the summary strip shows "0 known" rather than "all healthy".</p> <h2 id="b3--refresh-cadence-follows-the-fastest-job"><a class="header" href="#b3--refresh-cadence-follows-the-fastest-job">B3 — Refresh cadence follows the fastest job</a></h2> <p>Auto-poll 60 s on S1 (matches <code>audit-flush</code> <code>*/1 * * * *</code>, <code>scheduler.service.ts:78-83</code> — the highest-frequency default) plus <code>RefreshIndicator</code> pull. Poll pauses in background and resumes on focus (shared baseline).</p> <h2 id="b4--reads-are-cheap-writes-are-gated"><a class="header" href="#b4--reads-are-cheap-writes-are-gated">B4 — Reads are cheap; writes are gated</a></h2> <p>Reads: <code>GET /scheduler</code> only (<code>scheduler.controller.ts:23-28</code>). Writes: create/remove are superadmin-only (<code>scheduler.read/create/delete</code>, <code>scheduler.controller.ts:24,31,38</code>) — UI hides actions without permission, never shows a 403-triggering button.</p> <h2 id="b5--destructive-acts-require-exact-confirmation"><a class="header" href="#b5--destructive-acts-require-exact-confirmation">B5 — Destructive acts require exact confirmation</a></h2> <p>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".</p> <h2 id="b6--recovery-paths-are-explicit"><a class="header" href="#b6--recovery-paths-are-explicit">B6 — Recovery paths are explicit</a></h2> <p>Missed run → manual trigger (S5) or DLQ replay (S6). Both warn about duplication risk (F2/F4) and the UI surfaces the idempotency note (<code>IdempotencyService</code>, <code>idempotency.service.ts:11-20</code>) so the operator can decide whether a re-run is safe.</p> <h2 id="b7--offline-behaviour"><a class="header" href="#b7--offline-behaviour">B7 — Offline behaviour</a></h2> <p>S1/S2/S6 are read-only: last-good payload + <code>AppOfflineBanner</code>; writes disabled. On reconnect → immediate refresh. Timestamps render "last known".</p> <h2 id="b8--formatting-rules"><a class="header" href="#b8--formatting-rules">B8 — Formatting rules</a></h2> <div class="table-wrapper"><table><thead><tr><th>Value</th><th>Rule</th></tr></thead><tbody> <tr><td>Cron</td><td>monospace as-is (never re-write the 5 fields)</td></tr> <tr><td>Humanized schedule</td><td>derived via the C2 map (<a href="scheduler/07_Component_Library.html">07</a> C2) — display only</td></tr> <tr><td>Time</td><td>both relative ("2 h ago") and absolute UTC + device-local; <strong>never</strong> claim a local run time (all jobs run UTC, <code>scheduler.service.ts:48-119</code>)</td></tr> <tr><td>Durations</td><td>ms for < 1 s, seconds above; no client math on totals</td></tr> <tr><td>Patterns</td><td>5-field cron, never 6-field (dto regex <code>dto:20-24</code>)</td></tr> </tbody></table> </div> <h2 id="b9--never-auto-mutate"><a class="header" href="#b9--never-auto-mutate">B9 — Never auto-mutate</a></h2> <p>No client-side re-enqueue, no optimistic "Run now" without confirm (F2). All state changes go through the API; UI only reflects server truth.</p> <h2 id="b10--analytics-proposed"><a class="header" href="#b10--analytics-proposed">B10 — Analytics (proposed)</a></h2> <p><code>{screen}.{action}</code> events per <a href="scheduler/05_Screen_Inventory.html">05 §Analytics</a>; none instrumented today (<a href="scheduler/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a>).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-scheduler-module"><a class="header" href="#10--interaction-specification-scheduler-module">10 — Interaction Specification (Scheduler Module)</a></h1> <ul> <li><a href="scheduler/10_Interaction_Specification.html#1-list--detail-drill-down-s1--s2">1. List → Detail drill-down (S1 → S2)</a></li> <li><a href="scheduler/10_Interaction_Specification.html#2-polling-loop-s1">2. Polling loop (S1)</a></li> <li><a href="scheduler/10_Interaction_Specification.html#3-create-schedule-s3">3. Create schedule (S3)</a></li> <li><a href="scheduler/10_Interaction_Specification.html#4-manual-trigger-s5-proposed">4. Manual trigger (S5, <code>(proposed)</code>)</a></li> <li><a href="scheduler/10_Interaction_Specification.html#5-remove-schedule-s1s2">5. Remove schedule (S1/S2)</a></li> <li><a href="scheduler/10_Interaction_Specification.html#6-dlq-actions-s6-proposed">6. DLQ actions (S6, <code>(proposed)</code>)</a></li> <li><a href="scheduler/10_Interaction_Specification.html#7-motion--feedback">7. Motion & feedback</a></li> <li><a href="scheduler/10_Interaction_Specification.html#8-a11y">8. a11y</a></li> </ul> <blockquote> <p>Interactions of the Scheduled Jobs console, including the polling loop, trigger/remove flows and DLQ actions. Screen refs per 05/06; motion tokens per <a href="scheduler/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>.</p> </blockquote> <hr /> <h2 id="1-list--detail-drill-down-s1--s2"><a class="header" href="#1-list--detail-drill-down-s1--s2">1. List → Detail drill-down (S1 → S2)</a></h2> <ul> <li>Row tap → <code>/admin/scheduler/jobs/:queue/:name</code> (queue+name disambiguate two <code>0 8 * * *</code> jobs: <code>fee-reminder</code> → <code>payment-reminder</code>, <code>admission-reminder-scan</code> → <code>admission-reminder</code>, <code>scheduler.service.ts:92-110</code>).</li> <li>Detail loads run history <code>(proposed)</code>; header keeps C1/C2/C3 identity.</li> <li>Back returns to list preserving scroll + filters.</li> </ul> <h2 id="2-polling-loop-s1"><a class="header" href="#2-polling-loop-s1">2. Polling loop (S1)</a></h2> <pre class="mermaid">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 </pre> <ul> <li>Single-flight: one request in flight; new ticks coalesce.</li> <li>Background: timer paused via <code>WidgetsBindingObserver</code>; on resume → immediate staleness check.</li> <li>Status enrichments <code>(proposed)</code> are fetched together with the list, not per row (N+1 avoided).</li> </ul> <h2 id="3-create-schedule-s3"><a class="header" href="#3-create-schedule-s3">3. Create schedule (S3)</a></h2> <ul> <li>FAB "+ New" → <code>AppBottomSheet</code> (max-width 640 dp); fields per F1.</li> <li>Preset chips (C8) fill the pattern field; regex re-validates on blur (<code>dto:20-24</code>).</li> <li>Submit → <code>POST /scheduler</code> (<code>scheduler.controller.ts:30-35</code>); optimistic row insert with rollback on error; duplicate warn per <code>08 §F1</code>.</li> </ul> <h2 id="4-manual-trigger-s5-proposed"><a class="header" href="#4-manual-trigger-s5-proposed">4. Manual trigger (S5, <code>(proposed)</code>)</a></h2> <ul> <li>Row menu "Run now" → F2 dialog → <code>(proposed)</code> <code>POST /scheduler/:queue/:name/trigger</code>.</li> <li>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, <code>jobs/*.job.ts</code>).</li> <li>Idempotency note displayed in dialog (F2).</li> </ul> <h2 id="5-remove-schedule-s1s2"><a class="header" href="#5-remove-schedule-s1s2">5. Remove schedule (S1/S2)</a></h2> <ul> <li>"Remove" → F3 dialog (name+pattern+queue) → <code>DELETE /scheduler?queue=&name=&pattern=</code> (<code>scheduler.controller.ts:37-46</code>) → <code>removeRepeatable(jobName, {pattern, tz:'UTC'})</code> (<code>scheduler.service.ts:188-196</code>). Optimistic removal, rollback + snackbar on failure.</li> </ul> <h2 id="6-dlq-actions-s6-proposed"><a class="header" href="#6-dlq-actions-s6-proposed">6. DLQ actions (S6, <code>(proposed)</code>)</a></h2> <ul> <li><strong>Replay</strong> → F4 confirm → <code>(proposed)</code> <code>POST /scheduler/dlq/:id/retry</code>; must preserve <code>correlationId</code>/<code>tenantId</code> from the record (<code>dlq.setup.ts:12-21</code>) so <code>IdempotencyService</code> dedup keys stay stable (<code>idempotency.service.ts:12</code>).</li> <li><strong>Delete</strong> → F4 confirm → <code>(proposed)</code> <code>DELETE /scheduler/dlq/:id</code>.</li> <li>Status of replay visible in S1/S2 after one poll cycle.</li> </ul> <h2 id="7-motion--feedback"><a class="header" href="#7-motion--feedback">7. Motion & feedback</a></h2> <div class="table-wrapper"><table><thead><tr><th>Action</th><th>Motion (<code>00-shared/08</code>)</th><th>Feedback</th></tr></thead><tbody> <tr><td>Row status change</td><td>badge cross-fade <code>m-fast</code></td><td>live region announce</td></tr> <tr><td>Sheet open</td><td><code>m-base</code> slide up</td><td>—</td></tr> <tr><td>Trigger/remove</td><td>button spinner in place</td><td>snackbar (success) / inline error</td></tr> <tr><td>Poll failure</td><td>none</td><td>keep-last-good + snackbar once per minute</td></tr> <tr><td>Offline</td><td>banner slide <code>m-base</code></td><td><code>AppOfflineBanner</code></td></tr> </tbody></table> </div> <h2 id="8-a11y"><a class="header" href="#8-a11y">8. a11y</a></h2> <ul> <li>Badge: icon + text, never color alone (C1).</li> <li>Forms: labels linked, errors in live regions, focus first invalid (F1).</li> <li>Cron text: <code>Semantics(label: "cron pattern, 5 fields")</code> — never spoken as raw five tokens.</li> <li>Dialog focus trap while open (S3/S5/S6 confirms).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-scheduler-module"><a class="header" href="#11--design-system-mapping-scheduler-module">11 — Design System Mapping (Scheduler Module)</a></h1> <ul> <li><a href="scheduler/11_Design_System_Mapping.html#1-semantic-colour-mapping">1. Semantic colour mapping</a></li> <li><a href="scheduler/11_Design_System_Mapping.html#2-typography">2. Typography</a></li> <li><a href="scheduler/11_Design_System_Mapping.html#3-spacing--layout">3. Spacing & layout</a></li> <li><a href="scheduler/11_Design_System_Mapping.html#4-iconography">4. Iconography</a></li> <li><a href="scheduler/11_Design_System_Mapping.html#5-responsive--platform-behaviour">5. Responsive & platform behaviour</a></li> <li><a href="scheduler/11_Design_System_Mapping.html#6-a11y--contrast">6. a11y & contrast</a></li> <li><a href="scheduler/11_Design_System_Mapping.html#7-motion">7. Motion</a></li> </ul> <blockquote> <p>Maps the Scheduled Jobs console to the platform design system (<a href="scheduler/../00-shared/02_Design_Tokens.html">00-shared/02</a> tokens, <a href="scheduler/../00-shared/03_Component_Library.html">00-shared/03</a> components, <a href="scheduler/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a> a11y). No new tokens; all module components are <code>(proposed)</code> (07).</p> </blockquote> <hr /> <h2 id="1-semantic-colour-mapping"><a class="header" href="#1-semantic-colour-mapping">1. Semantic colour mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Semantic</th><th>Token (<a href="scheduler/../00-shared/02_Design_Tokens.html">00-shared/02</a>)</th><th>Used for</th></tr></thead><tbody> <tr><td>Success</td><td>success (green)</td><td><code>JobStatusBadge.ok</code>, replay success</td></tr> <tr><td>Info / running</td><td>primary (blue)</td><td><code>JobStatusBadge.running</code> (pulse)</td></tr> <tr><td>Warning</td><td>warning (amber)</td><td><code>JobStatusBadge.missed</code> — no run in expected window</td></tr> <tr><td>Error</td><td>error (red)</td><td><code>JobStatusBadge.failed</code>, <code>failedReason</code> text, invalid cron</td></tr> <tr><td>Neutral</td><td>text secondary</td><td><code>unknown</code> badge, empty states</td></tr> <tr><td>Background surface</td><td>surface / surface-variant</td><td>cards (C4/C6/C7), log tiles</td></tr> </tbody></table> </div> <p>Never used: emoji/colour-only status (a11y rule C1, <a href="scheduler/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>).</p> <h2 id="2-typography-3"><a class="header" href="#2-typography-3">2. Typography</a></h2> <div class="table-wrapper"><table><thead><tr><th>Use</th><th>Token</th><th>Notes</th></tr></thead><tbody> <tr><td>Job names</td><td>body-medium, weight 500</td><td>row title</td></tr> <tr><td>Cron patterns</td><td><code>monospace</code> variant</td><td>code-styled, never auto-wrapped</td></tr> <tr><td>Run history / logs</td><td><code>monospace</code> body-small</td><td>timestamps + error excerpts</td></tr> <tr><td>Humanized schedule</td><td>body-small, text-secondary</td><td>C2 tooltip + summary</td></tr> <tr><td>Headers/strip counts</td><td>headline-small / label-large</td><td>C4 summary strip</td></tr> </tbody></table> </div> <h2 id="3-spacing--layout-1"><a class="header" href="#3-spacing--layout-1">3. Spacing & layout</a></h2> <ul> <li>Console is desktop-first (admin tool, <a href="scheduler/05_Screen_Inventory.html">05 §Keyboard</a>): list max-width 1200 dp; two-column list/detail ≥ 1024 dp.</li> <li>Cards: standard <code>AppCard</code> padding (16 dp); summary strip flush with list.</li> <li>Sheets (S3): max-width 640 dp, keyboard-avoiding.</li> </ul> <h2 id="4-iconography-11"><a class="header" href="#4-iconography-11">4. Iconography</a></h2> <div class="table-wrapper"><table><thead><tr><th>Meaning</th><th>Icon</th></tr></thead><tbody> <tr><td>ok / failed / running / missed / unknown</td><td>check-circle / alert-circle / pulse-dot / clock / minus (C1)</td></tr> <tr><td>Remove</td><td>delete-outline</td></tr> <tr><td>Run now</td><td>play</td></tr> <tr><td>Logs</td><td>file-text</td></tr> <tr><td>DLQ</td><td>inbox-archive</td></tr> <tr><td>Queue family (C3)</td><td>delivery → send; processing → cog; system → server</td></tr> </tbody></table> </div> <h2 id="5-responsive--platform-behaviour"><a class="header" href="#5-responsive--platform-behaviour">5. Responsive & platform behaviour</a></h2> <div class="table-wrapper"><table><thead><tr><th>Breakpoint</th><th>Layout</th></tr></thead><tbody> <tr><td>< 640 dp</td><td>single column; sheets full-width; cron input smaller but still monospace</td></tr> <tr><td>640–1023</td><td>list; S2 detail pushed below</td></tr> <tr><td>≥ 1024 dp</td><td>two-column list/detail master-detail</td></tr> <tr><td>Desktop</td><td>hover row highlight; menu on hover too</td></tr> </tbody></table> </div> <h2 id="6-a11y--contrast"><a class="header" href="#6-a11y--contrast">6. a11y & contrast</a></h2> <ul> <li>Badges: icon+text, contrast ≥ 4.5:1 for text, ≥ 3:1 for badge fill (<a href="scheduler/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>).</li> <li>Focus order: list → row menu → actions; sheets trap focus (10 §8).</li> <li>Timestamps: screen-reader gets absolute UTC; sighted users get relative.</li> <li>Logs screen: no auto-scroll while screen reader active (tail-live off unless explicitly enabled, S4).</li> </ul> <h2 id="7-motion"><a class="header" href="#7-motion">7. Motion</a></h2> <p>All motion per <a href="scheduler/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>: <code>m-fast</code> 150 ms state changes, <code>m-base</code> 300 ms sheet/route transitions; pulse animation on <code>running</code> badge only (reduced-motion respect).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-scheduler-module"><a class="header" href="#12--api-mapping-scheduler-module">12 — API Mapping (Scheduler Module)</a></h1> <ul> <li><a href="scheduler/12_API_Mapping.html#e1--list-repeatable-jobs-implemented">E1 — List repeatable jobs (implemented)</a></li> <li><a href="scheduler/12_API_Mapping.html#e2--create-custom-repeatable-job-implemented">E2 — Create custom repeatable job (implemented)</a></li> <li><a href="scheduler/12_API_Mapping.html#e3--remove-repeatable-job-implemented">E3 — Remove repeatable job (implemented)</a></li> <li><a href="scheduler/12_API_Mapping.html#e4--run-history-proposed">E4 — Run history <code>(proposed)</code></a></li> <li><a href="scheduler/12_API_Mapping.html#e5--job-logs-proposed">E5 — Job logs <code>(proposed)</code></a></li> <li><a href="scheduler/12_API_Mapping.html#e6--manual-trigger-proposed">E6 — Manual trigger <code>(proposed)</code></a></li> <li><a href="scheduler/12_API_Mapping.html#e7--dlq-listing-proposed">E7 — DLQ listing <code>(proposed)</code></a></li> <li><a href="scheduler/12_API_Mapping.html#e8--dlq-replay--delete-proposed">E8 — DLQ replay / delete <code>(proposed)</code></a></li> <li><a href="scheduler/12_API_Mapping.html#non-rest-surface-the-actual-work">Non-REST surface (the actual work)</a></li> <li><a href="scheduler/12_API_Mapping.html#client-contract-summary">Client contract summary</a></li> </ul> <blockquote> <p>Honest statement first: <strong>the scheduler has no monitoring API surface.</strong> 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 <code>(proposed)</code> and would be new endpoints. The scheduler's real work happens through <strong>cron → BullMQ repeatables → queue internals</strong>, not REST. Wire contract per <a href="scheduler/../00-shared/07_API_Conventions.html">00-shared/07</a> (base <code>/api/v1</code>, Bearer JWT, envelopes).</p> </blockquote> <hr /> <h2 id="e1--list-repeatable-jobs-implemented"><a class="header" href="#e1--list-repeatable-jobs-implemented">E1 — List repeatable jobs (implemented)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>GET /api/v1/scheduler</code> (<code>scheduler.controller.ts:23-28</code>)</td></tr> <tr><td>Guard</td><td><code>JwtAuthGuard</code> + <code>@Permissions('scheduler.read')</code> (<code>scheduler.controller.ts:18,24</code>)</td></tr> <tr><td>Request</td><td>none</td></tr> <tr><td>Response</td><td>envelope <code>data: {queue, name, pattern, tz}[]</code> — iterates the 10 queues' <code>getRepeatableJobs()</code> (<code>scheduler.service.ts:152-186</code>), <code>tz</code> defaults <code>'UTC'</code> (<code>:181</code>)</td></tr> <tr><td>Notes</td><td>No status/last-run fields — <strong>console status badges are impossible from this endpoint alone</strong> (<a href="scheduler/07_Component_Library.html">07</a> C1, <a href="scheduler/09_User_Behaviour.html">09</a> B2)</td></tr> </tbody></table> </div> <h2 id="e2--create-custom-repeatable-job-implemented"><a class="header" href="#e2--create-custom-repeatable-job-implemented">E2 — Create custom repeatable job (implemented)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>POST /api/v1/scheduler</code> (<code>scheduler.controller.ts:30-35</code>)</td></tr> <tr><td>Guard</td><td><code>scheduler.create</code> (<code>:31</code>)</td></tr> <tr><td>Body</td><td><code>CreateScheduleDto</code> — queue (11 whitelisted, <code>dto:4-16</code>), jobName, 5-field pattern (<code>dto:20-24</code>), optional payload/tz (<code>dto:18-41</code>)</td></tr> <tr><td>Side effect</td><td><code>queue.add(jobName, payload, {repeat: {pattern, tz}, removeOnComplete {1h/100}, removeOnFail {7d}})</code> (<code>scheduler.service.ts:198-208</code>)</td></tr> <tr><td>Errors</td><td>400 invalid cron/queue; 401; 403; duplicate <strong>skipped silently</strong> (<code>scheduler.service.ts:128-133</code>)</td></tr> </tbody></table> </div> <h2 id="e3--remove-repeatable-job-implemented"><a class="header" href="#e3--remove-repeatable-job-implemented">E3 — Remove repeatable job (implemented)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>DELETE /api/v1/scheduler?queue=&name=&pattern=</code> (<code>scheduler.controller.ts:37-46</code>)</td></tr> <tr><td>Guard</td><td><code>scheduler.delete</code> (<code>:38</code>)</td></tr> <tr><td>Side effect</td><td><code>removeRepeatable(jobName, {pattern, tz: 'UTC'})</code> (<code>scheduler.service.ts:188-196</code>); unknown queue → error from <code>getQueue</code> (<code>:210-226</code>)</td></tr> </tbody></table> </div> <h2 id="e4--run-history-proposed"><a class="header" href="#e4--run-history-proposed">E4 — Run history <code>(proposed)</code></a></h2> <p><code>GET /scheduler/runs?queue=&name=</code> — last N runs (startedAt, status, duration, error). No source today: BullMQ job state holds the data (<code>attemptsMade</code>, <code>finishedOn</code>, <code>failedReason</code> per <code>dlq.setup.ts:8-21</code>), retention window bounded by <code>removeOnComplete {age:3600, count:100}</code> and <code>removeOnFail {age: 7d}</code> (<code>scheduler.service.ts:145-146</code>), failed jobs 14 d global (<code>bullmq.module.ts:63-64</code>). Backs S2 + S1 badges.</p> <h2 id="e5--job-logs-proposed"><a class="header" href="#e5--job-logs-proposed">E5 — Job logs <code>(proposed)</code></a></h2> <p><code>GET /scheduler/jobs/:queue/:name/logs?level=&tail=</code> — nestsjs-pino worker logs (<code>AGENTS.md</code> stack) surfaced by jobId; no log-retrieval endpoint today. Backs S4.</p> <h2 id="e6--manual-trigger-proposed"><a class="header" href="#e6--manual-trigger-proposed">E6 — Manual trigger <code>(proposed)</code></a></h2> <p><code>POST /scheduler/:queue/:name/trigger</code> — re-enqueues the child job exactly as the job class would (<code>jobs/*.job.ts</code> pattern: <code>check-overdue</code>, <code>send-daily-digest</code>, <code>send-payment-reminder</code>, <code>generate-attendance-report</code>). Must preserve <code>correlationId</code> so <code>IdempotencyService</code> dedup holds (<code>idempotency.service.ts:11-20</code>). Backs S5.</p> <h2 id="e7--dlq-listing-proposed"><a class="header" href="#e7--dlq-listing-proposed">E7 — DLQ listing <code>(proposed)</code></a></h2> <p><code>GET /scheduler/dlq</code> — records written by <code>setupDlqListener</code> when <code>attemptsMade >= (opts.attempts ?? 3)</code> (<code>dlq.setup.ts:8-9</code>): <code>originalQueue, originalJobId, originalJobName, data, failedReason, attemptsMade, failedAt</code> (<code>:12-21</code>). Backs S6.</p> <h2 id="e8--dlq-replay--delete-proposed"><a class="header" href="#e8--dlq-replay--delete-proposed">E8 — DLQ replay / delete <code>(proposed)</code></a></h2> <p><code>POST /scheduler/dlq/:id/retry</code> (re-add to original queue, preserving <code>correlationId</code>/<code>tenantId</code>), <code>DELETE /scheduler/dlq/:id</code>.</p> <h2 id="non-rest-surface-the-actual-work"><a class="header" href="#non-rest-surface-the-actual-work">Non-REST surface (the actual work)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Trigger</th><th>Mechanism</th><th>Target</th><th>Source</th></tr></thead><tbody> <tr><td>10 default schedules</td><td>BullMQ repeatable <code>queue.add(..., {repeat:{pattern,tz:'UTC'}})</code></td><td>10 queues</td><td><code>scheduler.service.ts:48-147</code></td></tr> <tr><td>Fan-out child jobs</td><td>direct <code>queue.add</code> from job classes</td><td>see table below</td><td><code>jobs/*.job.ts</code></td></tr> <tr><td>Event-driven jobs</td><td><code>eventQueueMap</code> route → <code>QueueBridge</code></td><td>emails/push/in-app/audit-write/attendance-process/invoice-generate</td><td><code>event-queue-map.ts:6-43</code>, <code>queue-bridge.service.ts:40-75</code></td></tr> <tr><td>Failure path</td><td>retry 3× exp. 5 s → DLQ</td><td>per queue → <code>dlq</code></td><td><code>bullmq.module.ts:60-65</code>, <code>dlq.setup.ts:5-27</code></td></tr> </tbody></table> </div><div class="table-wrapper"><table><thead><tr><th>Repeatable</th><th>Cron (UTC)</th><th>Child job → queue</th></tr></thead><tbody> <tr><td><code>overdue-scan</code></td><td><code>0 6 * * *</code></td><td><code>check-overdue</code> → <code>invoice-generate</code> (<code>scheduler.service.ts:49-55</code>, <code>overdue-scan.job.ts:12-19</code>)</td></tr> <tr><td><code>daily-digest</code></td><td><code>0 9 * * *</code></td><td><code>send-daily-digest</code> → <code>emails</code> (<code>:56-62</code>, <code>daily-digest.job.ts:12-25</code>)</td></tr> <tr><td><code>dashboard-rebuild</code></td><td><code>*/5 * * * *</code></td><td>(payload-only) → <code>cache-rebuild</code> (<code>:63-69</code>)</td></tr> <tr><td><code>biometric-sync</code></td><td><code>*/15 * * * *</code></td><td>→ <code>biometric-sync</code> (<code>:70-76</code>)</td></tr> <tr><td><code>audit-flush</code></td><td><code>*/1 * * * *</code></td><td>→ <code>audit-write</code> (<code>:77-83</code>)</td></tr> <tr><td><code>retention-archive</code></td><td><code>0 2 * * 0</code></td><td>→ <code>tenant-purge</code> (<code>:84-90</code>)</td></tr> <tr><td><code>fee-reminder</code></td><td><code>0 8 * * *</code></td><td>per-invoice <code>send-payment-reminder</code> → <code>payment-reminder</code> (<code>:91-97</code>, <code>fee-reminder.job.ts:16-47</code>)</td></tr> <tr><td><code>attendance-report-daily</code></td><td><code>0 7 * * *</code></td><td><code>generate-attendance-report</code> → <code>report-generate</code> (<code>:98-104</code>, <code>attendance-report.job.ts:13-28</code>)</td></tr> <tr><td><code>admission-reminder-scan</code></td><td><code>0 8 * * *</code></td><td>→ <code>admission-reminder</code> (<code>:105-111</code>)</td></tr> <tr><td><code>admission-expiry-scan</code></td><td><code>0 2 * * *</code></td><td>→ <code>admission-expiry</code> (<code>:112-118</code>)</td></tr> </tbody></table> </div> <h2 id="client-contract-summary-2"><a class="header" href="#client-contract-summary-2">Client contract summary</a></h2> <ul> <li>Reads: E1 today; E4-E8 gated on <code>(proposed)</code> endpoints — console builds against them as stubs behind a repository interface.</li> <li>Tenant: scheduler registry is <strong>platform-level</strong> (<code>tenantId: 'system'</code> trigger payloads, <code>scheduler.service.ts:139</code>) — console is superadmin surface, not tenant-scoped.</li> <li>401 → reauth; 403 → hide module; 429 → backoff poll (api tier, <a href="scheduler/../00-shared/07_API_Conventions.html">00-shared/07 §4</a>); 5xx → keep-last-good.</li> <li>Rate budget: 1 req/min poll = 60 req/h per console tab — fine.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-scheduler-module"><a class="header" href="#13--state-management-scheduler-module">13 — State Management (Scheduler Module)</a></h1> <ul> <li><a href="scheduler/13_State_Management.html#1-state-model-jobsbloc">1. State model (JobsBloc)</a></li> <li><a href="scheduler/13_State_Management.html#2-blocs">2. Blocs</a></li> <li><a href="scheduler/13_State_Management.html#3-fetch-flow-jobsbloc">3. Fetch flow (JobsBloc)</a></li> <li><a href="scheduler/13_State_Management.html#4-cron--queue-flow-server-for-the-consoles-mental-model">4. Cron → queue flow (server, for the console's mental model)</a></li> <li><a href="scheduler/13_State_Management.html#5-manual-trigger-s5">5. Manual trigger (S5)</a></li> <li><a href="scheduler/13_State_Management.html#6-cache--staleness">6. Cache & staleness</a></li> <li><a href="scheduler/13_State_Management.html#7-error-mapping-00-shared06-5">7. Error mapping (<code>00-shared/06 §5</code>)</a></li> </ul> <blockquote> <p>Bloc-based state for the Scheduled Jobs console per <a href="scheduler/../00-shared/06_State_Management.html">00-shared/06</a>. <strong>Server truth:</strong> BullMQ owns job state; the API exposes only the registry (E1-E3 implemented, E4-E8 <code>(proposed)</code>). The console is superadmin-only (platform level, <code>scheduler.controller.ts:24,31,38</code>).</p> </blockquote> <hr /> <h2 id="1-state-model-jobsbloc"><a class="header" href="#1-state-model-jobsbloc">1. State model (JobsBloc)</a></h2> <pre><code class="language-dart">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 } </code></pre> <p><code>JobRow</code> mirrors <code>scheduler.service.ts:152-186</code> exactly — no client-side derivation (<code>09</code> B8/B9). Status badges read only <code>enrich</code> when present, else <code>unknown</code> (<code>07</code> C1).</p> <h2 id="2-blocs"><a class="header" href="#2-blocs">2. Blocs</a></h2> <div class="table-wrapper"><table><thead><tr><th>Bloc</th><th>Owns</th><th>Source</th></tr></thead><tbody> <tr><td><code>JobsBloc</code></td><td>registry list, enrich map, poll loop</td><td>E1 + E4 <code>(proposed)</code></td></tr> <tr><td><code>JobDetailBloc</code></td><td>one job's definition + run history</td><td>E1 + E4</td></tr> <tr><td><code>CreateScheduleBloc</code></td><td>F1 form state, submit, duplicate warn</td><td>E2</td></tr> <tr><td><code>DlqBloc</code></td><td>DLQ records, replay/delete</td><td>E7/E8 <code>(proposed)</code></td></tr> <tr><td><code>LogsBloc</code></td><td>S4 log stream, level filter, tail-live</td><td>E5 <code>(proposed)</code></td></tr> </tbody></table> </div> <p>Single <code>JobsBloc</code> per <code>/admin/scheduler</code> route, provided in the screen, disposed on pop (poll timer dies with it).</p> <h2 id="3-fetch-flow-jobsbloc"><a class="header" href="#3-fetch-flow-jobsbloc">3. Fetch flow (JobsBloc)</a></h2> <pre class="mermaid">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 </pre> <ul> <li>Single-flight + coalescing (10 §2); <code>refresh({force})</code> for pull-to-refresh.</li> <li>Background: timer paused, resume → immediate staleness check (<code>09</code> B3).</li> </ul> <h2 id="4-cron--queue-flow-server-for-the-consoles-mental-model"><a class="header" href="#4-cron--queue-flow-server-for-the-consoles-mental-model">4. Cron → queue flow (server, for the console's mental model)</a></h2> <pre class="mermaid">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] </pre> <h2 id="5-manual-trigger-s5"><a class="header" href="#5-manual-trigger-s5">5. Manual trigger (S5)</a></h2> <p><code>TriggerBloc</code>-free: <code>JobDetailBloc.trigger()</code> → confirm dialog (F2) → E6 <code>(proposed)</code> → on success refetch run history; button disabled while in-flight (10 §4). If E6 absent → button disabled with tooltip.</p> <h2 id="6-cache--staleness"><a class="header" href="#6-cache--staleness">6. Cache & staleness</a></h2> <div class="table-wrapper"><table><thead><tr><th>Layer</th><th>Scope</th><th>TTL</th><th>Writes on</th></tr></thead><tbody> <tr><td>Server</td><td>BullMQ state</td><td>retention 1 h / 100 (triggers), 14 d failed (<code>bullmq.module.ts:63-64</code>)</td><td>every run</td></tr> <tr><td>Client volatile</td><td>registry list <code>sl:scheduler:jobs</code></td><td>60 s</td><td>successful E1 only</td></tr> <tr><td>Client render</td><td>—</td><td>—</td><td>last-good kept; stale banner past 60 s</td></tr> </tbody></table> </div> <h2 id="7-error-mapping-00-shared06-5"><a class="header" href="#7-error-mapping-00-shared06-5">7. Error mapping (<code>00-shared/06 §5</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Error</th><th>UI</th></tr></thead><tbody> <tr><td>401</td><td>silent refresh → replay; fail → session expiry</td></tr> <tr><td>403</td><td>console hidden (superadmin gate, <code>scheduler.controller.ts:24</code>)</td></tr> <tr><td>429</td><td>skip next poll; snackbar once</td></tr> <tr><td>404</td><td>job removed concurrently → drop row</td></tr> <tr><td>5xx</td><td>keep lastGood; snackbar ≥ 4 s; no skeleton flash</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-scheduler-module"><a class="header" href="#14--qa-checklist-scheduler-module">14 — QA Checklist (Scheduler Module)</a></h1> <ul> <li><a href="scheduler/14_QA_Checklist.html#1-registration--idempotency-of-defaults">1. Registration & idempotency of defaults</a></li> <li><a href="scheduler/14_QA_Checklist.html#2-missed-runs--recovery">2. Missed runs & recovery</a></li> <li><a href="scheduler/14_QA_Checklist.html#3-timezone--dst">3. Timezone / DST</a></li> <li><a href="scheduler/14_QA_Checklist.html#4-overlap-protection">4. Overlap protection</a></li> <li><a href="scheduler/14_QA_Checklist.html#5-failure-path--dlq">5. Failure path & DLQ</a></li> <li><a href="scheduler/14_QA_Checklist.html#6-idempotency">6. Idempotency</a></li> <li><a href="scheduler/14_QA_Checklist.html#7-tenant-isolation">7. Tenant isolation</a></li> <li><a href="scheduler/14_QA_Checklist.html#8-security--rbac">8. Security & RBAC</a></li> <li><a href="scheduler/14_QA_Checklist.html#9-console-qa-proposed">9. Console QA <code>(proposed)</code></a></li> <li><a href="scheduler/14_QA_Checklist.html#10-perf--load">10. Perf & load</a></li> </ul> <blockquote> <p>Test matrix for the scheduler backend + the <code>(proposed)</code> console. Sources: <code>scheduler.service.ts</code>, <code>jobs/*.job.ts</code>, <code>bullmq.module.ts</code>, <code>dlq.setup.ts</code>, <code>idempotency.service.ts</code>, <code>tenant-context.service.ts</code>. Backend items are testable today; console items are <code>(proposed)</code>.</p> </blockquote> <hr /> <h2 id="1-registration--idempotency-of-defaults"><a class="header" href="#1-registration--idempotency-of-defaults">1. Registration & idempotency of defaults</a></h2> <ul> <li><input disabled="" type="checkbox"/> Fresh boot registers exactly 10 repeatables, one per row of the table (01 §2; <code>scheduler.service.ts:48-119</code>).</li> <li><input disabled="" type="checkbox"/> Reboot with existing repeatables → <strong>no duplicates</strong> (<code>:128-133</code> skip).</li> <li><input disabled="" type="checkbox"/> <code>GET /scheduler</code> returns 10 rows with correct <code>{queue,name,pattern,tz}</code>, <code>tz</code> defaulting <code>UTC</code> (<code>:152-186</code>).</li> <li><input disabled="" type="checkbox"/> Redis flush mid-run → next boot re-registers all 10 (journey 3, 03).</li> <li><input disabled="" type="checkbox"/> Custom job with identical name+pattern is silently skipped (<code>:128-133</code>) — UI mirrors the warn (08 §F1).</li> </ul> <h2 id="2-missed-runs--recovery"><a class="header" href="#2-missed-runs--recovery">2. Missed runs & recovery</a></h2> <ul> <li><input disabled="" type="checkbox"/> Simulate outage: kill worker > 1 h, restart → audit-flush resumes; backlog bounded by repeatable semantics (missed fire = skipped, not burst).</li> <li><input disabled="" type="checkbox"/> Manual re-trigger path (S5 <code>(proposed)</code>) enqueues exactly one child job.</li> <li><input disabled="" type="checkbox"/> Re-trigger after partial success does not double-deliver (<code>IdempotencyService</code>, <code>idempotency.service.ts:11-20</code>).</li> <li><input disabled="" type="checkbox"/> Operator can confirm "no run in expected window" from S1 badges (<code>missed</code>, C1) — depends on E4 <code>(proposed)</code>.</li> </ul> <h2 id="3-timezone--dst"><a class="header" href="#3-timezone--dst">3. Timezone / DST</a></h2> <ul> <li><input disabled="" type="checkbox"/> All 10 defaults registered with <code>tz: 'UTC'</code> (<code>scheduler.service.ts:54,62,70,76,82,89,96,103,110,117</code>).</li> <li><input disabled="" type="checkbox"/> Custom schedules accept explicit tz (<code>dto:38-41</code>); cron fires at the wall-clock instant in that tz across a DST boundary.</li> <li><input disabled="" type="checkbox"/> Console renders UTC patterns without pretending to local run times (<a href="scheduler/09_User_Behaviour.html">09</a> B8).</li> <li><input disabled="" type="checkbox"/> No 6-field cron accepted (5-field regex <code>dto:20-24</code>).</li> </ul> <h2 id="4-overlap-protection"><a class="header" href="#4-overlap-protection">4. Overlap protection</a></h2> <ul> <li><input disabled="" type="checkbox"/> A long-running child job that outlives its next trigger does not produce concurrent duplicate work (BullMQ repeatable + <code>attempts</code> config <code>bullmq.module.ts:60-65</code>; verify overlap behaviour is intended fire-and-forget per queue).</li> <li><input disabled="" type="checkbox"/> <code>fee-reminder</code> enqueues exactly one <code>send-payment-reminder</code> per due invoice (ISSUED/PARTIAL, due ≤ 3 days, <code>fee-reminder.job.ts:16-23,25-41</code>).</li> <li><input disabled="" type="checkbox"/> <code>attendance-report-daily</code> payload carries <code>reportType</code> correctly (<code>attendance-report.job.ts:13-28</code>).</li> </ul> <h2 id="5-failure-path--dlq"><a class="header" href="#5-failure-path--dlq">5. Failure path & DLQ</a></h2> <ul> <li><input disabled="" type="checkbox"/> Job fails 1×, 2× → retried with exponential 5 s backoff (<code>bullmq.module.ts:60-65</code>).</li> <li><input disabled="" type="checkbox"/> Fails 3rd time → DLQ record with <code>originalQueue, originalJobId, originalJobName, data, failedReason, attemptsMade, failedAt</code> (<code>dlq.setup.ts:8-21</code>).</li> <li><input disabled="" type="checkbox"/> DLQ replay preserves <code>correlationId</code>/<code>tenantId</code> (idempotency keys stay stable, <code>idempotency.service.ts:12</code>).</li> <li><input disabled="" type="checkbox"/> Failed-trigger retention: 7 d (<code>scheduler.service.ts:146</code>); global failed retention 14 d (<code>bullmq.module.ts:63-64</code>) — DLQ viewer shows what actually exists (S6 empty states honest, <a href="scheduler/09_User_Behaviour.html">09</a> B2).</li> </ul> <h2 id="6-idempotency"><a class="header" href="#6-idempotency">6. Idempotency</a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>sl:idempotency:{jobId}</code> key set via SET NX PX 300 s (<code>idempotency.service.ts:11-20</code>); second delivery within TTL is dropped.</li> <li><input disabled="" type="checkbox"/> Expired key (job > 5 min) re-processes — accepted trade-off, documented.</li> <li><input disabled="" type="checkbox"/> QueueBridge event path dedupes on <code>{correlationId}:{eventType}</code> (<code>queue-bridge.service.ts:44-46</code>).</li> </ul> <h2 id="7-tenant-isolation"><a class="header" href="#7-tenant-isolation">7. Tenant isolation</a></h2> <ul> <li><input disabled="" type="checkbox"/> Workers restore context via <code>tenantContext.run</code> before touching repositories (<code>report.worker.ts:18-30</code>; also finance/attendance/admission/ inapp workers, <a href="scheduler/01_Product_Overview.html">01 §7</a>).</li> <li><input disabled="" type="checkbox"/> <strong>Gap:</strong> <code>FeeReminderJob</code> queries <code>InvoiceRepository</code> directly without wrapping in <code>tenantContext.run</code> (<code>fee-reminder.job.ts:20-23</code>) while the trigger payload carries <code>tenantId: 'system'</code> (<code>scheduler.service.ts:139</code>) — verify query scoping and either wrap the job or confirm intended cross-tenant scan. <strong>Open QA risk.</strong></li> <li><input disabled="" type="checkbox"/> Trigger payloads never read <code>tenantId</code> from operator input; custom create DTO has no tenant field (<code>create-schedule.dto.ts:18-41</code>).</li> </ul> <h2 id="8-security--rbac"><a class="header" href="#8-security--rbac">8. Security & RBAC</a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>scheduler.read/create/delete</code> enforced on E1/E2/E3 (<code>scheduler.controller.ts:24,31,38</code>).</li> <li><input disabled="" type="checkbox"/> Custom-job payload cannot inject arbitrary queue names (whitelist <code>dto:4-16</code>).</li> <li><input disabled="" type="checkbox"/> Console hides actions without permission (09 B4).</li> </ul> <h2 id="9-console-qa-proposed"><a class="header" href="#9-console-qa-proposed">9. Console QA <code>(proposed)</code></a></h2> <ul> <li><input disabled="" type="checkbox"/> S1 poll loop: 60 s cadence, single-flight, background pause (10 §2, 13 §3).</li> <li><input disabled="" type="checkbox"/> S2 run history matches retention reality (1 h/100, 7 d, 14 d — S2 note).</li> <li><input disabled="" type="checkbox"/> S3 duplicate warn; invalid cron inline error matches server message "Invalid cron pattern (5 fields required)" (<code>dto:22</code>).</li> <li><input disabled="" type="checkbox"/> S6 replay button disabled until E8 exists.</li> <li><input disabled="" type="checkbox"/> a11y: badges icon+text; live regions; focus traps (11 §6).</li> </ul> <h2 id="10-perf--load"><a class="header" href="#10-perf--load">10. Perf & load</a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>listJobs</code> across 10 queues stays < 1 s at 100+ repeatables each (<code>scheduler.service.ts:152-186</code>).</li> <li><input disabled="" type="checkbox"/> Poll of E1 + enrich does not exceed api tier 100 req/min (<a href="scheduler/../00-shared/07_API_Conventions.html">00-shared/07 §4</a>).</li> <li><input disabled="" type="checkbox"/> DLQ listener attaches once per worker — no duplicate records on restart (<code>dlq-listener.service.ts:31-44</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-scheduler-module"><a class="header" href="#15--flutter-implementation-guide-scheduler-module">15 — Flutter Implementation Guide (Scheduler Module)</a></h1> <ul> <li><a href="scheduler/15_Flutter_Implementation_Guide.html#1-module-skeleton">1. Module skeleton</a></li> <li><a href="scheduler/15_Flutter_Implementation_Guide.html#2-data-layer">2. Data layer</a></li> <li><a href="scheduler/15_Flutter_Implementation_Guide.html#3-blocs">3. Blocs</a></li> <li><a href="scheduler/15_Flutter_Implementation_Guide.html#4-key-widgets">4. Key widgets</a></li> <li><a href="scheduler/15_Flutter_Implementation_Guide.html#5-polling--background">5. Polling & background</a></li> <li><a href="scheduler/15_Flutter_Implementation_Guide.html#6-console-behaviour-gates">6. Console behaviour gates</a></li> <li><a href="scheduler/15_Flutter_Implementation_Guide.html#7-tests">7. Tests</a></li> <li><a href="scheduler/15_Flutter_Implementation_Guide.html#8-delivery-checklist">8. Delivery checklist</a></li> </ul> <blockquote> <p>Build guide for the Scheduled Jobs console (<code>(proposed)</code> screens S1-S6). Native mobile is excluded from PRD Phase 1 (web-first, <code>PRODUCT_REQUIREMENTS_DOCUMENT.md:144</code>) — this guide targets the Flutter web-first responsive client per <a href="scheduler/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a>; the scheduler console is a <strong>superadmin platform surface</strong> (<code>scheduler.controller.ts:24,31,38</code>). Only E1-E3 exist server-side; E4-E8 <code>(proposed)</code> — stub behind a repository interface and ship read-only until they land.</p> </blockquote> <hr /> <h2 id="1-module-skeleton"><a class="header" href="#1-module-skeleton">1. Module skeleton</a></h2> <pre><code>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 </code></pre> <p>Routes: <code>/admin/scheduler</code>, <code>/admin/scheduler/jobs/:queue/:name</code>, <code>/admin/scheduler/jobs/:queue/:name/logs</code>, <code>/admin/scheduler/dlq</code> (04 §5).</p> <h2 id="2-data-layer"><a class="header" href="#2-data-layer">2. Data layer</a></h2> <pre><code class="language-dart">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 } </code></pre> <ul> <li>Auth: Bearer JWT via shared client; 401 → refresh → replay (<code>00-shared/06 §3.6</code>).</li> <li>Error mapping per <code>13 §7</code> (401 reauth, 403 hide, 429 backoff, 404 drop row, 5xx keep-last-good).</li> <li><code>JobRow.fromJson</code> mirrors <code>scheduler.service.ts:152-186</code> <strong>only</strong> — no status fields exist yet (09 B2); <code>enrich</code> map filled from E4 when it ships.</li> </ul> <h2 id="3-blocs"><a class="header" href="#3-blocs">3. Blocs</a></h2> <ul> <li><code>JobsBloc</code>: <code>load()</code>, <code>refresh({force})</code>, <code>Timer.periodic(60 s)</code> started on Loaded, cancelled in <code>close()</code>; single-flight + coalescing; background pause via <code>WidgetsBindingObserver</code> (13 §3).</li> <li><code>JobDetailBloc</code>: header from E1 row + run history E4 (stub → empty state with retention note "triggers keep 1 h/100 completed, failed 7 d", <code>scheduler.service.ts:145-146</code>).</li> <li><code>CreateScheduleBloc</code>: F1 fields; cron regex client-side <code>^(\S+\s+){4}\S+$</code> mirroring <code>dto:20-24</code>; duplicate warn by comparing against current <code>JobsBloc</code> rows (08 §F1).</li> </ul> <h2 id="4-key-widgets"><a class="header" href="#4-key-widgets">4. Key widgets</a></h2> <div class="table-wrapper"><table><thead><tr><th>Widget</th><th>Impl notes</th></tr></thead><tbody> <tr><td><code>JobStatusBadge</code></td><td>icon+text always; <code>unknown</code> grey until E4 (07 C1)</td></tr> <tr><td><code>CronChip</code></td><td>monospace pattern + humanized via the C2 map; invalid state for F1 input</td></tr> <tr><td><code>QueueChip</code></td><td>family colour coding from <code>queue.constants.ts:1-17</code> (07 C3)</td></tr> <tr><td><code>DLQCard</code></td><td>fields from <code>dlq.setup.ts:12-21</code>; expandable JSON <code>data</code></td></tr> </tbody></table> </div> <p>All stateless + controlled (07 reuse rules); tokens from <code>00-shared/02</code>.</p> <h2 id="5-polling--background"><a class="header" href="#5-polling--background">5. Polling & background</a></h2> <ul> <li>60 s poll on S1 only (09 B3); S2/S6 refresh on entry + pull.</li> <li><code>AppLifecycleListener</code>: pause timer on background, immediate staleness check on resume.</li> <li>Rate budget: 1 req/min ≈ 60/h per tab — inside api tier (<code>00-shared/07 §4</code>).</li> </ul> <h2 id="6-console-behaviour-gates"><a class="header" href="#6-console-behaviour-gates">6. Console behaviour gates</a></h2> <ul> <li>Without E4: S1 badges show <code>unknown</code>, S2 shows retention note, strip shows "0 known" (09 B2) — <strong>never fake health</strong>.</li> <li>Without E6: "Run now" disabled with tooltip "manual trigger pending" (13 §5).</li> <li>Without E8: DLQ replay button disabled (14 §9).</li> <li>Create/remove work today (E2/E3) — those are live, test them fully.</li> </ul> <h2 id="7-tests-2"><a class="header" href="#7-tests-2">7. Tests</a></h2> <ul> <li>Widget: badge states (ok/running/failed/missed/unknown), cron chip humanizer for all 10 defaults (<code>scheduler.service.ts:48-119</code>), F1 regex (5-field cron in/out).</li> <li>Bloc: poll timer lifecycle, keep-last-good on 5xx, single-flight coalescing (13 §3).</li> <li>Integration (console, <code>(proposed)</code> endpoints): repository stub contract — swap fake for real when E4-E8 ship.</li> </ul> <h2 id="8-delivery-checklist"><a class="header" href="#8-delivery-checklist">8. Delivery checklist</a></h2> <ul> <li><input disabled="" type="checkbox"/> Reads E1; writes E2/E3 wired through repository</li> <li><input disabled="" type="checkbox"/> <code>(proposed)</code> endpoints stubbed, UI degrades honestly (07 C1 unknown)</li> <li><input disabled="" type="checkbox"/> a11y: badge icon+text, live regions, focus traps (11 §6)</li> <li><input disabled="" type="checkbox"/> Analytics <code>scheduler.{screen}.{action}</code> <code>(proposed)</code> (05 §Analytics)</li> <li><input disabled="" type="checkbox"/> <code>npm run typecheck && npm run lint</code> before any backend pairing change</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-search-module"><a class="header" href="#01--product-overview-search-module">01 — Product Overview (Search Module)</a></h1> <ul> <li><a href="search/01_Product_Overview.html#1-what-the-module-is">1. What the module is</a></li> <li><a href="search/01_Product_Overview.html#2-entities-searched-from-search-indexerservicets7-32">2. Entities searched (from <code>search-indexer.service.ts:7-32</code>)</a></li> <li><a href="search/01_Product_Overview.html#3-what-is-matched-indexed-fields">3. What is matched (indexed fields)</a></li> <li><a href="search/01_Product_Overview.html#4-scope-in--scope-out">4. Scope in / scope out</a></li> <li><a href="search/01_Product_Overview.html#5-prd-native-app-exclusion-flagged">5. PRD native-app exclusion (flagged)</a></li> <li><a href="search/01_Product_Overview.html#6-terminology">6. Terminology</a></li> </ul> <blockquote> <p>Global search across all indexed StudyLyon entities. Derived from <code>src/modules/search/**</code> (controller, service, indexer, schema, repository, DTO), <code>src/modules/rbac/permissions.constants.ts</code>, <code>src/infrastructure/redis/</code>, <code>docs/IMPLEMENTATION_PLAN.md</code>, and <code>src/common/dto/pagination-query.dto.ts</code>. Nothing is invented; plan-only capability is marked <code>(planned)</code>, client-only or roadmap-only capability <code>(forward-looking)</code>, analytics <code>(proposed)</code>.</p> </blockquote> <hr /> <h2 id="1-what-the-module-is-4"><a class="header" href="#1-what-the-module-is-4">1. What the module is</a></h2> <p>A single <strong>global search endpoint</strong> that searches a dedicated <code>search_indexes</code> collection (a denormalized mirror of selected business entities) and returns an <strong>aggregated flat list</strong> of typed results, paginated and relevance-ranked.</p> <p>Key facts from source:</p> <ul> <li>One endpoint: <code>GET /api/v1/search</code> (<code>@Controller('search')</code> <code>search.controller.ts:11</code>; <code>@Get()</code> <code>:15</code>). <code>@ApiTags('search')</code>, bearer auth, <code>JwtAuthGuard</code> (<code>search.controller.ts:8-10</code>).</li> <li>Guarded by the single <code>@Permissions('search')</code> permission (<code>search.controller.ts:16</code>; declared <code>permissions.constants.ts:93</code> — note: it is a bare <code>'search'</code> string, not a <code>search.*</code> wildcard family).</li> <li>Result shape is a flat <code>SearchResult</code> list — <code>{ entityType, entityId, title, description, tags }</code> (<code>search.service.ts:10-16</code>). Typed grouping is a client concern; the API is not grouped.</li> <li>Query params: <code>q</code> (string, optional), <code>entityType</code> (string, optional), <code>page</code> (≥1, default 1), <code>limit</code> (1–50, default 20) (<code>search-query.dto.ts:5-30</code>).</li> <li>Empty/whitespace <code>q</code> returns <code>{ data: [], meta }</code> with <code>totalItems: 0</code> (<code>search.service.ts:28-30</code>) — no error, no index scan.</li> <li>Index maintained <strong>event-driven</strong>: <code>SearchIndexerService</code> subscribes to every domain event via <code>eventBus.onAny</code> (<code>search-indexer.service.ts:44-46</code>) and upserts/soft-deletes index rows (<code>search-indexer.service.ts:48-99</code>).</li> <li>MongoDB <strong>full-text</strong> (<code>$text</code> + <code>textScore</code>) with a regex fallback (<code>search-index.repository.ts:20-50</code>); relevance sort by textScore.</li> </ul> <h2 id="2-entities-searched-from-search-indexerservicets7-32"><a class="header" href="#2-entities-searched-from-search-indexerservicets7-32">2. Entities searched (from <code>search-indexer.service.ts:7-32</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>entityType</th><th>Source events</th><th>Notes</th></tr></thead><tbody> <tr><td><code>User</code></td><td><code>UserCreated</code>, <code>UserUpdated</code>, <code>UserDeleted</code></td><td>index title = name; description = email</td></tr> <tr><td><code>Student</code></td><td><code>StudentCreated/Updated/Deleted</code></td><td>title falls back to <code>admissionNumber</code> (<code>search-indexer.service.ts:71</code>)</td></tr> <tr><td><code>Teacher</code></td><td><code>TeacherCreated/Updated/Deleted</code></td><td>tags may include role/department/grade (<code>search-indexer.service.ts:83-86</code>)</td></tr> <tr><td><code>Staff</code></td><td><code>StaffCreated/Updated/Deleted</code></td><td>same extraction pipeline</td></tr> <tr><td><code>Parent</code></td><td><code>ParentCreated/Updated/Deleted</code></td><td>same</td></tr> <tr><td><code>Book</code></td><td><code>BookCreated/Updated/Deleted</code></td><td>library catalogue; description = book description</td></tr> <tr><td><code>Organization</code></td><td><code>OrganizationCreated/Updated/Deleted</code></td><td>tenant-level entity</td></tr> <tr><td><code>Lead</code></td><td><code>LeadCreated</code> only (<code>:29</code>)</td><td><strong>gap:</strong> no <code>LeadUpdated</code>/<code>LeadDeleted</code> mapping → stale/dead lead rows persist</td></tr> <tr><td><code>Announcement</code></td><td><code>AnnouncementCreated</code>, <code>AnnouncementPublished</code> (<code>:30-31</code>)</td><td><strong>gap:</strong> no updated/deleted mapping</td></tr> </tbody></table> </div> <p>Plan-only additions (<code>(planned)</code>): index batches, sessions, test_series, DPP for the coaching module (<code>docs/IMPLEMENTATION_PLAN.md:771</code>); the plan's "Global Search" task targets "students, staff, leads, books, announcements" (<code>docs/IMPLEMENTATION_PLAN.md:174</code>).</p> <h2 id="3-what-is-matched-indexed-fields"><a class="header" href="#3-what-is-matched-indexed-fields">3. What is matched (indexed fields)</a></h2> <p>Per index row: <code>title</code>, <code>description</code>, <code>tags</code>, and a denormalized <code>text</code> string <code>"${title} ${description} ${tags.join(' ')}"</code> (<code>search-indexer.service.ts:90-96</code>). The Mongo text index covers <code>title</code>, <code>description</code>, <code>tags</code>, <code>text</code> (<code>search-index.schema.ts:29-34</code>); the regex fallback matches the same four fields, case-insensitive substring (<code>search-index.repository.ts:36-50</code>). Match fields map from source payloads: title = <code>name</code> → <code>title</code> → <code>admissionNumber</code> → <code>firstName + lastName</code> (<code>search-indexer.service.ts:68-75</code>); description = <code>email</code> → <code>description</code> → <code>body</code> (<code>:77-81</code>); tags = <code>role</code>, <code>department</code>, <code>grade</code> (<code>:83-86</code>).</p> <h2 id="4-scope-in--scope-out"><a class="header" href="#4-scope-in--scope-out">4. Scope in / scope out</a></h2> <div class="table-wrapper"><table><thead><tr><th>In scope (implemented)</th><th>Out of scope / noted</th></tr></thead><tbody> <tr><td>Global query across indexed entities, relevance-ranked</td><td>Per-entity native search endpoints — module owns a single <code>GET /search</code></td></tr> <tr><td><code>entityType</code> filter + standard pagination</td><td>Result grouping / typed sections — client-side concern</td></tr> <tr><td>Event-driven index sync (create/update/delete for 7 entity types)</td><td>External engine (Elasticsearch/Meilisearch) <code>(planned)</code> — none in code</td></tr> <tr><td>Tenant isolation on every read/write (<code>base.schema.ts:9-31</code>)</td><td>Search result caching — <code>RedisCacheService</code> exists (<code>redis-cache.service.ts:16-19</code>) but is <strong>not wired into</strong> <code>SearchService</code> <code>(proposed)</code></td></tr> <tr><td>Regex fallback when <code>$text</code> errors</td><td>Search history / saved searches / suggestions <code>(forward-looking)</code></td></tr> <tr><td></td><td>Lead/Announcement update-delete sync — <strong>known gap</strong> (see §2)</td></tr> </tbody></table> </div> <h2 id="5-prd-native-app-exclusion-flagged-2"><a class="header" href="#5-prd-native-app-exclusion-flagged-2">5. PRD native-app exclusion (flagged)</a></h2> <p>Per the shared ledger A1 (<a href="search/../00-shared/12_Assumptions_&_Open_Questions.html">00-shared/12</a>): the PRD puts <strong>native mobile apps out of Phase 1</strong> (<code>PRODUCT_REQUIREMENTS_DOCUMENT.md:144</code>), roadmap Phase 3 = read-only companion. This package is the <strong>forward-looking</strong> 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 <code>(forward-looking)</code> — no QR backend support (ledger B4).</p> <h2 id="6-terminology"><a class="header" href="#6-terminology">6. Terminology</a></h2> <ul> <li><strong>Index row</strong> — one <code>search_indexes</code> document per <code>(tenantId, entityType, entityId)</code> (<code>search-index.schema.ts:7-25</code>).</li> <li><strong>Scoring</strong> — MongoDB <code>textScore</code> used for sort only; <strong>not</strong> returned to the client (<code>search-index.repository.ts:28-33</code>).</li> <li><strong>Fallback</strong> — case-insensitive regex <code>$or</code> scan used when <code>$text</code> throws (<code>search.service.ts:35-41</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-search-module"><a class="header" href="#02--user-personas-search-module">02 — User Personas (Search Module)</a></h1> <ul> <li><a href="search/02_User_Personas.html#1-admin--principal">1. Admin / Principal</a></li> <li><a href="search/02_User_Personas.html#2-receptionist--front-office-clerk">2. Receptionist / Front-office clerk</a></li> <li><a href="search/02_User_Personas.html#3-teacher">3. Teacher</a></li> <li><a href="search/02_User_Personas.html#4-librarian">4. Librarian</a></li> <li><a href="search/02_User_Personas.html#5-parent-mobile-companion-forward-looking">5. Parent (mobile companion) <code>(forward-looking)</code></a></li> <li><a href="search/02_User_Personas.html#6-operations-lead-coaching-module-planned">6. Operations lead (coaching module) <code>(planned)</code></a></li> <li><a href="search/02_User_Personas.html#7-anti-personas-explicitly-out">7. Anti-personas (explicitly out)</a></li> <li><a href="search/02_User_Personas.html#persona--entity-matrix-what-each-persona-mostly-finds">Persona × entity matrix (what each persona mostly finds)</a></li> </ul> <blockquote> <p>Who uses global search, what they look for, and how the index serves them. Personas are grounded in the entities the indexer actually indexes (<code>search-indexer.service.ts:7-32</code>) and the single <code>search</code> permission (<code>permissions.constants.ts:93</code>). All personas are tenant-internal — the repository scopes every query to the JWT tenant (<code>search-index.repository.ts:24-26</code>).</p> </blockquote> <hr /> <h2 id="1-admin--principal"><a class="header" href="#1-admin--principal">1. Admin / Principal</a></h2> <ul> <li><strong>Access:</strong> <code>search</code> permission + <code>JwtAuthGuard</code> (<code>search.controller.ts:10,16</code>).</li> <li><strong>Goal:</strong> find any person, document, or record fast from the dashboard search bar.</li> <li><strong>Typical queries:</strong> staff member by name, student by admission number, book by title.</li> <li><strong>Served by:</strong> index rows for <code>User</code>, <code>Student</code>, <code>Teacher</code>, <code>Staff</code>, <code>Parent</code>, <code>Book</code>, <code>Organization</code>, <code>Announcement</code> (<code>search-indexer.service.ts:7-32</code>).</li> <li><strong>Expectation:</strong> results in < 1 s; taps a result and lands on the entity detail.</li> <li><strong>Frustration risk:</strong> stale results for leads/announcements — no update/delete events are mapped for those types (<code>search-indexer.service.ts:29-31</code>).</li> </ul> <h2 id="2-receptionist--front-office-clerk"><a class="header" href="#2-receptionist--front-office-clerk">2. Receptionist / Front-office clerk</a></h2> <ul> <li><strong>Access:</strong> <code>search</code> permission.</li> <li><strong>Goal:</strong> locate a student or parent quickly when they arrive.</li> <li><strong>Typical queries:</strong> student name fragments, admission number (<code>search-indexer.service.ts:71</code>), parent email (<code>:78</code>).</li> <li><strong>Served by:</strong> substring-tolerant fallback (<code>$regex</code> case-insensitive on title/description/text/tags — <code>search-index.repository.ts:36-50</code>); Mongo <code>$text</code> needs word-ish tokens, so partial names rely on the fallback path.</li> <li><strong>Expectation:</strong> type-ahead with debounce; "see all" drill-down per entity type when many matches.</li> </ul> <h2 id="3-teacher"><a class="header" href="#3-teacher">3. Teacher</a></h2> <ul> <li><strong>Access:</strong> <code>search</code> permission.</li> <li><strong>Goal:</strong> find students in their class, locate colleagues, find announcements.</li> <li><strong>Typical queries:</strong> student name, announcement keyword (matched on <code>body</code> → description — <code>search-indexer.service.ts:80</code>), book title for the library.</li> <li><strong>Served by:</strong> <code>Student</code>, <code>User</code>, <code>Announcement</code>, <code>Book</code> rows.</li> <li><strong>Expectation:</strong> announcement search should find the latest copy; known gap: only <code>AnnouncementCreated/Published</code> are indexed, edits do not re-sync.</li> </ul> <h2 id="4-librarian"><a class="header" href="#4-librarian">4. Librarian</a></h2> <ul> <li><strong>Access:</strong> <code>search</code> permission.</li> <li><strong>Goal:</strong> check whether a title exists in the catalogue without opening the library module.</li> <li><strong>Typical queries:</strong> partial title, author in description.</li> <li><strong>Served by:</strong> <code>BookCreated/Updated/Deleted</code> → <code>Book</code> rows (<code>search-indexer.service.ts:13,20,27</code>).</li> <li><strong>Expectation:</strong> ISBN-style tokens; numeric tokens work through <code>text</code>/<code>tags</code> fields since text search includes them.</li> </ul> <h2 id="5-parent-mobile-companion-forward-looking"><a class="header" href="#5-parent-mobile-companion-forward-looking">5. Parent (mobile companion) <code>(forward-looking)</code></a></h2> <ul> <li><strong>Access:</strong> future read-only companion role — PRD Phase 3 (<a href="search/../00-shared/12_Assumptions_&_Open_Questions.html">00-shared/12 A1</a>).</li> <li><strong>Goal:</strong> find their child, school announcements, staff contacts.</li> <li><strong>Served by:</strong> the same endpoint (permission permitting) — no parent-specific search surface exists in code.</li> <li><strong>Expectation:</strong> results are tenant-scoped; parent must never see cross-tenant rows (<code>base.schema.ts:9-31</code>, repository scoped filter).</li> </ul> <h2 id="6-operations-lead-coaching-module-planned"><a class="header" href="#6-operations-lead-coaching-module-planned">6. Operations lead (coaching module) <code>(planned)</code></a></h2> <ul> <li><strong>Goal:</strong> search batches, sessions, test series, DPP content.</li> <li><strong>Served by:</strong> plan-only index extension (<code>docs/IMPLEMENTATION_PLAN.md:771</code>) — not in code today.</li> </ul> <h2 id="7-anti-personas-explicitly-out"><a class="header" href="#7-anti-personas-explicitly-out">7. Anti-personas (explicitly out)</a></h2> <ul> <li><strong>Cross-tenant operator:</strong> impossible by construction — every read/write is tenant-scoped (<code>search-index.repository.ts:84-89</code>; <code>redis-cache.service.ts:16-19</code> even namespaces cache keys per tenant, though search does not use Redis yet).</li> <li><strong>Unauthenticated user:</strong> 401 — <code>JwtAuthGuard</code> on the controller (<code>search.controller.ts:3,10</code>).</li> </ul> <hr /> <h2 id="persona--entity-matrix-what-each-persona-mostly-finds"><a class="header" href="#persona--entity-matrix-what-each-persona-mostly-finds">Persona × entity matrix (what each persona mostly finds)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Persona</th><th>User</th><th>Student</th><th>Teacher</th><th>Staff</th><th>Parent</th><th>Book</th><th>Organization</th><th>Lead</th><th>Announcement</th></tr></thead><tbody> <tr><td>Admin</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td></tr> <tr><td>Receptionist</td><td>✓</td><td>✓</td><td>—</td><td>✓</td><td>✓</td><td>—</td><td>—</td><td>✓</td><td>—</td></tr> <tr><td>Teacher</td><td>✓</td><td>✓</td><td>✓</td><td>—</td><td>—</td><td>✓</td><td>—</td><td>—</td><td>✓</td></tr> <tr><td>Librarian</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td><td>✓</td><td>—</td><td>—</td><td>—</td></tr> <tr><td>Parent <code>(forward-looking)</code></td><td>—</td><td>✓</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td><td>✓</td></tr> </tbody></table> </div> <p>Gaps this matrix exposes are the same event-mapping gaps: leads are create-only (<code>search-indexer.service.ts:29</code>), announcements never update (<code>:30-31</code>).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journey-search-module"><a class="header" href="#03--user-journey-search-module">03 — User Journey (Search Module)</a></h1> <ul> <li><a href="search/03_User_Journey.html#j1-find-a-student-by-admission-number-receptionist">J1. Find a student by admission number (receptionist)</a></li> <li><a href="search/03_User_Journey.html#j2-locate-a-staff-member-by-email">J2. Locate a staff member by email</a></li> <li><a href="search/03_User_Journey.html#j3-search-a-book-in-the-catalogue-librarian">J3. Search a book in the catalogue (librarian)</a></li> <li><a href="search/03_User_Journey.html#j4-drill-into-a-type-see-all">J4. Drill into a type ("see all")</a></li> <li><a href="search/03_User_Journey.html#j5-zero-results">J5. Zero results</a></li> <li><a href="search/03_User_Journey.html#j6-search-during-degraded-backend-fallback-path">J6. Search during degraded backend (fallback path)</a></li> <li><a href="search/03_User_Journey.html#j7-re-search-after-entity-change">J7. Re-search after entity change</a></li> <li><a href="search/03_User_Journey.html#j8-forward-looking-qr-scan--search">J8. (Forward-looking) QR scan → search</a></li> <li><a href="search/03_User_Journey.html#j9-forward-looking-push-deep-link--search">J9. (Forward-looking) Push deep link → search</a></li> </ul> <blockquote> <p>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 <a href="search/../00-shared/10_QA_Baseline.html">00-shared/10 §1</a> conventions.</p> </blockquote> <hr /> <h2 id="j1-find-a-student-by-admission-number-receptionist"><a class="header" href="#j1-find-a-student-by-admission-number-receptionist">J1. Find a student by admission number (receptionist)</a></h2> <ol> <li>Receptionist opens the app → global search bar visible in the app shell (entry point per <a href="search/05_Screen_Inventory.html">05_Screen_Inventory.md</a> §1).</li> <li>Types <code>ADM-20</code> — client debounces ~300 ms <code>(proposed)</code>.</li> <li><code>GET /api/v1/search?q=ADM-20&page=1&limit=20</code> fires (<code>search.controller.ts:15-18</code>); server matches <code>text</code>/<code>title</code> containing the token — admission numbers land in <code>title</code> via <code>admissionNumber</code> fallback (<code>search-indexer.service.ts:71</code>).</li> <li><code>Student</code> group appears first with matching rows (<code>entityType</code> on each result — <code>search.service.ts:51-57</code>); receptionist taps the row.</li> <li>Navigates to the student detail screen (module-owned route).</li> </ol> <p><strong>Backend guarantees:</strong> tenant-scoped results (<code>search-index.repository.ts:24-26</code>); relevance order by textScore (<code>:28-33</code>).</p> <h2 id="j2-locate-a-staff-member-by-email"><a class="header" href="#j2-locate-a-staff-member-by-email">J2. Locate a staff member by email</a></h2> <ol> <li>User types a partial email, e.g. <code>sharma@</code>.</li> <li>Regex fallback path may be hit — emails are indexed as <code>description</code> (<code>search-indexer.service.ts:77-79</code>); <code>$text</code> and fallback both cover <code>description</code>.</li> <li><code>Staff</code> rows show; result tile renders title (name) + description (email).</li> </ol> <h2 id="j3-search-a-book-in-the-catalogue-librarian"><a class="header" href="#j3-search-a-book-in-the-catalogue-librarian">J3. Search a book in the catalogue (librarian)</a></h2> <ol> <li>Types a partial title. <code>$text</code> matches word stems; short fragments fall back to the case-insensitive substring scan.</li> <li><code>Book</code> group shown with title + description; tap → library book detail.</li> </ol> <h2 id="j4-drill-into-a-type-see-all"><a class="header" href="#j4-drill-into-a-type-see-all">J4. Drill into a type ("see all")</a></h2> <ol> <li>User runs <code>q=q</code> and sees 40 mixed results (2 pages of 20 — <code>limit</code> max 50, default 20, <code>search-query.dto.ts:23-29</code>).</li> <li>Taps "See all Students" → same endpoint with <code>entityType=Student</code> (<code>search-query.dto.ts:11-14</code>) — server filters <strong>in memory after</strong> the search (<code>search.service.ts:44-49</code>); note: the filter is applied to the current page only, so <code>totalItems</code> reflects the filtered page length, not the true per-type total (<code>search.service.ts:46-48</code>) — flagged in QA-14.</li> <li>Paging continues through the type-filtered list.</li> </ol> <h2 id="j5-zero-results"><a class="header" href="#j5-zero-results">J5. Zero results</a></h2> <ol> <li>Types gibberish or a query with no indexed match → <code>data: []</code>, <code>meta.totalItems: 0</code> (<code>search.service.ts:28-30</code> is the empty-query path; no-match path returns empty via <code>repo.search</code>).</li> <li>Client shows <code>AppEmptyState</code> ("No results for “x”") with a clear-query CTA (shared <a href="search/../00-shared/03_Component_Library.html">00-shared/03</a>).</li> </ol> <h2 id="j6-search-during-degraded-backend-fallback-path"><a class="header" href="#j6-search-during-degraded-backend-fallback-path">J6. Search during degraded backend (fallback path)</a></h2> <ol> <li>Mongo <code>$text</code> throws (e.g. malformed index state) → <code>SearchService</code> catches and re-runs the regex fallback transparently (<code>search.service.ts:35-41</code>).</li> <li>User sees identical UI, possibly different result order (fallback has no relevance sort — <code>search-index.repository.ts:49</code>).</li> </ol> <h2 id="j7-re-search-after-entity-change"><a class="header" href="#j7-re-search-after-entity-change">J7. Re-search after entity change</a></h2> <ol> <li>A student is renamed → <code>StudentUpdated</code> event → indexer upserts the row (<code>search-indexer.service.ts:48-96</code>) → next search reflects the new title.</li> <li>Known gap: renaming a <strong>lead</strong> or editing an <strong>announcement</strong> never fires an indexed event (<code>search-indexer.service.ts:29-31</code>) → stale rows until a future reindex job <code>(planned)</code>.</li> </ol> <h2 id="j8-forward-looking-qr-scan--search"><a class="header" href="#j8-forward-looking-qr-scan--search">J8. (Forward-looking) QR scan → search</a></h2> <ol> <li>User taps the scan icon in the search bar; scans a printed admission number / book barcode.</li> <li>Camera value fills <code>q</code> → normal search flow. No QR backend endpoints exist (ledger B4) — fully client-side <code>(forward-looking)</code>.</li> </ol> <h2 id="j9-forward-looking-push-deep-link--search"><a class="header" href="#j9-forward-looking-push-deep-link--search">J9. (Forward-looking) Push deep link → search</a></h2> <ol> <li>A notification arrives for a new announcement <code>(forward-looking)</code> — push device registry not implemented (ledger B3).</li> <li>Tap → app opens with <code>?q=<title></code> prefilled → standard results screen.</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-search-module"><a class="header" href="#04--information-architecture-search-module">04 — Information Architecture (Search Module)</a></h1> <ul> <li><a href="search/04_Information_Architecture.html#1-entry-point-the-global-search-bar">1. Entry point: the global search bar</a></li> <li><a href="search/04_Information_Architecture.html#2-what-the-index-contains-searchable-universe">2. What the index contains (searchable universe)</a></li> <li><a href="search/04_Information_Architecture.html#3-result-organization">3. Result organization</a></li> <li><a href="search/04_Information_Architecture.html#4-deep-links">4. Deep links</a></li> <li><a href="search/04_Information_Architecture.html#5-what-is-not-in-this-ia">5. What is NOT in this IA</a></li> <li><a href="search/04_Information_Architecture.html#6-navigation-summary">6. Navigation summary</a></li> </ul> <blockquote> <p>Where global search lives in the app, what it searches, and how results are organized. Backend facts from <code>src/modules/search/**</code>; app-shell conventions from <a href="search/../00-shared/05_Global_Information_Architecture.html">00-shared/05_Global_Information_Architecture.md</a>.</p> </blockquote> <hr /> <h2 id="1-entry-point-the-global-search-bar"><a class="header" href="#1-entry-point-the-global-search-bar">1. Entry point: the global search bar</a></h2> <p>Search is a <strong>cross-module utility</strong>, 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 (<code>GET /search</code>, <code>search.controller.ts:11-18</code>). Route: <code>/search</code> (results surface); the input itself is an in-shell control that routes to <code>/search</code> on commit.</p> <h2 id="2-what-the-index-contains-searchable-universe"><a class="header" href="#2-what-the-index-contains-searchable-universe">2. What the index contains (searchable universe)</a></h2> <p>One denormalized <code>search_indexes</code> collection per tenant (<code>search-index.schema.ts:7</code>); rows are created from domain events (<code>search-indexer.service.ts:7-32</code>):</p> <ul> <li>7 fully-synced types: <code>User</code>, <code>Student</code>, <code>Teacher</code>, <code>Staff</code>, <code>Parent</code>, <code>Book</code>, <code>Organization</code> (create/update/delete).</li> <li>2 partially-synced types: <code>Lead</code> (create only), <code>Announcement</code> (create/publish only) — <strong>no update/delete events</strong> (<code>search-indexer.service.ts:29-31</code>).</li> <li><code>(planned)</code>: batches, sessions, test_series, DPP (<code>docs/IMPLEMENTATION_PLAN.md:771</code>).</li> </ul> <h2 id="3-result-organization"><a class="header" href="#3-result-organization">3. Result organization</a></h2> <p>The API returns a <strong>flat, relevance-ranked list</strong> — there is no server-side grouping (<code>search.service.ts:10-16</code>, <code>:51-57</code>). The client groups by <code>entityType</code> with headers (see <a href="search/06_Screen_Specifications.html">06</a> §4). Filtering by type = the optional <code>entityType</code> query param (<code>search-query.dto.ts:11-14</code>).</p> <pre><code>/search ├── Group: Students (entityType=Student) │ └── SearchResultTile × n (title, description, tags) ├── Group: Books │ └── ... └── Group: Announcements └── ... </code></pre> <p>Group order is client-proposed (people first, then content) — the server does not order groups; ordering by textScore happens inside each flat list (<code>search-index.repository.ts:28-33</code>).</p> <h2 id="4-deep-links"><a class="header" href="#4-deep-links">4. Deep links</a></h2> <div class="table-wrapper"><table><thead><tr><th>Route</th><th>Purpose</th><th>Backing</th></tr></thead><tbody> <tr><td><code>/search</code></td><td>results surface for current query</td><td><code>GET /search?q=…</code></td></tr> <tr><td><code>/search?type=Student</code></td><td>see-all drill-down</td><td><code>entityType</code> param (<code>search-query.dto.ts:11-14</code>)</td></tr> <tr><td><code>/search/result/<entityType>/<entityId></code> <code>(proposed)</code></td><td>deep-link to a specific result</td><td>key = <code>(entityType, entityId)</code> (<code>search.service.ts:11-12</code>)</td></tr> </tbody></table> </div> <p>Result rows deep-link into the owning module's detail route (Students → <code>/students/:id</code>, etc.) — <strong>no dedicated result-detail screen exists</strong>; the module only returns pointers (<code>entityId</code>, <code>entityType</code>).</p> <h2 id="5-what-is-not-in-this-ia"><a class="header" href="#5-what-is-not-in-this-ia">5. What is NOT in this IA</a></h2> <ul> <li><strong>No search history / recent searches / saved searches / suggestions</strong> — <code>(forward-looking)</code>, nothing server-side.</li> <li><strong>No per-entity search endpoints</strong> — other modules' lists use their own <code>q</code> filters (<code>pagination-query.dto.ts:26-29</code>); this module is the global aggregator only.</li> <li><strong>No cross-tenant traversal</strong> — every row and query carries <code>tenantId</code> (<code>base.schema.ts:9-31</code>; <code>search-index.repository.ts:84-89</code>).</li> <li><strong>No results cache in the data path</strong> — Redis is tenant-namespaced and available (<code>redis-cache.service.ts:16-19</code>) but unused by <code>SearchService</code> <code>(proposed)</code>.</li> </ul> <h2 id="6-navigation-summary"><a class="header" href="#6-navigation-summary">6. Navigation summary</a></h2> <div class="table-wrapper"><table><thead><tr><th>From</th><th>To</th><th>Trigger</th></tr></thead><tbody> <tr><td>Any shell screen</td><td><code>/search</code></td><td>tap search bar / type</td></tr> <tr><td><code>/search</code></td><td><code>/students/:id</code>, <code>/books/:id</code>, …</td><td>tap result row (owner module route)</td></tr> <tr><td><code>/search</code></td><td><code>/search?type=X</code></td><td>"See all X" chip</td></tr> <tr><td><code>/search?type=X</code></td><td><code>/search</code></td><td>clear type filter</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-search-module"><a class="header" href="#05--screen-inventory-search-module">05 — Screen Inventory (Search Module)</a></h1> <ul> <li><a href="search/05_Screen_Inventory.html#legend">Legend</a></li> <li><a href="search/05_Screen_Inventory.html#1-global-search-bar-in-shell-control-every-authenticated-screen">1. Global Search Bar (in-shell control, every authenticated screen)</a></li> <li><a href="search/05_Screen_Inventory.html#2-search-results-screen-search--the-modules-only-full-screen">2. Search Results Screen (<code>/search</code>) — the module's only full screen</a></li> <li><a href="search/05_Screen_Inventory.html#3-see-all-drill-down-searchtypeentitytype">3. See-All Drill-Down (<code>/search?type=<entityType></code>)</a></li> <li><a href="search/05_Screen_Inventory.html#4-zero-state--landing-search-with-empty-query">4. Zero-State / Landing (<code>/search</code> with empty query)</a></li> <li><a href="search/05_Screen_Inventory.html#5-recent-searches-panel-forward-looking">5. Recent Searches panel <code>(forward-looking)</code></a></li> <li><a href="search/05_Screen_Inventory.html#6-scan-to-search-sheet-forward-looking">6. Scan-to-Search sheet <code>(forward-looking)</code></a></li> <li><a href="search/05_Screen_Inventory.html#shared-components-used">Shared components used</a></li> <li><a href="search/05_Screen_Inventory.html#analytics-events-proposed">Analytics events (proposed)</a></li> <li><a href="search/05_Screen_Inventory.html#keyboard-landscape-tablet-desktop">Keyboard, landscape, tablet, desktop</a></li> </ul> <blockquote> <p>Every screen of the Search module, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in <a href="search/../00-shared/03_Component_Library.html">00-shared/03</a>; this file enumerates which ones each screen uses with module specifics. Backend source: <code>src/modules/search/**</code>.</p> </blockquote> <hr /> <h2 id="legend-16"><a class="header" href="#legend-16">Legend</a></h2> <p>States = idle / loading / success / empty / error(offline, server, invalid) / disabled / permission. Analytics events follow <code>{module}.{screen}.{action}</code> (proposed; SDK open — <a href="search/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a>).</p> <hr /> <h2 id="1-global-search-bar-in-shell-control-every-authenticated-screen"><a class="header" href="#1-global-search-bar-in-shell-control-every-authenticated-screen">1. Global Search Bar (in-shell control, every authenticated screen)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Capture the query that drives the single search endpoint</td></tr> <tr><td>Entry</td><td>present on all shell screens; focused state → <code>/search</code></td></tr> <tr><td>Exit</td><td>submit → <code>/search?q=…</code>; cancel → back to previous screen</td></tr> <tr><td>Input</td><td>AppTextField-based <code>SearchBarField</code> (module component, <a href="search/07_Component_Library.html">07</a> §1): hint "Search students, books, announcements…", clear button, optional scan icon <code>(forward-looking)</code></td></tr> <tr><td>Behavior</td><td>debounced 300 ms <code>(proposed)</code>; results update in place while typing; submit commits exact query</td></tr> <tr><td>Permission</td><td><code>search</code> (<code>permissions.constants.ts:93</code>); without it the bar is hidden</td></tr> <tr><td>States</td><td>idle, typing (IME composing), loading (inline spinner, no layout jump), disabled (permission), offline (banner)</td></tr> <tr><td>Keyboard</td><td><code>TextInputType.text</code>, <code>textInputAction.search</code>, autocorrect off (names/IDs)</td></tr> <tr><td>a11y</td><td>semantics hint "Search the whole school"; clear button labeled; live-region on result count</td></tr> <tr><td>Analytics</td><td><code>search.bar.focus</code>, <code>search.bar.submit</code>, <code>search.bar.clear</code> (proposed)</td></tr> <tr><td>Motion</td><td>focus underline <code>m-fast</code>; results fade/slide <code>m-base</code> (<a href="search/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>)</td></tr> </tbody></table> </div> <h2 id="2-search-results-screen-search--the-modules-only-full-screen"><a class="header" href="#2-search-results-screen-search--the-modules-only-full-screen">2. Search Results Screen (<code>/search</code>) — the module's only full screen</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Show relevance-ranked results grouped by entity type; jump to any entity</td></tr> <tr><td>Entry</td><td>from search bar submit; deep link <code>?q=</code> <code>(forward-looking)</code></td></tr> <tr><td>Exit</td><td>tap result → owner module detail route; back → shell</td></tr> <tr><td>Source</td><td><code>GET /api/v1/search?q&entityType&page&limit</code> — flat <code>SearchResult[]</code> + <code>PaginationMeta</code> (<code>search.service.ts:10-16,59</code>)</td></tr> <tr><td>Groups</td><td>client-side grouping by <code>entityType</code> (<code>search.service.ts:11</code>); group headers with counts (count = rows on page, not total — see QA-14)</td></tr> <tr><td>Loading</td><td><code>AppSkeleton</code> list; keep previous results + dim while typing new query (avoid flicker)</td></tr> <tr><td>Empty</td><td><code>AppEmptyState</code> "No results for “q”" + clear CTA (server returns <code>data: []</code> — <code>search.service.ts:28-30</code>)</td></tr> <tr><td>Error</td><td><code>AppErrorState</code> with retry; note: server auto-falls back to regex internally (<code>search.service.ts:35-41</code>), so client errors are rare</td></tr> <tr><td>Pagination</td><td>infinite scroll (page+1 on scroll end); <code>meta.hasNext</code> gate (<code>pagination-query.dto.ts:41-55</code>)</td></tr> <tr><td>Pull-to-refresh</td><td><code>RefreshIndicator</code> re-runs current query (fresh index read)</td></tr> <tr><td>a11y</td><td>group headers as headings; per-row semantics label <code>"{title}, {entityType}, {description}"</code></td></tr> <tr><td>Motion</td><td>group insert/remove <code>m-base</code>; row tap ripple</td></tr> <tr><td>Analytics</td><td><code>search.results.shown(count,type)</code>, <code>search.result.tap(entityType)</code>, <code>search.results.empty</code>, <code>search.seeall.tap(type)</code> (proposed)</td></tr> </tbody></table> </div> <h2 id="3-see-all-drill-down-searchtypeentitytype"><a class="header" href="#3-see-all-drill-down-searchtypeentitytype">3. See-All Drill-Down (<code>/search?type=<entityType></code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Browse all matches of one entity type with pagination</td></tr> <tr><td>Source</td><td>same endpoint, <code>entityType</code> set (<code>search-query.dto.ts:11-14</code>)</td></tr> <tr><td>Grouping</td><td>no groups — flat list of one type; app bar shows type label + result count</td></tr> <tr><td>Empty</td><td>"No {type} found for “q”"</td></tr> <tr><td>Caveat</td><td>server filters the <strong>fetched page only</strong> (<code>search.service.ts:44-49</code>) — drill-down paging stays correct per page, but cross-page type totals are approximations; QA-14</td></tr> <tr><td>Exit</td><td>back → <code>/search</code> (query preserved in cubit state)</td></tr> </tbody></table> </div> <h2 id="4-zero-state--landing-search-with-empty-query"><a class="header" href="#4-zero-state--landing-search-with-empty-query">4. Zero-State / Landing (<code>/search</code> with empty query)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Explain the surface before any query</td></tr> <tr><td>Source</td><td>none — client-only; server returns <code>data: []</code> for blank <code>q</code> (<code>search.service.ts:28-30</code>)</td></tr> <tr><td>Content</td><td>icon, "Search across your school", example chips ("Student name", "Admission no.", "Book title", "Announcement") — tap chip fills bar</td></tr> <tr><td>States</td><td>only idle + offline</td></tr> <tr><td>Analytics</td><td><code>search.landing.chip.tap(topic)</code> (proposed)</td></tr> </tbody></table> </div> <h2 id="5-recent-searches-panel-forward-looking"><a class="header" href="#5-recent-searches-panel-forward-looking">5. Recent Searches panel <code>(forward-looking)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Quick re-run of past queries (client-side persistence only — ledger B5)</td></tr> <tr><td>Source</td><td>none — device storage; no backend history endpoint</td></tr> <tr><td>Note</td><td>keep dormant; single-tap re-run; clear-all action</td></tr> </tbody></table> </div> <h2 id="6-scan-to-search-sheet-forward-looking"><a class="header" href="#6-scan-to-search-sheet-forward-looking">6. Scan-to-Search sheet <code>(forward-looking)</code></a></h2> <p>QR/barcode capture filling <code>q</code> — no backend QR support (ledger B4); see <a href="search/03_User_Journey.html">03 J8</a>.</p> <hr /> <h2 id="shared-components-used-20"><a class="header" href="#shared-components-used-20">Shared components used</a></h2> <p><code>AppTextField</code>, <code>AppButton</code>, <code>AppChips</code>, <code>AppAvatar</code>, <code>AppListTile</code>, <code>AppSnackbar</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppErrorState</code>, <code>AppOfflineBanner</code>, <code>AppSpinner</code>, <code>AppTag</code>. Module-specific: <code>SearchBarField</code>, <code>SearchResultTile</code>, <code>SearchGroupHeader</code>, <code>SearchResultSkeleton</code> — defined in <a href="search/07_Component_Library.html">07_Component_Library.md</a>.</p> <h2 id="analytics-events-proposed-17"><a class="header" href="#analytics-events-proposed-17">Analytics events (proposed)</a></h2> <p><code>search.bar.{focus,submit,clear}</code>, <code>search.landing.chip.tap</code>, <code>search.results.{shown,tap,empty,seeall}</code>, <code>search.error.{server,offline}</code> (all proposed).</p> <h2 id="keyboard-landscape-tablet-desktop-17"><a class="header" href="#keyboard-landscape-tablet-desktop-17">Keyboard, landscape, tablet, desktop</a></h2> <ul> <li>Portrait: bar top, results scroll below.</li> <li>Tablet/desktop: results in two-column grid groups; keyboard shortcuts <code>/</code> focuses search (desktop), Esc clears.</li> <li>Keyboard avoidance + <code>resizeToAvoidBottomInset</code> (shared baseline).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-search-module"><a class="header" href="#06--screen-specifications-search-module">06 — Screen Specifications (Search Module)</a></h1> <ul> <li><a href="search/06_Screen_Specifications.html#1-global-search-bar-in-shell">1. Global Search Bar (in-shell)</a> <ul> <li><a href="search/06_Screen_Specifications.html#11-composition">1.1 Composition</a></li> <li><a href="search/06_Screen_Specifications.html#12-behavior-contract">1.2 Behavior contract</a></li> <li><a href="search/06_Screen_Specifications.html#13-debounce-specification">1.3 Debounce specification</a></li> <li><a href="search/06_Screen_Specifications.html#14-permission-behavior">1.4 Permission behavior</a></li> </ul> </li> <li><a href="search/06_Screen_Specifications.html#2-results-screen-state-machine">2. Results Screen State Machine</a> <ul> <li><a href="search/06_Screen_Specifications.html#21-idle">2.1 <code>idle</code></a></li> <li><a href="search/06_Screen_Specifications.html#22-loading">2.2 <code>loading</code></a></li> <li><a href="search/06_Screen_Specifications.html#23-success">2.3 <code>success</code></a></li> <li><a href="search/06_Screen_Specifications.html#24-empty">2.4 <code>empty</code></a></li> <li><a href="search/06_Screen_Specifications.html#25-error">2.5 <code>error</code></a></li> <li><a href="search/06_Screen_Specifications.html#26-permission">2.6 <code>permission</code></a></li> </ul> </li> <li><a href="search/06_Screen_Specifications.html#3-query-contract-what-the-client-sends">3. Query contract (what the client sends)</a></li> <li><a href="search/06_Screen_Specifications.html#4-empty-query-behavior-exact">4. Empty-query behavior (exact)</a></li> <li><a href="search/06_Screen_Specifications.html#5-stale-data-posture">5. Stale-data posture</a></li> <li><a href="search/06_Screen_Specifications.html#6-keyboard--platform-adaptations">6. Keyboard / platform adaptations</a></li> </ul> <blockquote> <p>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 — <a href="search/../00-shared/12_Assumptions_&_Open_Questions.html">00-shared/12 A1</a>). 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.</p> </blockquote> <hr /> <h2 id="1-global-search-bar-in-shell"><a class="header" href="#1-global-search-bar-in-shell">1. Global Search Bar (in-shell)</a></h2> <h3 id="11-composition"><a class="header" href="#11-composition">1.1 Composition</a></h3> <pre><code>[AppBar leading] SearchBarField [scan icon*] [clear ✕] (hint, debounce, submit) * forward-looking </code></pre> <ul> <li>Height 48 dp; rounded 24 dp; <code>AppTextField</code> variant with search icon (shared <code>00-shared/03</code>).</li> <li>Clear button appears only when text is non-empty; clears and re-shows landing (zero-state, <code>05 §4</code>).</li> </ul> <h3 id="12-behavior-contract"><a class="header" href="#12-behavior-contract">1.2 Behavior contract</a></h3> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Client action</th><th>Server effect</th></tr></thead><tbody> <tr><td>Text change (composing, non-empty)</td><td>restart 300 ms debounce timer <code>(proposed)</code>; fire request on expiry</td><td><code>GET /search?q=…</code> — trimmed query; blank/whitespace <code>q</code> short-circuits server-side to empty result (<code>search.service.ts:28-30</code>)</td></tr> <tr><td>Text change (empty)</td><td>cancel timer; emit idle; show landing</td><td>no request</td></tr> <tr><td>Submit (enter / search action)</td><td>commit query immediately, cancel debounce</td><td>same endpoint; <code>page</code> resets to 1</td></tr> <tr><td>Tap "See all X"</td><td>set <code>entityType</code> param, page 1</td><td>server filters fetched page by type (<code>search.service.ts:44-49</code>)</td></tr> <tr><td>Tap result</td><td>navigate to owner module detail (<code>/students/:id</code> etc.)</td><td>none — result carries <code>entityType</code> + <code>entityId</code> (<code>search.service.ts:11-12</code>)</td></tr> </tbody></table> </div> <h3 id="13-debounce-specification"><a class="header" href="#13-debounce-specification">1.3 Debounce specification</a></h3> <ul> <li><strong>Delay:</strong> 300 ms <code>(proposed)</code> — tuned for a Mongo <code>$text</code> query with <code>skip/limit</code> (<code>search-index.repository.ts:27-33</code>).</li> <li><strong>Rule:</strong> only the latest query fires; any earlier in-flight response for a superseded query is <strong>discarded</strong> (stale-guard token, <code>13</code> §3).</li> <li><strong>Exceptions:</strong> no debounce on submit; no debounce on pagination (fires immediately).</li> <li><strong>Why:</strong> each keystroke would otherwise hit <code>$text</code> + <code>countDocuments</code> + optional fallback — two queries per search (<code>search.service.ts:36-37</code>).</li> </ul> <h3 id="14-permission-behavior"><a class="header" href="#14-permission-behavior">1.4 Permission behavior</a></h3> <ul> <li>Bar hidden entirely without <code>search</code> permission (<code>permissions.constants.ts:93</code>).</li> <li>If the token expires mid-typing: 401 → global session-expiry flow (<code>00-shared/10 §3</code>), state preserved, re-login resumes.</li> </ul> <hr /> <h2 id="2-results-screen-state-machine"><a class="header" href="#2-results-screen-state-machine">2. Results Screen State Machine</a></h2> <p>States (legend per <code>05</code>): <code>idle → loading → success | error | empty</code>; the machine is implemented as <code>SearchCubit</code> (<code>13</code>).</p> <h3 id="21-idle"><a class="header" href="#21-idle">2.1 <code>idle</code></a></h3> <ul> <li>No query. Landing content only (<code>05 §4</code>). No network.</li> </ul> <h3 id="22-loading"><a class="header" href="#22-loading">2.2 <code>loading</code></a></h3> <ul> <li>Triggered: debounce expiry, submit, pagination fetch, retry.</li> <li>UI: previous results <strong>stay visible</strong> for refinement (reload, not replace); first-ever query shows <code>SearchResultSkeleton</code> (<code>07</code> §4).</li> <li>Inline spinner in bar; list dimmed at 40% opacity.</li> <li>Minimum spinner display 250 ms to avoid flash on fast responses <code>(proposed)</code>.</li> </ul> <h3 id="23-success"><a class="header" href="#23-success">2.3 <code>success</code></a></h3> <ul> <li><code>data: SearchResult[]</code> (<code>search.service.ts:10-16</code>), rendered grouped.</li> <li><strong>Grouping:</strong> client groups by <code>entityType</code>; server sends flat list (<code>search.service.ts:51-57</code>). 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.</li> <li><strong>Row anatomy</strong> (<code>SearchResultTile</code>): <ul> <li>Leading: <code>AppAvatar</code> (initials from <code>title</code>; entity-type icon fallback).</li> <li>Title: <code>title</code> (e.g. full name, admission number, book title — extraction rules <code>search-indexer.service.ts:68-75</code>).</li> <li>Subtitle: <code>description</code> (email, book description, announcement body — <code>:77-81</code>), ellipsized 2 lines.</li> <li>Trailing: <code>AppTag</code>s for <code>tags</code> (role/department/grade — <code>:83-86</code>), max 2 + "+n".</li> <li>A11y label: <code>"{title}, {entityType}, {description}"</code>.</li> </ul> </li> <li><strong>Empty description/tags:</strong> omit rows' subtitle/trailing — defaults are <code>''</code> and <code>[]</code> (<code>search-index.schema.ts:18-22</code>).</li> <li><strong>Pagination:</strong> scroll to 80% of the list → fetch <code>page+1</code> (limit 20 default, max 50 — <code>search-query.dto.ts:23-29</code>) if <code>meta.hasNext</code> (<code>pagination-query.dto.ts:41-55</code>). Append; show bottom loading row.</li> <li><strong>Counts:</strong> header count is <strong>per fetched page</strong>, not a global total — server computes <code>totalItems</code> from <code>countSearch(q)</code> (<code>search.service.ts:37</code>), but per-type totals are unknowable from the flat response (<code>search.service.ts:44-49</code>).</li> </ul> <h3 id="24-empty"><a class="header" href="#24-empty">2.4 <code>empty</code></a></h3> <ul> <li>Server: <code>data: []</code> with <code>meta.totalItems: 0</code>. Two sources: <ol> <li>blank/whitespace <code>q</code> — <code>search.service.ts:28-30</code> (client never sends it, by contract);</li> <li>no matches in index (text index or fallback scan) — <code>search-index.repository.ts:20-50</code>.</li> </ol> </li> <li>UI: <code>AppEmptyState</code>: "No results for “q”", sub-line "Check the spelling or try fewer words." CTA: "Clear search" (returns to landing).</li> <li>Do <strong>not</strong> show suggestions (no backend suggestions <code>(forward-looking)</code>).</li> </ul> <h3 id="25-error"><a class="header" href="#25-error">2.5 <code>error</code></a></h3> <ul> <li>Server auto-fails-over: <code>$text</code> failure → regex fallback (<code>search.service.ts:35-41</code>) — so most query-level failures never reach the client. Client <code>error</code> triggers on: 401 (session), 403 (permission revoked mid-session), 429 (rate limited — <code>00-shared/07 §4</code>), network/offline.</li> <li>UI: <code>AppErrorState</code> with message + Retry (re-run same query) and "Back".</li> <li>Offline: <code>AppOfflineBanner</code> + cached-last-results if any; retry on connectivity restore (<code>00-shared/10 §2</code>).</li> </ul> <h3 id="26-permission"><a class="header" href="#26-permission">2.6 <code>permission</code></a></h3> <ul> <li>Not reached from the bar (hidden), but direct deep links <code>(forward-looking)</code> must render a permission state: icon + "You don't have search access." (<code>403</code> → <code>PERMISSION_DENIED</code>, <code>00-shared/07 §3</code>).</li> </ul> <hr /> <h2 id="3-query-contract-what-the-client-sends"><a class="header" href="#3-query-contract-what-the-client-sends">3. Query contract (what the client sends)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Param</th><th>Source</th><th>Rules</th></tr></thead><tbody> <tr><td><code>q</code></td><td><code>search-query.dto.ts:6-9</code></td><td>optional string; client trims; never send blank; recommended client cap 100 chars <code>(proposed)</code> — server has no max</td></tr> <tr><td><code>entityType</code></td><td><code>search-query.dto.ts:11-14</code></td><td>optional string; client sends one of the 9 known types</td></tr> <tr><td><code>page</code></td><td><code>search-query.dto.ts:16-21</code></td><td>≥1, default 1</td></tr> <tr><td><code>limit</code></td><td><code>search-query.dto.ts:23-29</code></td><td>1–50, default 20; validation errors → <code>400 VALIDATION_ERROR</code> (<code>00-shared/07 §3</code>)</td></tr> </tbody></table> </div> <h2 id="4-empty-query-behavior-exact"><a class="header" href="#4-empty-query-behavior-exact">4. Empty-query behavior (exact)</a></h2> <p>Server: <code>!q?.trim()</code> → <code>{ data: [], meta: buildPaginationMeta(page, limit, 0) }</code> (<code>search.service.ts:28-30</code>) — <code>totalPages</code> computes to 1, <code>hasNext/hasPrevious</code> false (<code>pagination-query.dto.ts:46-54</code>). No index access. Client contract: never send it; landing screen (<code>05 §4</code>) covers the idle state.</p> <h2 id="5-stale-data-posture"><a class="header" href="#5-stale-data-posture">5. Stale-data posture</a></h2> <ul> <li>Index freshness = event-driven only; no reindex job in code (<code>search-indexer.service.ts:44-46</code>).</li> <li>Known staleness: <code>Lead</code> and <code>Announcement</code> rows never update/delete (<code>search-indexer.service.ts:29-31</code>) → renamed/edited leads and edited announcements surface stale titles until <code>(planned)</code> reindex/event wiring.</li> <li>UI: no staleness signal today; when a reindex job ships <code>(planned)</code>, surface <code>updatedAt</code> on tiles (<code>base.schema.ts:31-33</code>).</li> </ul> <h2 id="6-keyboard--platform-adaptations"><a class="header" href="#6-keyboard--platform-adaptations">6. Keyboard / platform adaptations</a></h2> <ul> <li>Tablet/desktop: two-column group layout; <code>/</code> focuses bar; <code>Esc</code> clears.</li> <li>IME composing ignored until composition end (CJK-safe debounce).</li> <li>Landscape: same as portrait, more tiles per row.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-search-module"><a class="header" href="#07--component-library-search-module">07 — Component Library (Search Module)</a></h1> <ul> <li><a href="search/07_Component_Library.html#1-searchbarfield">1. <code>SearchBarField</code></a></li> <li><a href="search/07_Component_Library.html#2-searchresulttile">2. <code>SearchResultTile</code></a></li> <li><a href="search/07_Component_Library.html#3-searchgroupheader">3. <code>SearchGroupHeader</code></a></li> <li><a href="search/07_Component_Library.html#4-searchresultskeleton">4. <code>SearchResultSkeleton</code></a></li> <li><a href="search/07_Component_Library.html#5-searchemptystate--searcherrorstate">5. <code>SearchEmptyState</code> / <code>SearchErrorState</code></a></li> <li><a href="search/07_Component_Library.html#6-searchlanding-zero-query-content">6. <code>SearchLanding</code> (zero-query content)</a></li> <li><a href="search/07_Component_Library.html#7-recentsearchchip-forward-looking">7. <code>RecentSearchChip</code> <code>(forward-looking)</code></a></li> <li><a href="search/07_Component_Library.html#8-shared-components-consumed">8. Shared components consumed</a></li> <li><a href="search/07_Component_Library.html#9-layout-containers">9. Layout containers</a></li> </ul> <blockquote> <p>Module-specific widgets for global search. Shared primitives come from <a href="search/../00-shared/03_Component_Library.html">00-shared/03_Component_Library.md</a> (authoritative); this file adds the search-specific compositions and their contracts. All names are Flutter widget names <code>(forward-looking)</code>.</p> </blockquote> <hr /> <h2 id="1-searchbarfield"><a class="header" href="#1-searchbarfield">1. <code>SearchBarField</code></a></h2> <ul> <li><strong>Extends:</strong> <code>AppTextField</code> (shared) with search-prefix icon.</li> <li><strong>Props:</strong> <code>hint</code>, <code>controller</code>, <code>onQueryChanged(String)</code>, <code>onSubmit(String)</code>, <code>debounceMs</code> (default 300), <code>enabled</code>.</li> <li><strong>Internal:</strong> 300 ms debounce <code>Timer</code> (cancel on dispose/restart — <a href="search/06_Screen_Specifications.html">06 §1.3</a>); clear ✕ button; optional <code>scan</code> action slot <code>(forward-looking)</code>.</li> <li><strong>Focus:</strong> <code>autofocus</code> false on shell; true when landing on <code>/search</code>.</li> <li><strong>Semantics:</strong> hint acts as label; clear button labeled "Clear search".</li> <li><strong>Keyboard:</strong> <code>textInputAction: search</code>, autocorrect off, <code>enableSuggestions: false</code>.</li> </ul> <h2 id="2-searchresulttile"><a class="header" href="#2-searchresulttile">2. <code>SearchResultTile</code></a></h2> <ul> <li><strong>Extends:</strong> <code>AppListTile</code>.</li> <li><strong>Props:</strong> <code>result: SearchResult</code> (<code>search.service.ts:10-16</code>), <code>onTap</code>.</li> <li><strong>Render:</strong> avatar (initials / entity icon), <code>title</code>, <code>description</code> (2-line ellipsis), up to 2 <code>AppTag</code>s + "+n" for <code>tags</code> (<code>search-indexer.service.ts:83-86</code>).</li> <li><strong>Missing fields:</strong> omit subtitle if <code>description == ''</code>; omit tags if empty array (schema defaults <code>''</code>/<code>[]</code> — <code>search-index.schema.ts:18-22</code>).</li> <li><strong>Type icon map (proposed):</strong> Student→graduation cap, User→person, Teacher→ presentation, Staff→briefcase, Parent→family, Book→book, Organization→ building, Lead→funnel, Announcement→megaphone. Fallback: generic search icon.</li> </ul> <h2 id="3-searchgroupheader"><a class="header" href="#3-searchgroupheader">3. <code>SearchGroupHeader</code></a></h2> <ul> <li><strong>Props:</strong> <code>entityType</code>, <code>pageCount</code>, <code>onSeeAll</code>.</li> <li><strong>Render:</strong> type label + "N on this page" caption + "See all" text button → sets <code>entityType</code> filter (<code>search-query.dto.ts:11-14</code>).</li> <li><strong>Count caveat:</strong> <code>pageCount</code> is the per-page count, never a global total (<code>search.service.ts:44-49</code>) — the caption text says "N in this view".</li> <li><strong>Semantics:</strong> heading level 2; see-all button labeled <code>"See all <type>"</code>.</li> </ul> <h2 id="4-searchresultskeleton"><a class="header" href="#4-searchresultskeleton">4. <code>SearchResultSkeleton</code></a></h2> <ul> <li><strong>Extends:</strong> <code>AppSkeleton</code> (shared). Three shimmer rows of tile shape (avatar + 2 lines). Used only for first query (<a href="search/06_Screen_Specifications.html">06 §2.2</a>); refinements keep prior content.</li> </ul> <h2 id="5-searchemptystate--searcherrorstate"><a class="header" href="#5-searchemptystate--searcherrorstate">5. <code>SearchEmptyState</code> / <code>SearchErrorState</code></a></h2> <ul> <li>Thin wrappers over shared <code>AppEmptyState</code> / <code>AppErrorState</code> (<a href="search/../00-shared/03_Component_Library.html">00-shared/03</a>) with module copy ("No results for “q”"; retry re-runs the query — <a href="search/06_Screen_Specifications.html">06 §2.4-2.5</a>).</li> </ul> <h2 id="6-searchlanding-zero-query-content"><a class="header" href="#6-searchlanding-zero-query-content">6. <code>SearchLanding</code> (zero-query content)</a></h2> <ul> <li>Icon + "Search across your school" + <code>AppChips</code>: "Student name", "Admission no.", "Book title", "Announcement". Tap fills the bar (<a href="search/05_Screen_Inventory.html">05 §4</a>). Analytics: <code>search.landing.chip.tap(topic)</code> (proposed).</li> </ul> <h2 id="7-recentsearchchip-forward-looking"><a class="header" href="#7-recentsearchchip-forward-looking">7. <code>RecentSearchChip</code> <code>(forward-looking)</code></a></h2> <ul> <li>Client-persisted history item (device storage only — ledger B5); single tap re-runs query. No backend history endpoint.</li> </ul> <h2 id="8-shared-components-consumed"><a class="header" href="#8-shared-components-consumed">8. Shared components consumed</a></h2> <p><code>AppTextField</code>, <code>AppListTile</code>, <code>AppAvatar</code>, <code>AppChips</code>/<code>AppTag</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppErrorState</code>, <code>AppOfflineBanner</code>, <code>AppSpinner</code>, <code>AppButton</code> (clear/back CTAs), <code>AppBadge</code> (group count). Motion tokens <code>m-fast</code>/<code>m-base</code> from <a href="search/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>; tokens from <a href="search/../00-shared/02_Design_Tokens.html">00-shared/02</a>.</p> <h2 id="9-layout-containers"><a class="header" href="#9-layout-containers">9. Layout containers</a></h2> <div class="table-wrapper"><table><thead><tr><th>Name</th><th>Use</th></tr></thead><tbody> <tr><td><code>SearchResultsGroup</code></td><td>vertical group: header + <code>SearchResultTile</code>s</td></tr> <tr><td><code>SearchResultsGridView</code></td><td>tablet/desktop two-column grouping (<a href="search/06_Screen_Specifications.html">06 §6</a>)</td></tr> <tr><td><code>SearchBottomLoader</code></td><td>infinite-scroll loading row (<a href="search/06_Screen_Specifications.html">06 §2.3</a>)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-search-module"><a class="header" href="#08--form-specifications-search-module">08 — Form Specifications (Search Module)</a></h1> <ul> <li><a href="search/08_Form_Specifications.html#1-search-input--the-only-field">1. Search input — the only field</a> <ul> <li><a href="search/08_Form_Specifications.html#11-server-contract-exact">1.1 Server contract (exact)</a></li> <li><a href="search/08_Form_Specifications.html#12-client-rules-proposed--fill-the-servers-gaps">1.2 Client rules <code>(proposed)</code> — fill the server's gaps</a></li> <li><a href="search/08_Form_Specifications.html#13-validation-behavior-client-side-none-server-side">1.3 Validation behavior (client-side, none server-side)</a></li> <li><a href="search/08_Form_Specifications.html#14-special-characters">1.4 Special characters</a></li> <li><a href="search/08_Form_Specifications.html#15-autofill--ime">1.5 Autofill / IME</a></li> <li><a href="search/08_Form_Specifications.html#16-related-forms">1.6 Related "forms"</a></li> </ul> </li> <li><a href="search/08_Form_Specifications.html#2-accessibility-of-the-input">2. Accessibility of the input</a></li> <li><a href="search/08_Form_Specifications.html#3-data-sent-vs-data-shown">3. Data sent vs data shown</a></li> </ul> <blockquote> <p>Specification of the search <strong>input</strong> (the module's only form element). Backend constraints are exact from <code>search-query.dto.ts</code>; client rules are <code>(proposed)</code> where marked. There is no multi-field form in this module.</p> </blockquote> <hr /> <h2 id="1-search-input--the-only-field"><a class="header" href="#1-search-input--the-only-field">1. Search input — the only field</a></h2> <h3 id="11-server-contract-exact"><a class="header" href="#11-server-contract-exact">1.1 Server contract (exact)</a></h3> <div class="table-wrapper"><table><thead><tr><th>Property</th><th>Value</th><th>Source</th></tr></thead><tbody> <tr><td>Name</td><td><code>q</code></td><td><code>search-query.dto.ts:6-9</code></td></tr> <tr><td>Type</td><td>string, optional</td><td><code>@IsString @IsOptional</code> (<code>:7-9</code>)</td></tr> <tr><td>Max length</td><td><strong>none</strong> (unbounded)</td><td>DTO has no <code>@MaxLength</code></td></tr> <tr><td>Min length</td><td><strong>none</strong> (empty allowed)</td><td>empty/whitespace → empty result, not an error (<code>search.service.ts:28-30</code>)</td></tr> <tr><td>Other params</td><td><code>entityType</code> (optional string), <code>page</code> (≥1), <code>limit</code> (1–50)</td><td><code>search-query.dto.ts:11-29</code></td></tr> </tbody></table> </div> <h3 id="12-client-rules-proposed--fill-the-servers-gaps"><a class="header" href="#12-client-rules-proposed--fill-the-servers-gaps">1.2 Client rules <code>(proposed)</code> — fill the server's gaps</a></h3> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Value</th><th>Rationale</th></tr></thead><tbody> <tr><td>Trim</td><td>leading/trailing whitespace stripped before send</td><td>server compares <code>q?.trim()</code> (<code>search.service.ts:28-30</code>) — trailing spaces would still match server-side but inflate tokens</td></tr> <tr><td>Empty guard</td><td>never send blank <code>q</code>; show landing instead</td><td>server returns <code>data: []</code> (<code>search.service.ts:28-30</code>) — a wasted round trip</td></tr> <tr><td>Max input length</td><td>100 chars</td><td>server is unbounded; beyond 100 the <code>$text</code> query degrades (<code>search-index.repository.ts:24-33</code>); no server error exists, so cap client-side</td></tr> <tr><td>Debounce</td><td>300 ms after last keystroke (<a href="search/06_Screen_Specifications.html">06 §1.3</a>)</td><td>each search = 2 queries (<code>search.service.ts:36-37</code>)</td></tr> <tr><td>Whitespace collapse</td><td>internal runs collapsed to single space <code>(proposed)</code></td><td>avoids empty tokens in <code>$text</code></td></tr> <tr><td>Min chars</td><td><strong>1</strong> (no enforced minimum)</td><td>server matches single characters via fallback regex (<code>search-index.repository.ts:36-50</code>); client still debounces to protect the index</td></tr> </tbody></table> </div> <h3 id="13-validation-behavior-client-side-none-server-side"><a class="header" href="#13-validation-behavior-client-side-none-server-side">1.3 Validation behavior (client-side, none server-side)</a></h3> <p>Server-side validation fires <strong>only</strong> for non-string/negative/non-integer params → <code>400 VALIDATION_ERROR</code> with per-field details (<a href="search/../00-shared/07_API_Conventions.html">00-shared/07 §3</a>). The client prevents these by construction (int types on <code>page</code>/<code>limit</code>, string on <code>q</code>). No inline field errors exist on this input; failures surface as screen-level <code>AppErrorState</code> (<a href="search/06_Screen_Specifications.html">06 §2.5</a>).</p> <h3 id="14-special-characters"><a class="header" href="#14-special-characters">1.4 Special characters</a></h3> <ul> <li><strong>Regex fallback risk:</strong> the fallback builds <code>{ $regex: q, $options: 'i' }</code> from the <strong>raw query</strong> (<code>search-index.repository.ts:40</code>). Unescaped regex metacharacters (<code>( ) [ ] { } * + ? ^ $ | . \</code>) can throw a server error on that path. The client <strong>escapes nothing today</strong> — QA-14 §6 covers testing; a client-side escape <code>(proposed)</code> is the mitigation.</li> <li><strong>Text-index behavior:</strong> <code>$text</code> tokenizes; punctuation is largely ignored by Mongo (<code>search-index.repository.ts:24-33</code>) — so <code>ADM-20</code> vs <code>ADM 20</code> can return different results; fallback covers substring cases.</li> </ul> <h3 id="15-autofill--ime"><a class="header" href="#15-autofill--ime">1.5 Autofill / IME</a></h3> <ul> <li><code>autofillHints: none</code> (search is not a stored identity field).</li> <li>IME composing: debounce timer starts only after composition ends (CJK-safe, <a href="search/06_Screen_Specifications.html">06 §6</a>).</li> <li><code>textInputAction: search</code> → submits and navigates to <code>/search</code>.</li> </ul> <h3 id="16-related-forms"><a class="header" href="#16-related-forms">1.6 Related "forms"</a></h3> <div class="table-wrapper"><table><thead><tr><th>Control</th><th>Spec</th></tr></thead><tbody> <tr><td>See-all filter</td><td>single-select of entityType via chip row; maps to <code>entityType</code> param (<code>search-query.dto.ts:11-14</code>); client remembers it in cubit state</td></tr> <tr><td>Clear</td><td>✕ button: clears text + <code>entityType</code>, resets to landing, cancels debounce</td></tr> <tr><td>Example chips (landing)</td><td>fill bar with sample query, trigger debounce (<a href="search/07_Component_Library.html">07 §6</a>)</td></tr> </tbody></table> </div> <h2 id="2-accessibility-of-the-input"><a class="header" href="#2-accessibility-of-the-input">2. Accessibility of the input</a></h2> <ul> <li>Label/hint linked to the field semantics (<a href="search/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>).</li> <li>Clear and scan buttons are labeled controls, not icon-only.</li> <li>Results count announced via live region after each successful response.</li> <li>Focus order: bar → groups → per-row actions.</li> </ul> <h2 id="3-data-sent-vs-data-shown"><a class="header" href="#3-data-sent-vs-data-shown">3. Data sent vs data shown</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Sent</th><th>Shown</th></tr></thead><tbody> <tr><td><code>q</code></td><td>trimmed, whitespace-collapsed</td><td>as typed (original casing preserved)</td></tr> <tr><td><code>entityType</code></td><td>one of 9 known types</td><td>group headers + filter chips</td></tr> <tr><td><code>page</code>, <code>limit</code></td><td>ints</td><td>pagination spinner only</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-search-module"><a class="header" href="#09--user-behaviour-search-module">09 — User Behaviour (Search Module)</a></h1> <ul> <li><a href="search/09_User_Behaviour.html#1-typing-behaviour">1. Typing behaviour</a></li> <li><a href="search/09_User_Behaviour.html#2-query-vocabulary-by-intent">2. Query vocabulary by intent</a></li> <li><a href="search/09_User_Behaviour.html#3-result-consumption-behaviour">3. Result-consumption behaviour</a></li> <li><a href="search/09_User_Behaviour.html#4-zero-result-behaviour">4. Zero-result behaviour</a></li> <li><a href="search/09_User_Behaviour.html#5-failure-tolerance-behaviour">5. Failure-tolerance behaviour</a></li> <li><a href="search/09_User_Behaviour.html#6-freshness-expectations">6. Freshness expectations</a></li> <li><a href="search/09_User_Behaviour.html#7-power-user-patterns">7. Power-user patterns</a></li> <li><a href="search/09_User_Behaviour.html#8-behavioural-metrics-proposed">8. Behavioural metrics (proposed)</a></li> </ul> <blockquote> <p>Observed and designed-for behavior patterns around global search. Backend behaviors cited with source; client behaviors <code>(proposed)</code>.</p> </blockquote> <hr /> <h2 id="1-typing-behaviour"><a class="header" href="#1-typing-behaviour">1. Typing behaviour</a></h2> <ul> <li>Users type <strong>partial, unordered tokens</strong> ("rah sharma" for Rahul Sharma).</li> <li>Mongo <code>$text</code> treats space-separated tokens as AND-ish word search; the fallback regex treats the whole string as one substring (<code>search-index.repository.ts:24-50</code>) — so multi-word partials behave differently between paths. Debounce (300 ms, <a href="search/06_Screen_Specifications.html">06 §1.3</a>) absorbs bursts.</li> <li>Users do not press Enter; they expect live results (submit exists but is secondary).</li> </ul> <h2 id="2-query-vocabulary-by-intent"><a class="header" href="#2-query-vocabulary-by-intent">2. Query vocabulary by intent</a></h2> <div class="table-wrapper"><table><thead><tr><th>Intent</th><th>Query shape</th><th>Matched fields (indexer)</th></tr></thead><tbody> <tr><td>Find a person</td><td>name fragment</td><td><code>title</code> = name / <code>firstName+lastName</code> (<code>search-indexer.service.ts:68-75</code>)</td></tr> <tr><td>Find by roll/admission no</td><td><code>ADM-2024-118</code></td><td><code>title</code> = <code>admissionNumber</code> (<code>:71</code>)</td></tr> <tr><td>Find by email</td><td><code>sharma@</code></td><td><code>description</code> = <code>email</code> (<code>:77-79</code>)</td></tr> <tr><td>Find a book</td><td>title words / author</td><td><code>title</code>, <code>description</code> (<code>:68-81</code>)</td></tr> <tr><td>Find an announcement</td><td>keyword</td><td><code>description</code> = <code>body</code> (<code>:80</code>)</td></tr> <tr><td>Find by department/grade</td><td>"grade 5", "science dept"</td><td><code>tags</code> = role/department/grade (<code>:83-86</code>)</td></tr> </tbody></table> </div> <h2 id="3-result-consumption-behaviour"><a class="header" href="#3-result-consumption-behaviour">3. Result-consumption behaviour</a></h2> <ul> <li>Users scan <strong>group headers first</strong>, then the top 2–3 rows per group — group order matters (people-first proposal in <a href="search/06_Screen_Specifications.html">06 §2.3</a>).</li> <li>Users rarely go past page 1 of the aggregate; they use "See all" for a type when they need depth (<a href="search/05_Screen_Inventory.html">05 §3</a>).</li> <li>Result count is an expectation cue: with <code>meta.totalItems</code> (<code>search.service.ts:59</code>) users expect a total, but per-type counts are per-page only (<code>search.service.ts:44-49</code>) — copy must not claim totals (<a href="search/07_Component_Library.html">07 §3</a>).</li> </ul> <h2 id="4-zero-result-behaviour"><a class="header" href="#4-zero-result-behaviour">4. Zero-result behaviour</a></h2> <ul> <li>Users re-type with fewer words (fallback regex substring helps here — <code>search-index.repository.ts:36-50</code>).</li> <li>Users then clear and try a different field (email vs name). Landing chips (<a href="search/07_Component_Library.html">07 §6</a>) pre-empt this by advertising the field vocabulary.</li> </ul> <h2 id="5-failure-tolerance-behaviour"><a class="header" href="#5-failure-tolerance-behaviour">5. Failure-tolerance behaviour</a></h2> <ul> <li>Server auto-fallback (<code>search.service.ts:35-41</code>) means users see <strong>results even when <code>$text</code> breaks</strong> — most users will never observe the fallback.</li> <li>When offline: users retry on connectivity restore (banner + retry, <a href="search/06_Screen_Specifications.html">06 §2.5</a>); cached last results are acceptable interim state (<a href="search/../00-shared/10_QA_Baseline.html">00-shared/10 §2</a>).</li> </ul> <h2 id="6-freshness-expectations"><a class="header" href="#6-freshness-expectations">6. Freshness expectations</a></h2> <ul> <li>Users expect <strong>immediate</strong> visibility of edits: rename a student → search shows the new name (event-driven upsert, <code>search-indexer.service.ts:90-96</code>).</li> <li>Hidden surprise: <strong>leads and announcements never re-sync</strong> on update/delete (<code>search-indexer.service.ts:29-31</code>) — stale rows persist; mitigate with the <code>(planned)</code> reindex job and surface <code>updatedAt</code> (<a href="search/06_Screen_Specifications.html">06 §5</a>).</li> </ul> <h2 id="7-power-user-patterns"><a class="header" href="#7-power-user-patterns">7. Power-user patterns</a></h2> <ul> <li>Keyboard <code>/</code> to focus the bar, <code>Esc</code> to clear (desktop — <a href="search/06_Screen_Specifications.html">06 §6</a>).</li> <li>Direct deep links into <code>/search?q=</code> <code>(forward-looking)</code> from notifications/push <code>(forward-looking)</code> — ledger B3.</li> <li>Scanning a printed admission number into the bar <code>(forward-looking)</code> — ledger B4.</li> </ul> <h2 id="8-behavioural-metrics-proposed"><a class="header" href="#8-behavioural-metrics-proposed">8. Behavioural metrics (proposed)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Metric</th><th>Definition</th></tr></thead><tbody> <tr><td>Search latency p95</td><td>time from debounce fire → results rendered (< 1 s target)</td></tr> <tr><td>Zero-result rate</td><td>empty responses / total searches (<code>search.service.ts:28-30</code> path excluded)</td></tr> <tr><td>Fallback rate</td><td>how often the regex path is hit (<code>search.service.ts:38-41</code>)</td></tr> <tr><td>See-all usage</td><td>drill-downs per session (<a href="search/05_Screen_Inventory.html">05 §3</a>)</td></tr> <tr><td>Refinement rate</td><td>queries changed before any tap</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-search-module"><a class="header" href="#10--interaction-specification-search-module">10 — Interaction Specification (Search Module)</a></h1> <ul> <li><a href="search/10_Interaction_Specification.html#1-interaction-states-per-06-2">1. Interaction states (per 06 §2)</a></li> <li><a href="search/10_Interaction_Specification.html#2-debounce-interaction-exact-timing">2. Debounce interaction (exact timing)</a></li> <li><a href="search/10_Interaction_Specification.html#3-query-transitions">3. Query transitions</a></li> <li><a href="search/10_Interaction_Specification.html#4-navigation-interactions">4. Navigation interactions</a></li> <li><a href="search/10_Interaction_Specification.html#5-motion--animation-tokens-from-00-shared08">5. Motion & animation (tokens from 00-shared/08)</a></li> <li><a href="search/10_Interaction_Specification.html#6-input-handling-rules">6. Input handling rules</a></li> <li><a href="search/10_Interaction_Specification.html#7-offline--degraded-interaction">7. Offline / degraded interaction</a></li> <li><a href="search/10_Interaction_Specification.html#8-analytics-events-proposed">8. Analytics events (proposed)</a></li> </ul> <blockquote> <p>Interaction-level contract for search: state transitions, timing, motion, input handling. Shared interaction/motion conventions from <a href="search/../00-shared/08_Interaction_&_Motion.html">00-shared/08_Interaction_&_Motion.md</a>; this file is module-specific.</p> </blockquote> <hr /> <h2 id="1-interaction-states-per-06-2"><a class="header" href="#1-interaction-states-per-06-2">1. Interaction states (per <a href="search/06_Screen_Specifications.html">06 §2</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Trigger</th><th>Visual</th><th>Exit</th></tr></thead><tbody> <tr><td>idle</td><td>no query</td><td>landing (<a href="search/07_Component_Library.html">07 §6</a>)</td><td>type → typing</td></tr> <tr><td>typing</td><td>keystroke while composing</td><td>bar spinner off; no network</td><td>debounce expiry → loading</td></tr> <tr><td>loading</td><td>debounce fire / submit / page fetch / retry</td><td>previous results dimmed; bar spinner; first query → skeleton (<a href="search/07_Component_Library.html">07 §4</a>)</td><td>response → success/empty/error</td></tr> <tr><td>success</td><td>200 with data</td><td>grouped tiles; new groups animate in</td><td>scroll → loading(page); type → typing; tap → navigate</td></tr> <tr><td>empty</td><td>200 with <code>data: []</code></td><td><code>AppEmptyState</code> (<a href="search/06_Screen_Specifications.html">06 §2.4</a>)</td><td>clear → idle; type → typing</td></tr> <tr><td>error</td><td>network/401/403/429/5xx</td><td><code>AppErrorState</code> (<a href="search/06_Screen_Specifications.html">06 §2.5</a>)</td><td>retry → loading; back → previous</td></tr> <tr><td>permission</td><td>direct deep link without <code>search</code> perm (<code>permissions.constants.ts:93</code>)</td><td>permission state (<a href="search/06_Screen_Specifications.html">06 §2.6</a>)</td><td>n/a</td></tr> </tbody></table> </div> <h2 id="2-debounce-interaction-exact-timing"><a class="header" href="#2-debounce-interaction-exact-timing">2. Debounce interaction (exact timing)</a></h2> <ol> <li>Keystroke → <code>SearchBarField</code> restarts a 300 ms <code>Timer</code>.</li> <li>Composition end (IME) starts the timer (CJK-safe).</li> <li>Timer fires → <code>SearchCubit.queryChanged</code> → stale-guard token++ → <code>loading</code> → repository call (<a href="search/13_State_Management.html">13</a> §3).</li> <li>Any earlier in-flight response whose token is stale is dropped.</li> <li>Empty input cancels the timer immediately (no request, <a href="search/08_Form_Specifications.html">08 §1.2</a>).</li> </ol> <h2 id="3-query-transitions"><a class="header" href="#3-query-transitions">3. Query transitions</a></h2> <div class="table-wrapper"><table><thead><tr><th>From</th><th>To</th><th>Behavior</th></tr></thead><tbody> <tr><td><code>q=A</code> (results shown)</td><td><code>q=B</code></td><td>keep <code>A</code> results visible while <code>B</code> loads; replace on <code>B</code> success — never blank the list (<a href="search/06_Screen_Specifications.html">06 §2.2</a>)</td></tr> <tr><td>aggregate view</td><td>see-all <code>type=X</code></td><td><code>entityType=X</code> set, page 1, list collapses to one group; back restores previous query + type</td></tr> <tr><td>see-all</td><td>back</td><td>restore aggregate view from cubit state (no refetch needed if unchanged)</td></tr> <tr><td>pagination</td><td>append</td><td>page+1 appended; no scroll jump; scroll position preserved</td></tr> </tbody></table> </div> <h2 id="4-navigation-interactions"><a class="header" href="#4-navigation-interactions">4. Navigation interactions</a></h2> <ul> <li>Tap tile → owner-module detail route (Students <code>/students/:id</code>, etc.) — SearchModule only supplies the pointer (<code>entityId</code>, <code>entityType</code>, <code>search.service.ts:11-12</code>).</li> <li>Back from detail → results preserved as-is (cubit state survives).</li> <li>Deep link <code>/search?q=…</code> <code>(forward-looking)</code>: prefill bar, run query.</li> </ul> <h2 id="5-motion--animation-tokens-from-00-shared08"><a class="header" href="#5-motion--animation-tokens-from-00-shared08">5. Motion & animation (tokens from <a href="search/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Motion</th></tr></thead><tbody> <tr><td>Focus into bar</td><td>underline grow <code>m-fast</code> (150 ms)</td></tr> <tr><td>New group appears</td><td>fade + 4 px rise <code>m-base</code> (250 ms)</td></tr> <tr><td>Group removal</td><td>fade out 150 ms, list reflows</td></tr> <tr><td>Row tap</td><td>standard ripple; navigation slide <code>m-base</code></td></tr> <tr><td>Clear (✕)</td><td>bar resets to idle <code>m-fast</code>; landing fade-in <code>m-base</code></td></tr> <tr><td>Error state</td><td>shake <code>m-fast</code> on retry failure (match auth module convention)</td></tr> </tbody></table> </div> <h2 id="6-input-handling-rules"><a class="header" href="#6-input-handling-rules">6. Input handling rules</a></h2> <ul> <li><code>textInputAction.search</code> submits; <code>Esc</code> (desktop) clears; <code>/</code> (desktop) focuses bar (<a href="search/06_Screen_Specifications.html">06 §6</a>).</li> <li>No IME debounce while composing (<a href="search/08_Form_Specifications.html">08 §1.5</a>).</li> <li>Focus: bar auto-focused on <code>/search</code> entry; cleared on navigation.</li> </ul> <h2 id="7-offline--degraded-interaction"><a class="header" href="#7-offline--degraded-interaction">7. Offline / degraded interaction</a></h2> <ul> <li>Offline → banner + last results retained (if any) + retry on restore (<a href="search/../00-shared/10_QA_Baseline.html">00-shared/10 §2</a>).</li> <li>Server-side fallback (<code>search.service.ts:35-41</code>) is invisible to the user; client does not show a "degraded" state (order may differ — fallback has no textScore sort, <code>search-index.repository.ts:49</code>).</li> </ul> <h2 id="8-analytics-events-proposed"><a class="header" href="#8-analytics-events-proposed">8. Analytics events (proposed)</a></h2> <p><code>search.bar.focus</code>, <code>search.bar.submit</code>, <code>search.bar.clear</code>, <code>search.landing.chip.tap</code>, <code>search.results.shown(count)</code>, <code>search.results.empty</code>, <code>search.result.tap(entityType)</code>, <code>search.seeall.tap(type)</code>, <code>search.page.next</code>, <code>search.error.{server,offline,retry}</code>. All <code>(proposed)</code> — SDK open (<a href="search/../00-shared/12_Assumptions_&_Open_Questions.html">00-shared/12 A4</a>).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-search-module"><a class="header" href="#11--design-system-mapping-search-module">11 — Design System Mapping (Search Module)</a></h1> <ul> <li><a href="search/11_Design_System_Mapping.html#1-tokens-used">1. Tokens used</a></li> <li><a href="search/11_Design_System_Mapping.html#2-component-mapping">2. Component mapping</a></li> <li><a href="search/11_Design_System_Mapping.html#3-iconography">3. Iconography</a></li> <li><a href="search/11_Design_System_Mapping.html#4-typography-rules">4. Typography rules</a></li> <li><a href="search/11_Design_System_Mapping.html#5-theme-variants">5. Theme variants</a></li> <li><a href="search/11_Design_System_Mapping.html#6-what-the-module-does-not-customize">6. What the module does NOT customize</a></li> </ul> <blockquote> <p>Maps every search surface to shared design-system tokens and components (<a href="search/../00-shared/02_Design_Tokens.html">00-shared/02_Design_Tokens.md</a>, <a href="search/../00-shared/04_Design_System_Mapping.html">00-shared/04_Design_System_Mapping.md</a>, <a href="search/../00-shared/03_Component_Library.html">00-shared/03_Component_Library.md</a>). No new tokens are invented; module components (<a href="search/07_Component_Library.html">07</a>) are compositions of shared primitives.</p> </blockquote> <hr /> <h2 id="1-tokens-used"><a class="header" href="#1-tokens-used">1. Tokens used</a></h2> <div class="table-wrapper"><table><thead><tr><th>Token class</th><th>Usage in search</th><th>Shared source</th></tr></thead><tbody> <tr><td>Color</td><td><code>color.surface</code> (bar), <code>color.primary</code> (focus underline, see-all), <code>color.text.secondary</code> (hint/captions), <code>color.error</code> (error state)</td><td><a href="search/../00-shared/02_Design_Tokens.html">00-shared/02</a></td></tr> <tr><td>Spacing</td><td>8/12/16 grid: bar padding 16, group gap 16, tile gap 8</td><td><a href="search/../00-shared/02_Design_Tokens.html">00-shared/02</a></td></tr> <tr><td>Radii</td><td>bar 24 dp (pill), tiles 12 dp</td><td><a href="search/../00-shared/02_Design_Tokens.html">00-shared/02</a></td></tr> <tr><td>Elevation</td><td>none on results (flat list); shadow only on bar when focused</td><td><a href="search/../00-shared/02_Design_Tokens.html">00-shared/02</a></td></tr> <tr><td>Type scale</td><td><code>titleMedium</code> (tile title), <code>bodyMedium</code> (description), <code>labelSmall</code> (captions/tags)</td><td><a href="search/../00-shared/02_Design_Tokens.html">00-shared/02</a></td></tr> <tr><td>Motion</td><td><code>m-fast</code> 150 ms, <code>m-base</code> 250 ms (<a href="search/10_Interaction_Specification.html">10 §5</a>)</td><td><a href="search/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a></td></tr> <tr><td>Semantics</td><td>heading levels on group headers, live region on count</td><td><a href="search/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a></td></tr> </tbody></table> </div> <h2 id="2-component-mapping-2"><a class="header" href="#2-component-mapping-2">2. Component mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Shared component (<a href="search/../00-shared/03_Component_Library.html">00-shared/03</a>)</th><th>Search usage</th><th>Module wrapper</th></tr></thead><tbody> <tr><td><code>AppTextField</code></td><td>search input base</td><td><code>SearchBarField</code> (<a href="search/07_Component_Library.html">07 §1</a>)</td></tr> <tr><td><code>AppListTile</code></td><td>result row base</td><td><code>SearchResultTile</code> (<a href="search/07_Component_Library.html">07 §2</a>)</td></tr> <tr><td><code>AppAvatar</code></td><td>leading avatar / type icon</td><td>in <code>SearchResultTile</code></td></tr> <tr><td><code>AppChips</code> / <code>AppTag</code></td><td>landing examples; result tags (role/dept/grade — <code>search-indexer.service.ts:83-86</code>)</td><td><a href="search/07_Component_Library.html">07 §2,6</a></td></tr> <tr><td><code>AppSkeleton</code></td><td>first-query shimmer</td><td><code>SearchResultSkeleton</code> (<a href="search/07_Component_Library.html">07 §4</a>)</td></tr> <tr><td><code>AppEmptyState</code></td><td>zero results / landing</td><td><code>SearchEmptyState</code> (<a href="search/07_Component_Library.html">07 §5</a>)</td></tr> <tr><td><code>AppErrorState</code></td><td>error + retry</td><td><code>SearchErrorState</code> (<a href="search/07_Component_Library.html">07 §5</a>)</td></tr> <tr><td><code>AppOfflineBanner</code></td><td>offline banner</td><td>direct use</td></tr> <tr><td><code>AppSpinner</code></td><td>bar inline spinner, bottom loader</td><td>direct use</td></tr> <tr><td><code>AppButton</code></td><td>clear/back CTAs</td><td>direct use</td></tr> <tr><td><code>AppBadge</code></td><td>group count badge (per-page counts — <a href="search/07_Component_Library.html">07 §3</a>)</td><td>in <code>SearchGroupHeader</code></td></tr> </tbody></table> </div> <h2 id="3-iconography-2"><a class="header" href="#3-iconography-2">3. Iconography</a></h2> <p>Entity-type icons (proposed set, <a href="search/07_Component_Library.html">07 §2</a>): Student, User, Teacher, Staff, Parent, Book, Organization, Lead, Announcement. Rules: monochrome <code>color.text.secondary</code>; avatar fallback = initials from <code>title</code> (<code>search.service.ts:13</code>).</p> <h2 id="4-typography-rules"><a class="header" href="#4-typography-rules">4. Typography rules</a></h2> <ul> <li>Titles never truncate to one line without ellipsis; descriptions max 2 lines.</li> <li>Query text in the bar renders as typed (casing preserved — <a href="search/08_Form_Specifications.html">08 §3</a>).</li> <li>"No results for “q”" quotes the raw query verbatim.</li> </ul> <h2 id="5-theme-variants"><a class="header" href="#5-theme-variants">5. Theme variants</a></h2> <div class="table-wrapper"><table><thead><tr><th>Variant</th><th>Change</th></tr></thead><tbody> <tr><td>Dark theme</td><td>surface/elevation tokens only; no layout change</td></tr> <tr><td>High contrast</td><td>focus underline 2 dp; tag borders instead of fills</td></tr> <tr><td>Text scale ×1.5+</td><td>groups remain scrollable; tiles stack (a11y, <a href="search/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>)</td></tr> </tbody></table> </div> <h2 id="6-what-the-module-does-not-customize"><a class="header" href="#6-what-the-module-does-not-customize">6. What the module does NOT customize</a></h2> <p>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 <a href="search/07_Component_Library.html">07</a>.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-search-module"><a class="header" href="#12--api-mapping-search-module">12 — API Mapping (Search Module)</a></h1> <ul> <li><a href="search/12_API_Mapping.html#1-endpoint-inventory">1. Endpoint inventory</a></li> <li><a href="search/12_API_Mapping.html#2-query-parameters-exact">2. Query parameters (exact)</a></li> <li><a href="search/12_API_Mapping.html#3-success-response-exact-shape">3. Success response (exact shape)</a></li> <li><a href="search/12_API_Mapping.html#4-empty-query-response">4. Empty query response</a></li> <li><a href="search/12_API_Mapping.html#5-error-responses-client-relevant">5. Error responses (client-relevant)</a></li> <li><a href="search/12_API_Mapping.html#6-request-example-curl">6. Request example (curl)</a></li> <li><a href="search/12_API_Mapping.html#7-client-mapping-widget--endpoint">7. Client mapping (widget → endpoint)</a></li> <li><a href="search/12_API_Mapping.html#8-non-goals">8. Non-goals</a></li> </ul> <blockquote> <p>Exact wire contract for global search. Envelope shapes are authoritative from <a href="search/../00-shared/07_API_Conventions.html">00-shared/07_API_Conventions.md</a> (success/error envelopes, pagination meta, error codes, rate limits). Endpoint facts from <code>search.controller.ts</code>, <code>search.service.ts</code>, <code>search-query.dto.ts</code>.</p> </blockquote> <hr /> <h2 id="1-endpoint-inventory"><a class="header" href="#1-endpoint-inventory">1. Endpoint inventory</a></h2> <div class="table-wrapper"><table><thead><tr><th>Method</th><th>Path</th><th>Permission</th><th>Source</th></tr></thead><tbody> <tr><td>GET</td><td><code>/api/v1/search</code></td><td><code>search</code></td><td><code>search.controller.ts:11,15-16</code>; <code>permissions.constants.ts:93</code></td></tr> </tbody></table> </div> <ul> <li>Base URL <code>https://api.<domain>/api/v1</code> (<a href="search/../00-shared/07_API_Conventions.html">00-shared/07 §1</a>).</li> <li>Auth: <code>Authorization: Bearer <accessToken></code> — <code>JwtAuthGuard</code> (<code>search.controller.ts:3,10</code>); Swagger <code>@ApiBearerAuth()</code> (<code>:9</code>).</li> <li>This is the <strong>only</strong> endpoint in the module. Plan-only: <code>(planned)</code> coaching-index extension needs no new endpoint (same <code>GET /search</code> — <code>docs/IMPLEMENTATION_PLAN.md:771</code>).</li> </ul> <h2 id="2-query-parameters-exact"><a class="header" href="#2-query-parameters-exact">2. Query parameters (exact)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Param</th><th>Type</th><th>Default</th><th>Constraint</th><th>Source</th></tr></thead><tbody> <tr><td><code>q</code></td><td>string</td><td>—</td><td>optional, no length cap</td><td><code>search-query.dto.ts:6-9</code></td></tr> <tr><td><code>entityType</code></td><td>string</td><td>—</td><td>optional, free-form (client sends known types)</td><td><code>search-query.dto.ts:11-14</code></td></tr> <tr><td><code>page</code></td><td>int</td><td>1</td><td><code>≥ 1</code></td><td><code>search-query.dto.ts:16-21</code></td></tr> <tr><td><code>limit</code></td><td>int</td><td>20</td><td><code>1..50</code></td><td><code>search-query.dto.ts:23-29</code></td></tr> </tbody></table> </div> <p>Out-of-range <code>page</code>/<code>limit</code>, non-integer values → <code>400 VALIDATION_ERROR</code> (<a href="search/../00-shared/07_API_Conventions.html">00-shared/07 §3</a>).</p> <h2 id="3-success-response-exact-shape"><a class="header" href="#3-success-response-exact-shape">3. Success response (exact shape)</a></h2> <pre><code class="language-json">{ "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-…" } </code></pre> <ul> <li><code>data</code> = array of <code>SearchResult</code> (<code>search.service.ts:10-16,51-57</code>).</li> <li><code>meta</code> = <code>buildPaginationMeta(page, limit, totalItems)</code> (<code>pagination-query.dto.ts:41-55</code>); note <code>totalPages</code> never drops below 1 (<code>:46</code>).</li> <li><code>totalItems</code> = full-text count (<code>search.service.ts:37</code>); when <code>entityType</code> filtering trims a page, <code>totalItems</code> is <strong>replaced by the filtered page length</strong> (<code>search.service.ts:44-49</code>) — see QA-14 §7.</li> <li>Relevance score (<code>textScore</code>) is used for sort only and <strong>never serialized</strong> (<code>search-index.repository.ts:28-33</code>; <code>search.service.ts:51-57</code>).</li> </ul> <h2 id="4-empty-query-response"><a class="header" href="#4-empty-query-response">4. Empty query response</a></h2> <p><code>q</code> blank/whitespace → <code>data: []</code>, <code>meta.totalItems: 0</code>, <code>totalPages: 1</code> (<code>search.service.ts:28-30</code>). 200, not an error.</p> <h2 id="5-error-responses-client-relevant"><a class="header" href="#5-error-responses-client-relevant">5. Error responses (client-relevant)</a></h2> <div class="table-wrapper"><table><thead><tr><th>HTTP</th><th>Code</th><th>When</th><th>Source</th></tr></thead><tbody> <tr><td>400</td><td><code>VALIDATION_ERROR</code></td><td>invalid <code>page</code>/<code>limit</code>/types</td><td><code>search-query.dto.ts</code>; <code>00-shared/07 §3</code></td></tr> <tr><td>401</td><td><code>UNAUTHENTICATED</code></td><td>missing/expired token</td><td><code>JwtAuthGuard</code> (<code>search.controller.ts:10</code>)</td></tr> <tr><td>403</td><td><code>PERMISSION_DENIED</code></td><td>no <code>search</code> permission</td><td><code>@Permissions('search')</code> (<code>search.controller.ts:16</code>; <code>permissions.constants.ts:93</code>)</td></tr> <tr><td>429</td><td><code>RATE_LIMITED</code></td><td>per-tier limits (<code>00-shared/07 §4</code>)</td><td>global rate limit guards</td></tr> <tr><td>5xx</td><td><code>INTERNAL_SERVER_ERROR</code></td><td>internal (incl. regex-fallback failure)</td><td><code>00-shared/07 §3</code></td></tr> </tbody></table> </div> <p>Server-side resilience: <code>$text</code> failure is caught and retried via regex fallback before any error reaches the client (<code>search.service.ts:35-41</code>).</p> <h2 id="6-request-example-curl"><a class="header" href="#6-request-example-curl">6. Request example (curl)</a></h2> <pre><code>GET /api/v1/search?q=rahul%20sharma&entityType=Student&page=1&limit=20 Authorization: Bearer <accessToken> </code></pre> <h2 id="7-client-mapping-widget--endpoint"><a class="header" href="#7-client-mapping-widget--endpoint">7. Client mapping (widget → endpoint)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen (<code>05</code>)</th><th>Request</th><th>Key handling</th></tr></thead><tbody> <tr><td>Search bar</td><td><code>GET /search?q=…</code> (debounced 300 ms)</td><td>drop stale responses (<code>13 §3</code>)</td></tr> <tr><td>Results screen</td><td>same + infinite scroll (<code>page++</code>)</td><td>append; <code>meta.hasNext</code> gate (<code>pagination-query.dto.ts:52</code>)</td></tr> <tr><td>See-all</td><td><code>&entityType=X</code></td><td>replace list, page 1</td></tr> <tr><td>Refresh</td><td>same query, page 1</td><td>replace list</td></tr> </tbody></table> </div> <h2 id="8-non-goals"><a class="header" href="#8-non-goals">8. Non-goals</a></h2> <ul> <li>No <code>POST /search</code>, no bulk, no suggestions, no history endpoints — anything beyond <code>GET /search</code> is <code>(forward-looking)</code> / <code>(planned)</code>.</li> <li>Search results are <strong>not cached in Redis today</strong> — <code>RedisCacheService</code> (<code>redis-cache.service.ts:16-19</code>) is available but unwired <code>(proposed)</code>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-search-module"><a class="header" href="#13--state-management-search-module">13 — State Management (Search Module)</a></h1> <ul> <li><a href="search/13_State_Management.html#1-cubits">1. Cubits</a> <ul> <li><a href="search/13_State_Management.html#11-searchcubit-results-screen--in-shell-bar-share-one-instance">1.1 <code>SearchCubit</code> (results screen + in-shell bar share one instance)</a></li> <li><a href="search/13_State_Management.html#12-searchhistorycubit-forward-looking">1.2 <code>SearchHistoryCubit</code> <code>(forward-looking)</code></a></li> </ul> </li> <li><a href="search/13_State_Management.html#2-repository">2. Repository</a></li> <li><a href="search/13_State_Management.html#3-debounce--stale-guard-flow-exact">3. Debounce + stale-guard flow (exact)</a></li> <li><a href="search/13_State_Management.html#4-entity-type-filter-state">4. Entity-type filter state</a></li> <li><a href="search/13_State_Management.html#5-failure--retry">5. Failure & retry</a></li> <li><a href="search/13_State_Management.html#6-what-is-not-in-state">6. What is NOT in state</a></li> </ul> <blockquote> <p>Client state architecture for global search. Conventions from <a href="search/../00-shared/06_State_Management.html">00-shared/06_State_Management.md</a> (flutter_bloc, <code>get_it</code> DI, repository layer with <code>dio</code>, no server-state libs). Search is a <strong>pure server-state</strong> surface — no local persistence <code>(forward-looking)</code> history aside.</p> </blockquote> <hr /> <h2 id="1-cubits-4"><a class="header" href="#1-cubits-4">1. Cubits</a></h2> <h3 id="11-searchcubit-results-screen--in-shell-bar-share-one-instance"><a class="header" href="#11-searchcubit-results-screen--in-shell-bar-share-one-instance">1.1 <code>SearchCubit</code> (results screen + in-shell bar share one instance)</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Fields</th></tr></thead><tbody> <tr><td><code>SearchInitial</code></td><td>—</td></tr> <tr><td><code>SearchLoading</code></td><td>previous <code>SearchResults?</code> (kept for dimmed view)</td></tr> <tr><td><code>SearchSuccess</code></td><td><code>SearchResults</code> (query, <code>entityType</code>, list, <code>PaginationMeta</code>)</td></tr> <tr><td><code>SearchEmpty</code></td><td><code>query</code> (raw, for copy)</td></tr> <tr><td><code>SearchError</code></td><td><code>ApiException(code, status, message)</code>, <code>query</code></td></tr> </tbody></table> </div> <p><code>SearchResults</code> model mirrors <code>SearchResult[]</code> + <code>PaginationMeta</code> (<code>search.service.ts:10-16</code>, <code>pagination-query.dto.ts:32-39</code>).</p> <h3 id="12-searchhistorycubit-forward-looking"><a class="header" href="#12-searchhistorycubit-forward-looking">1.2 <code>SearchHistoryCubit</code> <code>(forward-looking)</code></a></h3> <p>Device-storage recent queries (ledger B5 — server has no history endpoint).</p> <h2 id="2-repository"><a class="header" href="#2-repository">2. Repository</a></h2> <p><code>SearchRepository</code> (single method):</p> <pre><code class="language-dart">Future<SearchPage> search({ required String q, String? entityType, int page = 1, int limit = 20, }) async => /* GET /search, maps envelope → SearchPage */; </code></pre> <ul> <li>Only layer touching HTTP (<code>00-shared/06 §2</code>); throws typed <code>ApiException</code> (401/403/429/5xx — <code>12 §5</code>).</li> <li><strong>No caching</strong> in this layer today — matches backend (Redis unwired, <code>redis-cache.service.ts:16-19</code>); add <code>(proposed)</code> TTL cache keyed <code>search:{q}:{type}:{page}</code> if latency budgets demand (<code>00-shared/10 §1</code>).</li> </ul> <h2 id="3-debounce--stale-guard-flow-exact"><a class="header" href="#3-debounce--stale-guard-flow-exact">3. Debounce + stale-guard flow (exact)</a></h2> <pre class="mermaid">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 </pre> <p>Rules (<code>06 §1.3</code>, <code>10 §2</code>):</p> <ul> <li>Timer restarts on every keystroke; fires only after 300 ms of silence.</li> <li>Submit (<code>textInputAction.search</code>) skips the timer.</li> <li>Guard token: each new query invalidates earlier in-flight responses — prevents out-of-order rendering.</li> <li>Pagination: <code>loadMore()</code> guarded by <code>meta.hasNext</code> (<code>pagination-query.dto.ts:52</code>) and <code>!isLoadingPage</code>; appends, never replaces.</li> </ul> <h2 id="4-entity-type-filter-state"><a class="header" href="#4-entity-type-filter-state">4. Entity-type filter state</a></h2> <ul> <li><code>entityType</code> lives in the cubit, not in route params (keeps back-navigation cheap, <code>10 §3</code>); route params optional <code>(forward-looking)</code> for deep links.</li> <li>See-all sets <code>entityType</code> + page 1; clearing restores the aggregate.</li> <li>Back from detail: cubit survives (app-shell scope), so results are intact.</li> </ul> <h2 id="5-failure--retry"><a class="header" href="#5-failure--retry">5. Failure & retry</a></h2> <ul> <li><code>SearchError</code> → <code>AppErrorState</code> retry re-emits the same query (token++, page 1).</li> <li>401 → global session-expiry flow (<code>00-shared/10 §3</code>), query preserved.</li> <li>Offline → banner + last <code>SearchSuccess</code> retained (<code>10 §7</code>).</li> </ul> <h2 id="6-what-is-not-in-state"><a class="header" href="#6-what-is-not-in-state">6. What is NOT in state</a></h2> <ul> <li>No per-type totals (server cannot provide them beyond the fetched page — <code>search.service.ts:44-49</code>).</li> <li>No debounce state (owned by the widget's <code>Timer</code>).</li> <li>No history/suggestions (<code>(forward-looking)</code>).</li> <li>No Redis/persistent cache (<code>(proposed)</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-search-module"><a class="header" href="#14--qa-checklist-search-module">14 — QA Checklist (Search Module)</a></h1> <ul> <li><a href="search/14_QA_Checklist.html#1-query-contract">1. Query contract</a></li> <li><a href="search/14_QA_Checklist.html#2-minimum-characters">2. Minimum characters</a></li> <li><a href="search/14_QA_Checklist.html#3-special-characters">3. Special characters</a></li> <li><a href="search/14_QA_Checklist.html#4-tenant-isolation">4. Tenant isolation</a></li> <li><a href="search/14_QA_Checklist.html#5-result-limits--pagination">5. Result limits & pagination</a></li> <li><a href="search/14_QA_Checklist.html#6-stale-data">6. Stale data</a></li> <li><a href="search/14_QA_Checklist.html#7-fallback-parity">7. Fallback parity</a></li> <li><a href="search/14_QA_Checklist.html#8-scoring">8. Scoring</a></li> <li><a href="search/14_QA_Checklist.html#9-security--auth">9. Security & auth</a></li> <li><a href="search/14_QA_Checklist.html#10-ux--a11y">10. UX & a11y</a></li> <li><a href="search/14_QA_Checklist.html#11-performance">11. Performance</a></li> </ul> <blockquote> <p>Module-specific quality gates on top of <a href="search/../00-shared/10_QA_Baseline.html">00-shared/10_QA_Baseline.md</a> (performance budgets, offline matrix, security checklist, device matrix). Each item is traceable to source.</p> </blockquote> <hr /> <h2 id="1-query-contract"><a class="header" href="#1-query-contract">1. Query contract</a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>q</code> is trimmed; whitespace-only <code>q</code> is <strong>never sent</strong> (server returns <code>data: []</code> — <code>search.service.ts:28-30</code>).</li> <li><input disabled="" type="checkbox"/> <code>page ≥ 1</code>, <code>limit ∈ 1..50</code> (defaults 1/20 — <code>search-query.dto.ts:16-29</code>); values outside → server <code>400 VALIDATION_ERROR</code>.</li> <li><input disabled="" type="checkbox"/> Debounce fires only after 300 ms of keystroke silence; submit skips it (<a href="search/13_State_Management.html">13 §3</a>).</li> <li><input disabled="" type="checkbox"/> Stale responses dropped (guard token) — rapid typing never renders out-of-order results.</li> </ul> <h2 id="2-minimum-characters"><a class="header" href="#2-minimum-characters">2. Minimum characters</a></h2> <ul> <li><input disabled="" type="checkbox"/> Server accepts <strong>1-char queries</strong> (no <code>@Min</code> on <code>q</code> — <code>search-query.dto.ts:6-9</code>); verify 1-char results render and the fallback path does not degrade response time (regex full-scan — <code>search-index.repository.ts:36-50</code>).</li> <li><input disabled="" type="checkbox"/> No query → landing screen, zero network calls.</li> </ul> <h2 id="3-special-characters"><a class="header" href="#3-special-characters">3. Special characters</a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>( ) [ ] { } * + ? ^ $ | . \</code> inside <code>q</code> must never 500. Risk: the fallback builds <code>{ $regex: q, $options: 'i' }</code> from raw input (<code>search-index.repository.ts:40</code>) — invalid regex throws. <strong>Gap</strong>: mitigation <code>(proposed)</code> is client-side escaping (<a href="search/08_Form_Specifications.html">08 §1.4</a>).</li> <li><input disabled="" type="checkbox"/> Punctuation in <code>$text</code> queries (<code>ADM-20</code>, <code>Mr. John</code>) — verify expected match behavior vs fallback (tokenization differs — <a href="search/09_User_Behaviour.html">09 §1</a>).</li> <li><input disabled="" type="checkbox"/> <code>%</code>, <code>&</code>, <code>+</code>, <code>=</code>, UTF-8 (Iñigo, Școala) round-trip through URL encoding and match by substring/word.</li> <li><input disabled="" type="checkbox"/> 100+ char queries: server unbounded (<a href="search/08_Form_Specifications.html">08 §1.2</a>) — confirm p95 latency still within budget (<a href="search/../00-shared/10_QA_Baseline.html">00-shared/10 §1</a>).</li> </ul> <h2 id="4-tenant-isolation-1"><a class="header" href="#4-tenant-isolation-1">4. Tenant isolation</a></h2> <ul> <li><input disabled="" type="checkbox"/> Cross-tenant rows never leak: every query uses <code>scopedFilter</code> (<code>search-index.repository.ts:24-26</code>) over <code>BaseSchema.tenantId</code> (<code>base.schema.ts:9-11</code>); upserts key on <code>(tenantId, entityType, entityId)</code> (<code>search-index.repository.ts:84-89</code>).</li> <li><input disabled="" type="checkbox"/> Two tenants searching the same string see disjoint results.</li> <li><input disabled="" type="checkbox"/> <code>entityId</code> from tenant A is not resolvable in tenant B (result rows are pointers into owner modules — <a href="search/12_API_Mapping.html">12 §8</a>).</li> </ul> <h2 id="5-result-limits--pagination"><a class="header" href="#5-result-limits--pagination">5. Result limits & pagination</a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>limit</code> respected (max 50) — server slices via <code>.skip().limit()</code> (<code>search-index.repository.ts:32-33</code>).</li> <li><input disabled="" type="checkbox"/> Pagination walk: <code>meta.hasNext</code> true → page+1 appends; final page stops (<code>pagination-query.dto.ts:46-54</code>).</li> <li><input disabled="" type="checkbox"/> <strong>Known bug-behavior (flagged):</strong> <code>entityType</code> filtering happens on the <strong>fetched page only</strong> (<code>search.service.ts:44-49</code>); with <code>limit=20</code> and 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", <a href="search/07_Component_Library.html">07 §3</a>) and consider a <code>(planned)</code> server-side type filter.</li> </ul> <h2 id="6-stale-data"><a class="header" href="#6-stale-data">6. Stale data</a></h2> <ul> <li><input disabled="" type="checkbox"/> Rename student/teacher/staff/parent/book/user → re-search within seconds shows new title (event → upsert — <code>search-indexer.service.ts:48-96</code>).</li> <li><input disabled="" type="checkbox"/> Soft-delete an entity → row disappears from search (<code>removeByEntity</code> sets <code>isDeleted</code> — <code>search-index.repository.ts:92-98</code>; scoped filter excludes — <code>base.schema.ts:20-21</code>).</li> <li><input disabled="" type="checkbox"/> <strong>Known gap:</strong> <code>LeadUpdated</code>, <code>LeadDeleted</code>, <code>AnnouncementUpdated</code>, <code>AnnouncementDeleted</code> are unmapped (<code>search-indexer.service.ts:29-31</code>) → edited/deleted leads & announcements persist stale rows. Verify the <code>(planned)</code> reindex job resolves; until then document behavior.</li> <li><input disabled="" type="checkbox"/> Indexer idempotency: duplicate events upsert, never duplicate rows (<code>search-index.repository.ts:85-89</code>).</li> </ul> <h2 id="7-fallback-parity"><a class="header" href="#7-fallback-parity">7. Fallback parity</a></h2> <ul> <li><input disabled="" type="checkbox"/> Force <code>$text</code> failure (test hook) → regex fallback returns results, <code>totalItems</code> consistent per path (<code>search.service.ts:35-41</code>).</li> <li><input disabled="" type="checkbox"/> Fallback order differs (no textScore sort — <code>search-index.repository.ts:49</code>): acceptable, verify no UI regression.</li> </ul> <h2 id="8-scoring"><a class="header" href="#8-scoring">8. Scoring</a></h2> <ul> <li><input disabled="" type="checkbox"/> Exact-title match ranks above substring (<code>$text</code> textScore sort — <code>search-index.repository.ts:28-33</code>); score never serialized (<code>search.service.ts:51-57</code>).</li> </ul> <h2 id="9-security--auth"><a class="header" href="#9-security--auth">9. Security & auth</a></h2> <ul> <li><input disabled="" type="checkbox"/> 401 unauthenticated → <code>UNAUTHENTICATED</code>; 403 without <code>search</code> perm (<code>permissions.constants.ts:93</code>) → <code>PERMISSION_DENIED</code> (<a href="search/12_API_Mapping.html">12 §5</a>).</li> <li><input disabled="" type="checkbox"/> No query text in crash logs or analytics payloads beyond the analytics contract (<a href="search/../00-shared/10_QA_Baseline.html">00-shared/10 §3</a>).</li> <li><input disabled="" type="checkbox"/> Rate limits respected; 429 handled with guidance (<a href="search/../00-shared/07_API_Conventions.html">00-shared/07 §4</a>).</li> </ul> <h2 id="10-ux--a11y"><a class="header" href="#10-ux--a11y">10. UX & a11y</a></h2> <ul> <li><input disabled="" type="checkbox"/> 1-char queries, 300 ms debounce → no input lag, no flicker (prior results dim, <a href="search/06_Screen_Specifications.html">06 §2.2</a>).</li> <li><input disabled="" type="checkbox"/> Zero-results copy quotes raw query; clear CTA returns to landing.</li> <li><input disabled="" type="checkbox"/> Live-region announces result count; group headers are headings; tiles labeled <code>"{title}, {type}, {description}"</code> (<a href="search/07_Component_Library.html">07 §2</a>).</li> <li><input disabled="" type="checkbox"/> Offline → banner + cached last results + retry on restore (<a href="search/../00-shared/10_QA_Baseline.html">00-shared/10 §2</a>).</li> <li><input disabled="" type="checkbox"/> Keyboard: <code>/</code> focuses, <code>Esc</code> clears, enter submits (desktop — <a href="search/06_Screen_Specifications.html">06 §6</a>).</li> </ul> <h2 id="11-performance-6"><a class="header" href="#11-performance-6">11. Performance</a></h2> <ul> <li><input disabled="" type="checkbox"/> Search p95 < 1 s (target) — 2 queries per search (search + count, <code>search.service.ts:36-37</code>).</li> <li><input disabled="" type="checkbox"/> No duplicate parallel requests for the same <code>(q, type, page)</code> key (<a href="search/../00-shared/10_QA_Baseline.html">00-shared/10 §1</a>).</li> <li><input disabled="" type="checkbox"/> Long lists: lazy tile rendering, no rebuild storm on pagination append.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-search-module"><a class="header" href="#15--flutter-implementation-guide-search-module">15 — Flutter Implementation Guide (Search Module)</a></h1> <ul> <li><a href="search/15_Flutter_Implementation_Guide.html#1-feature-folder">1. Feature folder</a></li> <li><a href="search/15_Flutter_Implementation_Guide.html#2-models--mapping-exact-shape">2. Models & mapping (exact shape)</a></li> <li><a href="search/15_Flutter_Implementation_Guide.html#3-repository">3. Repository</a></li> <li><a href="search/15_Flutter_Implementation_Guide.html#4-cubit-see-13-for-the-full-state-machine">4. Cubit (see <code>13</code> for the full state machine)</a></li> <li><a href="search/15_Flutter_Implementation_Guide.html#5-debounce-widget">5. Debounce widget</a></li> <li><a href="search/15_Flutter_Implementation_Guide.html#6-widget-tree">6. Widget tree</a></li> <li><a href="search/15_Flutter_Implementation_Guide.html#7-router-entries">7. Router entries</a></li> <li><a href="search/15_Flutter_Implementation_Guide.html#8-tests">8. Tests</a></li> <li><a href="search/15_Flutter_Implementation_Guide.html#9-non-goals-in-v1">9. Non-goals in v1</a></li> </ul> <blockquote> <p>How to build the search feature in the Flutter client. Extends <a href="search/../00-shared/11_Flutter_App_Architecture.html">00-shared/11_Flutter_App_Architecture.md</a> (folder layout, DI, dio, go_router, flutter_bloc). <strong>Forward-looking spec</strong> — no client repo exists yet (ledger A1).</p> </blockquote> <hr /> <h2 id="1-feature-folder-2"><a class="header" href="#1-feature-folder-2">1. Feature folder</a></h2> <pre><code>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 </code></pre> <h2 id="2-models--mapping-exact-shape"><a class="header" href="#2-models--mapping-exact-shape">2. Models & mapping (exact shape)</a></h2> <pre><code class="language-dart">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 } </code></pre> <h2 id="3-repository-1"><a class="header" href="#3-repository-1">3. Repository</a></h2> <pre><code class="language-dart">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 } } </code></pre> <ul> <li>Base URL <code>/api/v1</code> prefix handled by <code>AppDio</code> (<code>00-shared/11 §2</code>).</li> <li>Validation rules enforced by the server (<code>search-query.dto.ts:16-29</code>) — keep client values in range; treat <code>400 VALIDATION_ERROR</code> as a bug.</li> <li><strong>No client cache</strong> initially — backend doesn't cache either (<code>redis-cache.service.ts</code> unwired); add <code>(proposed)</code> in-memory TTL cache only if the 2-query pattern (<code>search.service.ts:36-37</code>) misses budget.</li> </ul> <h2 id="4-cubit-see-13-for-the-full-state-machine"><a class="header" href="#4-cubit-see-13-for-the-full-state-machine">4. Cubit (see <code>13</code> for the full state machine)</a></h2> <pre><code class="language-dart">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 } </code></pre> <h2 id="5-debounce-widget"><a class="header" href="#5-debounce-widget">5. Debounce widget</a></h2> <pre><code class="language-dart">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()); </code></pre> <p>Rules: timer restart per keystroke; <code>dispose()</code> cancels; IME composing ignored until composition end (<code>08 §1.5</code>).</p> <h2 id="6-widget-tree"><a class="header" href="#6-widget-tree">6. Widget tree</a></h2> <pre><code>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) </code></pre> <p>Grouping helper: <code>groupBy(entityType)</code> preserves first-seen order; group order proposed in <code>06 §2.3</code>.</p> <h2 id="7-router-entries"><a class="header" href="#7-router-entries">7. Router entries</a></h2> <pre><code class="language-dart">GoRoute(path: '/search', builder: SearchResultsScreen.new, queryParameters: {'q': ..., 'type': ...}); // type maps to entityType (forward-looking deep links) </code></pre> <h2 id="8-tests"><a class="header" href="#8-tests">8. Tests</a></h2> <ul> <li><strong>Cubit:</strong> debounce via fake async; stale-guard (two rapid queries, first response arrives last → discarded); empty → <code>SearchEmpty</code>; error → retry.</li> <li><strong>Repository:</strong> mocked dio; envelope mapping; <code>400/401/403/429</code> → typed <code>ApiException</code> (<code>00-shared/06 §2</code>).</li> <li><strong>Widget:</strong> golden for grouped list, empty, skeleton; a11y semantics labels (<code>07 §2</code>).</li> <li><strong>Integration:</strong> live tenant — type "rahul", expect Student group, tap → detail (<code>14</code> as acceptance list).</li> </ul> <h2 id="9-non-goals-in-v1"><a class="header" href="#9-non-goals-in-v1">9. Non-goals in v1</a></h2> <ul> <li>No local search history/suggestions (<code>(forward-looking)</code>).</li> <li>No QR scan wiring (<code>(forward-looking)</code> — ledger B4).</li> <li>No push deep-link handling (<code>(forward-looking)</code> — ledger B3).</li> <li>No Redis-backed result cache (<code>(proposed)</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-settings-module"><a class="header" href="#01--product-overview-settings-module">01 — Product Overview (Settings Module)</a></h1> <ul> <li><a href="settings/01_Product_Overview.html#1-purpose">1. Purpose</a></li> <li><a href="settings/01_Product_Overview.html#2-business-goals">2. Business goals</a></li> <li><a href="settings/01_Product_Overview.html#3-user-goals">3. User goals</a></li> <li><a href="settings/01_Product_Overview.html#4-stakeholders">4. Stakeholders</a></li> <li><a href="settings/01_Product_Overview.html#5-why-this-exists">5. Why this exists</a></li> <li><a href="settings/01_Product_Overview.html#6-dependencies">6. Dependencies</a></li> <li><a href="settings/01_Product_Overview.html#7-success-metrics">7. Success metrics</a></li> <li><a href="settings/01_Product_Overview.html#8-edge-cases-derived">8. Edge cases (derived)</a></li> <li><a href="settings/01_Product_Overview.html#9-assumptions-module">9. Assumptions (module)</a></li> <li><a href="settings/01_Product_Overview.html#10-open-questions-module-level-global-ledger-in-00-shared12">10. Open questions (module-level; global ledger in 00-shared/12)</a></li> <li><a href="settings/01_Product_Overview.html#11-glossary-this-module">11. Glossary (this module)</a></li> </ul> <blockquote> <p>StudyLyon — multi-tenant ERP / School Management API. This package designs the <strong>Settings module client</strong> (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, and wire contracts are derived directly from <code>src/modules/settings/**</code>, <code>src/modules/organizations/**</code>, <code>src/modules/feature-flags/**</code>, <code>src/modules/rbac/permissions.constants.ts</code>, <code>studylyon-blueprint/04-Modules/Organizations.md</code> and <code>03-Database/COLLECTIONS.md</code>. No feature is invented; gaps are flagged in the <strong>Assumptions & Open Questions</strong> section.</p> </blockquote> <hr /> <h2 id="1-purpose-22"><a class="header" href="#1-purpose-22">1. Purpose</a></h2> <p>Settings is the tenant-scoped <strong>key/value configuration store</strong> of StudyLyon. It gives each organization a flat, arbitrarily-typed setting registry — <code>key → value</code> — grouped into six fixed buckets, read/written through a thin CRUD API.</p> <div class="table-wrapper"><table><thead><tr><th>Responsibility</th><th>Source</th></tr></thead><tbody> <tr><td>List all tenant settings (sorted by group then key)</td><td><code>settings.service.ts:10-12</code></td></tr> <tr><td>List settings of one group (<code>?group=</code> filter)</td><td><code>settings.controller.ts:26-28</code>, <code>settings.service.ts:14-16</code></td></tr> <tr><td>Read a single setting by key</td><td><code>settings.controller.ts:31-35</code>, <code>settings.service.ts:18-22</code></td></tr> <tr><td>Create-or-update a setting (upsert per key)</td><td><code>settings.controller.ts:37-41</code>, <code>settings.service.ts:24-26</code></td></tr> <tr><td>Bulk upsert (sequential per-key)</td><td><code>settings.controller.ts:43-47</code>, <code>settings.service.ts:28-34</code></td></tr> <tr><td>Delete a setting (soft delete)</td><td><code>settings.controller.ts:49-53</code>, <code>settings.service.ts:36-41</code></td></tr> <tr><td>Tenant isolation on every query/upsert</td><td><code>base.repository.ts:20-30</code>, <code>setting.repository.ts:21-40</code></td></tr> </tbody></table> </div> <p><strong>The module is deliberately thin.</strong> 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 <strong>second, separate surface</strong>: the <code>settings</code> object embedded in the <code>organizations</code> document (<code>organization.schema.ts:96-112</code>) with its own endpoints (<code>GET/PATCH /organizations/:id/settings</code>). The blueprint separates them deliberately — <em>"Settings separated from <code>organizations</code> to avoid hot-document writes"</em> (<code>Organizations.md:60</code>), and defines a dedicated <code>organization_settings</code> collection (<code>COLLECTIONS.md:767-783</code>). <strong>Today only the standalone <code>settings</code> collection is implemented in code; the <code>organization_settings</code> collection is a blueprint concept not yet in code.</strong></p> <h2 id="2-business-goals-19"><a class="header" href="#2-business-goals-19">2. Business goals</a></h2> <div class="table-wrapper"><table><thead><tr><th>Goal</th><th>Measure</th></tr></thead><tbody> <tr><td>Any org-level knob in one place</td><td>All settings CRUD via 5 endpoints; groups cover academic, attendance, grading, notification, theme, general (<code>setting.schema.ts:7-14</code>)</td></tr> <tr><td>Zero-conflict multi-tenant isolation</td><td>Unique index <code>{tenantId, key}</code> (<code>setting.schema.ts:38</code>); tenantId injected by repository, never from body (<code>base.repository.ts:33-35</code>)</td></tr> <tr><td>No hot-writes on org document</td><td>Standalone collection + upsert semantics (<code>Organizations.md:60</code>)</td></tr> <tr><td>Config written by machines and admins</td><td>Idempotent <code>PUT</code> upsert; bulk endpoint for batch imports (<code>settings.service.ts:24-34</code>)</td></tr> <tr><td>Delete ≠ data loss</td><td>Soft delete with audit markers (<code>base.repository.ts:68-74</code>)</td></tr> </tbody></table> </div> <h2 id="3-user-goals-16"><a class="header" href="#3-user-goals-16">3. User goals</a></h2> <ul> <li><strong>Org admin:</strong> browse every org setting in one screen, edit any value with the right input type, save one or many, remove stale keys.</li> <li><strong>Setting editor (staff/admin with <code>settings.*</code> perms):</strong> find a key quickly (group filter <ul> <li>client search — the API has <strong>no search param</strong>), edit JSON/numbers/booleans without breaking values.</li> </ul> </li> <li><strong>Platform admin:</strong> cross-tenant visibility for support (platform admin bypasses the tenant scope, <code>base.repository.ts:21-23</code>).</li> <li><strong>Consumers (other modules / future UI):</strong> read settings programmatically via <code>GET /settings/:key</code> or <code>GET /settings?group=</code>.</li> </ul> <h2 id="4-stakeholders-16"><a class="header" href="#4-stakeholders-16">4. Stakeholders</a></h2> <p>Org admins, delegated setting editors, platform support, module developers (each module reads its own settings at runtime), QA/design/engineering. Note: today <strong>no backend consumer reads settings</strong> — nothing in <code>src/</code> imports <code>SettingsService</code> outside the settings module (no cross-module call, per AGENTS.md module-boundary rule); consumption is via API or future events.</p> <h2 id="5-why-this-exists-17"><a class="header" href="#5-why-this-exists-17">5. Why this exists</a></h2> <p>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 <code>organizations</code> document (<code>COLLECTIONS.md:781</code>).</p> <h2 id="6-dependencies-10"><a class="header" href="#6-dependencies-10">6. Dependencies</a></h2> <div class="table-wrapper"><table><thead><tr><th>Dependency</th><th>Role</th><th>Source</th></tr></thead><tbody> <tr><td>Auth (JWT)</td><td>every settings endpoint requires a bearer token</td><td><code>settings.controller.ts:12,19</code>; global guards <code>app.module.ts:129-131</code></td></tr> <tr><td>RBAC</td><td><code>settings.read/update/delete</code> permission constants exist</td><td><code>permissions.constants.ts:75-77</code> — <strong>not enforced</strong> on this controller (OQ-2)</td></tr> <tr><td>TenantContextService</td><td>tenant scoping of all queries</td><td><code>setting.repository.ts:16,34</code>; <code>base.repository.ts:20-30</code></td></tr> <tr><td>Organizations module</td><td>separate embedded <code>settings</code> surface (attendance/academic/theme)</td><td><code>organization.schema.ts:96-112</code>; <code>organizations.controller.ts:56-72</code></td></tr> <tr><td>Feature Flags module</td><td>sibling boolean config surface</td><td><code>feature-flags.controller.ts:23-58</code></td></tr> <tr><td>BaseRepository</td><td>soft-delete + <code>version</code> + audit fields</td><td><code>base.schema.ts:8-35</code>, <code>base.repository.ts:32-74</code></td></tr> <tr><td>Mongo collection</td><td><code>settings</code> (implemented); <code>organization_settings</code> (blueprint only)</td><td><code>setting.schema.ts:16</code>; <code>COLLECTIONS.md:767-783</code></td></tr> </tbody></table> </div> <h2 id="7-success-metrics-14"><a class="header" href="#7-success-metrics-14">7. Success metrics</a></h2> <ul> <li>List load < 300 ms (settings volume per tenant is small — tens to low hundreds of keys).</li> <li>Save round-trip < 500 ms p95; upsert idempotent — re-tap safe.</li> <li>Zero cross-tenant leaks (unique index + scopedFilter; QA-perm).</li> <li>Bulk save of N keys: all-or-nothing perception on UI despite sequential server loop (failure surfaced per item; see OQ-4).</li> </ul> <h2 id="8-edge-cases-derived"><a class="header" href="#8-edge-cases-derived">8. Edge cases (derived)</a></h2> <ul> <li><code>GET /settings/:key</code> missing key → 404 <code>RESOURCE_NOT_FOUND</code> "Setting "key" not found." (<code>settings.service.ts:20</code>).</li> <li><code>PUT /settings</code> missing <code>key</code> or <code>value</code> → class-validator has <strong>no decorators</strong> on them (<code>update-setting.dto.ts:5-10</code>) → Mongoose <code>required</code> validation error → <strong>500</strong> (OQ-3).</li> <li><code>DELETE /settings/:key</code> → soft delete; the unique index <code>{tenantId, key}</code> (<code>setting.schema.ts:38</code>) still holds the soft-deleted doc → re-creating the same key after delete hits a duplicate-key error → 500 (OQ-5).</li> <li><code>label</code>/<code>description</code> are accepted by the DTO (<code>update-setting.dto.ts:17-25</code>) but the service <strong>never persists them</strong> — only key/value/group reach the repo (<code>settings.service.ts:25</code>; <code>setting.repository.ts:37</code>).</li> <li><code>isEncrypted</code> exists on the schema (<code>setting.schema.ts:33-34</code>) but nothing sets it and no encryption path exists (OQ-6).</li> <li>Bulk update runs a <strong>sequential loop</strong>, not a transaction (<code>settings.service.ts:28-34</code>) — a mid-batch failure leaves earlier keys saved.</li> <li>Platform admin <code>GET /settings</code> lists settings of <strong>all tenants</strong> (scope bypass, <code>base.repository.ts:21-23</code>).</li> <li>Values are untyped: <code>value: unknown</code> (<code>update-setting.dto.ts:10</code>) and <code>@Prop({type: Object})</code> (<code>setting.schema.ts:21-22</code>) → the client must infer the editor type from the runtime value.</li> </ul> <h2 id="9-assumptions-module-11"><a class="header" href="#9-assumptions-module-11">9. Assumptions (module)</a></h2> <ul> <li><strong>Mobile client is forward-looking</strong>: backend is complete; this package is the UI-side spec.</li> <li><strong>The settings list is NOT paginated</strong>: <code>findAll</code> returns a bare array (<code>settings.service.ts:10-12</code>) and the envelope interceptor only adds <code>meta</code> for <code>{data, meta}</code> payloads (<code>response-envelope.interceptor.ts:25-32,55-59</code>) — the settings list is a single fetch with <code>meta</code> omitted.</li> <li><strong>Group set is fixed</strong> to the enum: <code>academic, attendance, grading, notification, theme, general</code> (<code>setting.schema.ts:7-14</code>). A <code>COACHING</code> group is <code>(planned)</code> — <code>IMPLEMENTATION_PLAN.md:773</code>.</li> <li><strong>No setting definitions exist</strong>: the UI is data-driven (render whatever keys exist); it cannot show a canonical "every setting" catalog, defaults, or per-key docs.</li> <li>Settings written via API are immediately consistent for subsequent reads (single Mongo doc, no cache layer in the settings path).</li> </ul> <h2 id="10-open-questions-module-level-global-ledger-in-00-shared12-7"><a class="header" href="#10-open-questions-module-level-global-ledger-in-00-shared12-7">10. Open questions (module-level; global ledger in <a href="settings/../00-shared/12_Assumptions_&_Open_Questions.html">00-shared/12</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Item</th><th>Impact</th></tr></thead><tbody> <tr><td>OQ-1</td><td>Which module(s) consume settings at runtime? No consumer exists in <code>src/</code>.</td><td>Copy/help text, "who reads this" metadata</td></tr> <tr><td>OQ-2</td><td><code>settings.read/update/delete</code> (<code>permissions.constants.ts:75-77</code>) are not enforced — the controller has JWT guard only (<code>settings.controller.ts:19</code>) and <strong>no <code>@Permissions</code> metadata</strong>. Any authenticated tenant user can read/edit/delete every setting.</td><td>Role-based UI gating vs server truth; 403 UX</td></tr> <tr><td>OQ-3</td><td><code>PUT /settings</code> with missing <code>key</code>/<code>value</code> → Mongoose required error → 500 instead of 400 (no <code>@IsString</code>/<code>@IsDefined</code> on the DTO).</td><td>Client must always send both; error mapping</td></tr> <tr><td>OQ-4</td><td>Bulk loop is non-transactional (<code>settings.service.ts:28-34</code>) — partial failures possible. Add a transaction or accept per-item results?</td><td>Save-all UX, retry semantics</td></tr> <tr><td>OQ-5</td><td>Soft delete + unique <code>{tenantId, key}</code> index = a deleted key can never be recreated (E11000 → 500). Fix: include <code>isDeleted</code> in index or hard-delete.</td><td>Delete → re-create flow</td></tr> <tr><td>OQ-6</td><td><code>isEncrypted</code> is never written; secrets (SMTP creds etc.) are stored plaintext. Encryption pipeline <code>(planned)</code>?</td><td>Encrypted-badge UI, secret handling</td></tr> <tr><td>OQ-7</td><td>No history/audit of setting changes exposed (audit module exists; no settings events). History screen <code>(planned)</code>.</td><td>"Setting detail history" tab</td></tr> <tr><td>OQ-8</td><td>Which settings keys are canonical? No seed/registry anywhere. Coaching expansion will add groups/keys (<code>IMPLEMENTATION_PLAN.md:773</code>).</td><td>Editor type heuristics, defaults</td></tr> </tbody></table> </div> <h2 id="11-glossary-this-module-11"><a class="header" href="#11-glossary-this-module-11">11. Glossary (this module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Term</th><th>Meaning</th></tr></thead><tbody> <tr><td>Setting</td><td>One <code>settings</code> doc: <code>{tenantId, key, value(any JSON), group, label?, description?, isEncrypted, createdBy/updatedBy, isDeleted, deletedAt?, deletedBy?, version, createdAt, updatedAt}</code> (<code>setting.schema.ts:17-35</code>, <code>base.schema.ts:10-34</code>)</td></tr> <tr><td>Group</td><td>Fixed enum bucket: <code>academic, attendance, grading, notification, theme, general</code></td></tr> <tr><td>Upsert</td><td><code>findOneAndUpdate(..., {upsert: true})</code> — create if missing, else update <code>value</code> (+<code>group</code>) (<code>setting.repository.ts:29-40</code>)</td></tr> <tr><td>Embedded settings</td><td>The <code>settings</code> object on the <code>organizations</code> doc (attendance/academic/theme) — <strong>separate surface</strong></td></tr> <tr><td>Envelope</td><td><code>{success,message,data,meta?,timestamp,requestId}</code> (<code>response-envelope.interceptor.ts:48-59</code>)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-settings-module"><a class="header" href="#02--user-personas-settings-module">02 — User Personas (Settings Module)</a></h1> <ul> <li><a href="settings/02_User_Personas.html#1-org-admin-org_admin-role--flagship-user">1. Org Admin (org_admin role) — flagship user</a></li> <li><a href="settings/02_User_Personas.html#2-setting-editor-delegated-staff--eg-office-manager-ict-coordinator">2. Setting Editor (delegated staff — e.g., office manager, ICT coordinator)</a></li> <li><a href="settings/02_User_Personas.html#3-platform-admin-platform_admin-cross-tenant">3. Platform Admin (platform_admin, cross-tenant)</a></li> <li><a href="settings/02_User_Personas.html#4-config-consumer-machine--other-modules-planned">4. Config Consumer (machine / other modules) <code>(planned)</code></a></li> <li><a href="settings/02_User_Personas.html#5-read-only-staff--teacher-everyone-else">5. Read-Only Staff / Teacher (everyone else)</a></li> <li><a href="settings/02_User_Personas.html#6-role--settings-appearance-matrix">6. Role → settings-appearance matrix</a></li> </ul> <blockquote> <p>Roles, goals, permissions, and expected behavior for every consumer of the Settings module. Permission values are <strong>exact</strong> from <code>src/modules/rbac/permissions.constants.ts</code>. Critical caveat: the settings controller carries <strong>no <code>@Permissions</code> metadata</strong> (<code>settings.controller.ts:19</code> — JWT guard only), so <code>settings.read/update/delete</code> (<code>permissions.constants.ts:75-77</code>) are <em>declared but not enforced</em> today (OQ-2). The UI still gates on them for forward-compatibility; the server is the authority only for authentication, not permission.</p> </blockquote> <hr /> <h2 id="1-org-admin-org_admin-role--flagship-user"><a class="header" href="#1-org-admin-org_admin-role--flagship-user">1. Org Admin (org_admin role) — flagship user</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Frequency</td><td>Daily; config changes weekly (term boundaries, grading, theme)</td></tr> <tr><td>Permissions</td><td>All (role seeds <code>ALL_PERMISSIONS</code>, <code>rbac/schemas/role.schema.ts:23</code> — includes <code>settings.read/update/delete</code>)</td></tr> <tr><td>Goal</td><td>See the whole org config in one place; change values with the right input; batch-save term changes</td></tr> <tr><td>Pain points</td><td>Free-form keys with no description; not knowing a value's type until it breaks; deleted keys that can't be recreated (OQ-5)</td></tr> <tr><td>Core job</td><td>Browse <code>GET /settings</code> (optionally <code>?group=</code>), edit <code>PUT /settings</code>, batch <code>PUT /settings/bulk</code>, remove <code>DELETE /settings/:key</code></td></tr> <tr><td>Expected behavior</td><td>Group tabs; typed editors per value; immediate save feedback; dirty tracking across edits</td></tr> <tr><td>Mental model</td><td>"Settings is my school's control panel."</td></tr> </tbody></table> </div> <h2 id="2-setting-editor-delegated-staff--eg-office-manager-ict-coordinator"><a class="header" href="#2-setting-editor-delegated-staff--eg-office-manager-ict-coordinator">2. Setting Editor (delegated staff — e.g., office manager, ICT coordinator)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Frequency</td><td>Several times/week</td></tr> <tr><td>Permissions</td><td>Custom role with <code>settings.read/update</code> (not <code>settings.delete</code>) — <strong>once server enforcement lands (OQ-2)</strong></td></tr> <tr><td>Goal</td><td>Update attendance thresholds, notification toggles, academic labels without touching org-level or theme</td></tr> <tr><td>Pain points</td><td>Accidental bulk-save of half-edited values; JSON fields they can't parse</td></tr> <tr><td>Expected</td><td>Group-scoped view (attendance only), per-field save, JSON editor with syntax feedback, "saved" snackbar per key</td></tr> <tr><td>Mental model</td><td>"I only touch my group's knobs."</td></tr> </tbody></table> </div> <h2 id="3-platform-admin-platform_admin-cross-tenant"><a class="header" href="#3-platform-admin-platform_admin-cross-tenant">3. Platform Admin (platform_admin, cross-tenant)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Permissions</td><td><code>isPlatformAdmin</code> bypasses the tenant scope entirely (<code>base.repository.ts:21-23</code>; set in <code>jwt-auth.guard.ts:54</code>)</td></tr> <tr><td>Frequency</td><td>On-demand support/diagnostics</td></tr> <tr><td>Goal</td><td>Compare tenant configs; fix a broken setting during support</td></tr> <tr><td>Restrictions</td><td>Platform admin must hold a real tenant JWT; its <code>GET /settings</code> returns <strong>all tenants' settings merged</strong> (no tenant filter) — the UI must show a "cross-tenant view" warning if surfaced</td></tr> <tr><td>Mental model</td><td>"Global view; treat carefully."</td></tr> </tbody></table> </div> <h2 id="4-config-consumer-machine--other-modules-planned"><a class="header" href="#4-config-consumer-machine--other-modules-planned">4. Config Consumer (machine / other modules) <code>(planned)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Access</td><td><code>GET /settings?group=</code> or <code>GET /settings/:key</code> with a service account (API keys, <code>auth</code> module)</td></tr> <tr><td>Goal</td><td>Read org config at runtime (grading scale, working days, theme colors)</td></tr> <tr><td>Status</td><td><strong>No consumer exists in <code>src/</code> today</strong> (OQ-1); settings consumption by other modules is future wiring</td></tr> <tr><td>Note</td><td>Values are untyped — consumers must handle <code>unknown</code> (<code>setting.schema.ts:21-22</code>)</td></tr> </tbody></table> </div> <h2 id="5-read-only-staff--teacher-everyone-else"><a class="header" href="#5-read-only-staff--teacher-everyone-else">5. Read-Only Staff / Teacher (everyone else)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Permissions</td><td>No <code>settings.*</code> perms in seeded roles (<code>rbac/schemas/role.schema.ts:31-42</code>)</td></tr> <tr><td>Behavior</td><td>Never sees the Settings surface in the UI (hidden by permission gating); settings screens are admin-only</td></tr> <tr><td>Caveat</td><td>Because the server does <strong>not</strong> enforce <code>settings.*</code> today, any authenticated user <em>could</em> call the API — UI hiding is the only defense until OQ-2 is resolved</td></tr> </tbody></table> </div> <h2 id="6-role--settings-appearance-matrix"><a class="header" href="#6-role--settings-appearance-matrix">6. Role → settings-appearance matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th>Surface</th><th>org_admin</th><th>setting editor</th><th>platform_admin</th><th>teacher/staff/parent/student</th></tr></thead><tbody> <tr><td><code>/settings</code> section (module)</td><td>✓</td><td>✓ (granted groups)</td><td>✓</td><td>hidden</td></tr> <tr><td>Settings list + group filter</td><td>✓</td><td>✓</td><td>✓ (cross-tenant warning)</td><td>—</td></tr> <tr><td>Edit (PUT)</td><td>✓</td><td>✓ (update perm)</td><td>✓</td><td>—</td></tr> <tr><td>Batch save (PUT /bulk)</td><td>✓</td><td>✓</td><td>✓</td><td>—</td></tr> <tr><td>Delete (DELETE)</td><td>✓</td><td>perms <code>settings.delete</code></td><td>✓</td><td>—</td></tr> <tr><td>Org embedded settings (S4 tabs in Organizations module)</td><td>✓</td><td>—</td><td>✓</td><td>—</td></tr> <tr><td>Feature flags (sibling surface)</td><td>✓</td><td>—</td><td>✓</td><td>—</td></tr> </tbody></table> </div> <p>Server-side truth: authentication only (JWT). Permission gating is <strong>client-side</strong> (route guard reads <code>user.permissions</code>) until <code>@Permissions('settings.*')</code> lands on <code>settings.controller.ts</code> — mirror the pattern of <code>files.controller.ts:30</code> / <code>webhooks.controller.ts:22</code> when that happens.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journeys-settings-module"><a class="header" href="#03--user-journeys-settings-module">03 — User Journeys (Settings Module)</a></h1> <ul> <li><a href="settings/03_User_Journeys.html#1-browse-settings-list--group-filter">1. Browse settings (list + group filter)</a></li> <li><a href="settings/03_User_Journeys.html#2-edit-a-setting-single-upsert">2. Edit a setting (single upsert)</a></li> <li><a href="settings/03_User_Journeys.html#3-batch-save-bulk-update">3. Batch save (bulk update)</a></li> <li><a href="settings/03_User_Journeys.html#4-permission-denied">4. Permission denied</a></li> <li><a href="settings/03_User_Journeys.html#5-delete-a-setting">5. Delete a setting</a></li> <li><a href="settings/03_User_Journeys.html#6-cross-cutting-shared-rules">6. Cross-cutting (shared rules)</a></li> </ul> <blockquote> <p>End-to-end journeys computed from <code>settings.controller.ts</code> + <code>settings.service.ts</code> + <code>setting.repository.ts</code>. Each journey: entry, intent, decision points, system responses, loading, failures, recovery, exit, back navigation, abandonment, timeout, session expiry, permission denial, offline. <code>(planned)</code> / <code>(forward-looking)</code> per global rules.</p> </blockquote> <hr /> <h2 id="1-browse-settings-list--group-filter"><a class="header" href="#1-browse-settings-list--group-filter">1. Browse settings (list + group filter)</a></h2> <pre><code>entry: Settings section → "Settings" (admin), deep link, dashboard quick-link intent: see the whole tenant config, grouped </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>Decision points:</strong> group chip (server filter) vs free search (client filter).</li> <li><strong>Loading:</strong> <code>AppSkeleton(list)</code>; content ≤ 2 s budget (<code>00-shared/10 §1</code>).</li> <li><strong>Failure covers:</strong> 401 → silent refresh → session expiry; 5xx → <code>AppErrorState</code> + retry; offline → last-good cache + <code>AppOfflineBanner</code>.</li> <li><strong>Exit:</strong> row tap → setting detail; FAB none (edits happen in-line/detail).</li> <li><strong>Empty state:</strong> no settings at all → "No settings yet — create one" (<code>AppEmptyState</code>); group empty → "Nothing in this group".</li> <li><strong>Permission denial:</strong> client route guard rejects without <code>settings.read</code> → 403 screen (server does not 403 today — OQ-2).</li> </ul> <h2 id="2-edit-a-setting-single-upsert"><a class="header" href="#2-edit-a-setting-single-upsert">2. Edit a setting (single upsert)</a></h2> <pre><code>entry: settings list row tap → detail → edit intent: change one value safely </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>Save semantics (source):</strong> upsert = <code>findOneAndUpdate({tenantId,key,isDeleted:false}, {$set:{value, group?}}, {upsert:true, new:true})</code> (<code>setting.repository.ts:35-39</code>) — the whole <code>value</code> is <strong>replaced</strong>, never merged. <code>label</code>/<code>description</code> from the DTO are <strong>dropped</strong> by the service (<code>settings.service.ts:25</code>).</li> <li><strong>Idempotency:</strong> PUT is safe to retry; last-write-wins per key.</li> <li><strong>Client validation:</strong> mirrors the DTO — <code>key</code> non-empty string, <code>group</code> ∈ enum (<code>update-setting.dto.ts:6-15</code>); value type-check is client-only (server stores any JSON).</li> <li><strong>Optimistic?</strong> No — value is config; <strong>server-confirm</strong> then reflect (consistent with <code>00-shared/06 §3.5</code> safe-mutation policy: write-once/semantic ops). In-line save on the list is the fast path (see 4).</li> </ul> <h2 id="3-batch-save-bulk-update"><a class="header" href="#3-batch-save-bulk-update">3. Batch save (bulk update)</a></h2> <pre><code>entry: settings list "Edit" mode or multi-select → Save all intent: apply many config changes in one action </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>Server contract:</strong> loop of individual upserts, results returned as array (<code>settings.service.ts:28-34</code>). <strong>Not atomic</strong> — a mid-batch failure returns 5xx with earlier keys already saved (OQ-4).</li> <li><strong>Client countermeasure:</strong> only dirty keys are sent (no wasted writes); on failure show "Saved N of M" with per-key retry — the retry must <strong>re-send the full dirty set</strong> (upsert is idempotent, so re-sending saved keys is harmless).</li> <li><strong>Abandonment:</strong> leaving with dirty keys → "Discard changes?" dialog (client-side only; server has no draft).</li> </ul> <h2 id="4-permission-denied"><a class="header" href="#4-permission-denied">4. Permission denied</a></h2> <pre><code>entry: any settings route/action for a user without settings.* (today: client-side only) </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>Failure:</strong> 403 from server (future) → shared 403 screen; hidden nav entries for users without perms (<code>00-shared/05 §2</code>); inline action 403 → snackbar + hide action (<code>00-shared/06 §5</code>).</li> </ul> <h2 id="5-delete-a-setting"><a class="header" href="#5-delete-a-setting">5. Delete a setting</a></h2> <pre><code>entry: detail screen or row menu → Delete intent: remove a stale key </code></pre> <pre class="mermaid">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) </pre> <h2 id="6-cross-cutting-shared-rules"><a class="header" href="#6-cross-cutting-shared-rules">6. Cross-cutting (shared rules)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Behavior</th></tr></thead><tbody> <tr><td>Timeout</td><td>dio 15 s; retry on network failure (upsert safe)</td></tr> <tr><td>Session expiry</td><td>401 → silent refresh → <code>sessionExpired</code> → login; state preserved where safe</td></tr> <tr><td>Offline</td><td>list from last-good cache + banner; writes blocked with guidance (no offline queue for settings — <code>00-shared/07 §10</code>)</td></tr> <tr><td>Abandonment</td><td>dirty edits dropped on exit with confirm; no server draft</td></tr> <tr><td>Deep links <code>(forward-looking)</code></td><td><code>studylyon://settings</code> → list; <code>studylyon://settings/:key</code> → detail</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-settings-module"><a class="header" href="#04--information-architecture-settings-module">04 — Information Architecture (Settings Module)</a></h1> <ul> <li><a href="settings/04_Information_Architecture.html#1-placement-in-the-app-shell">1. Placement in the app shell</a></li> <li><a href="settings/04_Information_Architecture.html#2-data-model-groups-fixed-enum">2. Data model: groups (fixed enum)</a></li> <li><a href="settings/04_Information_Architecture.html#3-route-table-module-owned-extends-00-shared05-4">3. Route table (module-owned; extends <code>00-shared/05 §4</code>)</a></li> <li><a href="settings/04_Information_Architecture.html#4-screen-hierarchy">4. Screen hierarchy</a></li> <li><a href="settings/04_Information_Architecture.html#5-modal-hierarchy-module-per-00-shared05-5">5. Modal hierarchy (module, per <code>00-shared/05 §5</code>)</a></li> <li><a href="settings/04_Information_Architecture.html#6-screen-relationships--state-handoff">6. Screen relationships & state handoff</a></li> <li><a href="settings/04_Information_Architecture.html#7-cross-surface-embedded-org-settings-context-not-module-owned">7. Cross-surface: embedded org settings (context, not module-owned)</a></li> <li><a href="settings/04_Information_Architecture.html#8-quick-actions--fab--context-menus">8. Quick actions / FAB / context menus</a></li> <li><a href="settings/04_Information_Architecture.html#9-accessibility-of-ia">9. Accessibility of IA</a></li> </ul> <blockquote> <p>Where the Settings module lives in the shell (<a href="settings/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §2</a>: Settings → <code>/settings</code>, role: admin) and how its data surface is organized. Everything derived from <code>setting.schema.ts</code> (groups), <code>settings.controller.ts</code> (API), and the organizations module's separate embedded-settings surface.</p> </blockquote> <hr /> <h2 id="1-placement-in-the-app-shell-1"><a class="header" href="#1-placement-in-the-app-shell-1">1. Placement in the app shell</a></h2> <pre><code>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) </code></pre> <p>The module owns only the key/value <strong>Settings hub</strong>. Sibling surfaces under the same <code>/settings</code> prefix are owned by other modules (see <code>design-docs/rbac/04</code>, <code>design-docs/auth/04</code>).</p> <h2 id="2-data-model-groups-fixed-enum"><a class="header" href="#2-data-model-groups-fixed-enum">2. Data model: groups (fixed enum)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Group</th><th>Enum value</th><th>Typical contents (illustrative; no registry exists)</th><th>Source</th></tr></thead><tbody> <tr><td>Academic</td><td><code>academic</code></td><td>grading scale, passing %, academic labels</td><td><code>setting.schema.ts:8</code></td></tr> <tr><td>Attendance</td><td><code>attendance</code></td><td>grace period, late threshold, working days</td><td><code>setting.schema.ts:9</code></td></tr> <tr><td>Grading</td><td><code>grading</code></td><td>grade bands, rounding</td><td><code>setting.schema.ts:10</code></td></tr> <tr><td>Notification</td><td><code>notification</code></td><td>channel toggles, digest times</td><td><code>setting.schema.ts:11</code></td></tr> <tr><td>Theme</td><td><code>theme</code></td><td>primary/secondary color (parallels org <code>settings.theme</code>)</td><td><code>setting.schema.ts:12</code></td></tr> <tr><td>General</td><td><code>general</code> (default)</td><td>anything ungrouped</td><td><code>setting.schema.ts:13,24-25</code></td></tr> <tr><td><code>COACHING</code></td><td><code>(planned)</code></td><td>batch defaults, test series config, DPP settings</td><td><code>IMPLEMENTATION_PLAN.md:773</code></td></tr> </tbody></table> </div> <p>Server sorts list by <code>group: 1, key: 1</code> (<code>settings.service.ts:11</code>) — the UI renders tabs in enum order (<code>setting.schema.ts:7-14</code> declaration order) and rows alphabetically per group.</p> <h2 id="3-route-table-module-owned-extends-00-shared05-4"><a class="header" href="#3-route-table-module-owned-extends-00-shared05-4">3. Route table (module-owned; extends <code>00-shared/05 §4</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Route</th><th>Screen</th><th>Auth</th><th>Source API</th></tr></thead><tbody> <tr><td><code>/settings</code></td><td>Settings list (hub)</td><td>JWT + <code>settings.read</code> (client)</td><td><code>GET /settings[?group=]</code></td></tr> <tr><td><code>/settings/group/:group</code></td><td>Group-filtered list (deep link)</td><td>same</td><td><code>GET /settings?group=</code></td></tr> <tr><td><code>/settings/:key</code></td><td>Setting detail + edit</td><td>JWT + <code>settings.read/update</code></td><td><code>GET /settings/:key</code>, <code>PUT /settings</code></td></tr> <tr><td><code>/settings/403</code></td><td>shared permission screen</td><td>—</td><td>—</td></tr> </tbody></table> </div> <h2 id="4-screen-hierarchy"><a class="header" href="#4-screen-hierarchy">4. Screen hierarchy</a></h2> <pre class="mermaid">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 </pre> <ul> <li>List is the <strong>hub</strong>; detail is a push (phone) / right pane (tablet+, master-detail ≥840 dp).</li> <li>Editing happens <strong>in place</strong> on the list (inline typed editor per row) or on the detail page — both call the same <code>PUT /settings</code> upsert (see 03 §2, 08).</li> </ul> <h2 id="5-modal-hierarchy-module-per-00-shared05-5"><a class="header" href="#5-modal-hierarchy-module-per-00-shared05-5">5. Modal hierarchy (module, per <code>00-shared/05 §5</code>)</a></h2> <ol> <li><strong>Snackbar/toast</strong> — "Saved", "Setting deleted", "Copied (JSON)".</li> <li><strong>Bottom sheet</strong> — JSON editor (multi-line, needs height), delete confirm for inline rows.</li> <li><strong>Dialog</strong> — delete confirm (destructive), "Discard changes?" on dirty exit, JSON parse error details.</li> <li><strong>Full-screen page</strong> — setting detail (key, group, value, meta).</li> <li><strong>Loader</strong> — row-level saving spinner; never full-screen dual column (list is cached).</li> </ol> <h2 id="6-screen-relationships--state-handoff"><a class="header" href="#6-screen-relationships--state-handoff">6. Screen relationships & state handoff</a></h2> <ul> <li>Detail edits return to the list with the row refreshed from the <strong>server response</strong> (<code>data: Setting</code> from <code>PUT /settings</code>).</li> <li>Group chip state survives navigation via <code>SettingsCubit</code> (selected group held in state, <code>13_State_Management.md</code>).</li> <li>Delete pops detail → list; row removed on server 200 (or 404 treated as removed).</li> </ul> <h2 id="7-cross-surface-embedded-org-settings-context-not-module-owned"><a class="header" href="#7-cross-surface-embedded-org-settings-context-not-module-owned">7. Cross-surface: embedded org settings (context, not module-owned)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Surface</th><th>Location</th><th>Source</th></tr></thead><tbody> <tr><td>Org embedded <code>settings</code> (attendance/academic/theme objects)</td><td><code>/organization</code> → Settings tabs (Organizations module S4)</td><td><code>organization.schema.ts:96-112</code>; <code>organizations.controller.ts:56-72</code></td></tr> <tr><td>Org branding (colors/logo/favicon)</td><td><code>/organization</code> → Branding</td><td><code>organization.schema.ts:114-120</code></td></tr> <tr><td>Feature flags (boolean toggles)</td><td><code>/organization</code> → Feature flags</td><td><code>feature-flags.controller.ts:23-58</code>; <code>feature-flag.schema.ts:9-23</code></td></tr> </tbody></table> </div> <p><strong>Design consequence:</strong> two surfaces can hold overlapping concepts — <code>settings.theme</code> keys in this module vs <code>organization.settings.theme</code> + <code>branding</code> (dual-surface confusion, see <code>design-docs/organizations/09 §</code> — "Which settings apply?" OQ). The Settings hub must carry a visual note: <em>"Org-level config (attendance, theme, working days) also lives under Organization"</em> with a cross-link, until the blueprint's <code>organization_settings</code> collection (<code>COLLECTIONS.md:767-783</code>) consolidates them <code>(planned)</code>.</p> <h2 id="8-quick-actions--fab--context-menus"><a class="header" href="#8-quick-actions--fab--context-menus">8. Quick actions / FAB / context menus</a></h2> <ul> <li><strong>FAB</strong> (list, extended): "New setting" → creates an unsaved key (group default GENERAL).</li> <li><strong>Row menu</strong> (<code>AppMenu</code>): Edit value, Duplicate key (new key copy), Delete.</li> <li><strong>Multi-select</strong> (long-press rows on phone; checkboxes on desktop) → bottom <code>Save all</code> bar.</li> <li><strong>Keyboard (desktop):</strong> <code>Ctrl+F</code> focuses search (client-side); <code>+</code> / <code>N</code> new setting; <code>Esc</code> closes editor/confirm; <code>Ctrl/Cmd+Enter</code> saves from the JSON editor (<code>00-shared/08 §2</code>).</li> </ul> <h2 id="9-accessibility-of-ia-4"><a class="header" href="#9-accessibility-of-ia-4">9. Accessibility of IA</a></h2> <ul> <li>One <code>headlineMedium</code> per screen; group tabs announced as tab list with selected state.</li> <li>Focus order: search → group chips → first row; row edit opens editor with focus in value.</li> <li>Group chips never color-only — label + selected pill (<code>00-shared/09 §9</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-settings-module"><a class="header" href="#05--screen-inventory-settings-module">05 — Screen Inventory (Settings Module)</a></h1> <ul> <li><a href="settings/05_Screen_Inventory.html#legend">Legend</a></li> <li><a href="settings/05_Screen_Inventory.html#1-settings-list-hub--settings">1. Settings List (hub) — <code>/settings</code></a></li> <li><a href="settings/05_Screen_Inventory.html#2-setting-detail--settingskey">2. Setting Detail — <code>/settings/:key</code></a></li> <li><a href="settings/05_Screen_Inventory.html#3-typed-value-editors-per-type--embedded-in-list-row--detail">3. Typed Value Editors (per type — embedded in list row + detail)</a></li> <li><a href="settings/05_Screen_Inventory.html#4-new-setting-fab-flow--sheet">4. New Setting (FAB flow) — sheet</a></li> <li><a href="settings/05_Screen_Inventory.html#5-delete-confirm-dialogsheet">5. Delete Confirm (dialog/sheet)</a></li> <li><a href="settings/05_Screen_Inventory.html#6-history--not-available">6. History — NOT AVAILABLE</a></li> <li><a href="settings/05_Screen_Inventory.html#7-cross-surface-references-not-module-owned">7. Cross-surface references (not module-owned)</a></li> <li><a href="settings/05_Screen_Inventory.html#shared-components-used">Shared components used</a></li> <li><a href="settings/05_Screen_Inventory.html#analytics-events-proposed">Analytics events (proposed)</a></li> <li><a href="settings/05_Screen_Inventory.html#keyboard--landscape--tablet--desktop">Keyboard / landscape / tablet / desktop</a></li> </ul> <blockquote> <p>Every screen of the Settings module, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in <a href="settings/../00-shared/03_Component_Library.html">00-shared/03</a>; module-specific components in <a href="settings/07_Component_Library.html">07_Component_Library.md</a>.</p> </blockquote> <hr /> <h2 id="legend-17"><a class="header" href="#legend-17">Legend</a></h2> <p>States = idle / loading / success / empty / error(offline, 5xx, 404) / disabled / permission. Analytics events follow <code>{module}.{screen}.{action}</code> (proposed; SDK open — <a href="settings/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a>).</p> <hr /> <h2 id="1-settings-list-hub--settings"><a class="header" href="#1-settings-list-hub--settings">1. Settings List (hub) — <code>/settings</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Browse all tenant settings, grouped; edit in place; batch save</td></tr> <tr><td>Entry</td><td>Settings nav destination, deep link, return from detail</td></tr> <tr><td>Exit</td><td>detail push, organization cross-link, 403 (no perm)</td></tr> <tr><td>Source</td><td><code>GET /api/v1/settings</code> (all) — array, <strong>meta omitted</strong> (non-paginated, <code>response-envelope.interceptor.ts:25-32,55-59</code>); <code>GET /settings?group=</code> on chip tap</td></tr> <tr><td>Composition</td><td>AppBar (<code>settings</code> icon + "Settings"), <code>AppSearchBar</code> (client-side filter), group <code>AppChips</code> (enum order, <code>setting.schema.ts:7-14</code>), <code>ListView.builder</code> of <code>SettingRow</code> cards, extended <code>AppFAB</code> "New setting"</td></tr> <tr><td>States</td><td>loading <code>AppSkeleton(list)</code>; empty "No settings yet"; group-empty "Nothing in this group"; error <code>AppErrorState(code, retry)</code>; offline banner + cached rows</td></tr> <tr><td>Row</td><td>key (mono, <code>titleMedium</code>), value preview (type-chipped: str/num/bool/JSON), group badge on GENERAL rows, encrypted badge if <code>isEncrypted</code> (never today — OQ-6)</td></tr> <tr><td>Multi-select</td><td>long-press → selection mode, bottom <code>Save all (N)</code> bar; only dirty rows serialized (03 §3)</td></tr> <tr><td>Permission</td><td><code>settings.read</code> (client guard); <code>settings.update</code> enables editors</td></tr> <tr><td>Analytics</td><td><code>settings.list.view</code>, <code>settings.list.group_tap.{group}</code>, <code>settings.list.search</code>, <code>settings.bulk.save.{n}</code></td></tr> <tr><td>Adaptive</td><td>phone: stacked cards; tablet/desktop: two-pane (list + detail pane) ≥840 dp</td></tr> </tbody></table> </div> <h2 id="2-setting-detail--settingskey"><a class="header" href="#2-setting-detail--settingskey">2. Setting Detail — <code>/settings/:key</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Read/write one setting with meta</td></tr> <tr><td>Entry</td><td>list row tap</td></tr> <tr><td>Exit</td><td>back to list; delete pops</td></tr> <tr><td>Source</td><td><code>GET /settings/:key</code> (<code>settings.controller.ts:31-35</code>) — 404 <code>RESOURCE_NOT_FOUND</code> if key missing (<code>settings.service.ts:20</code>)</td></tr> <tr><td>Composition</td><td>key header (<code>mono</code>), group picker (<code>AppDropdown</code> of enum), typed value editor (see 6 §S2), label/description read-only placeholders (server drops them — <code>settings.service.ts:25</code>), meta footer (created/updated/version/audit ids when present), Delete (menu)</td></tr> <tr><td>States</td><td>loading, loaded, 404 → <code>AppEmptyState</code> "Setting not found" + back</td></tr> <tr><td>Permission</td><td><code>settings.update</code> gates Save; <code>settings.delete</code> gates Delete</td></tr> </tbody></table> </div> <h2 id="3-typed-value-editors-per-type--embedded-in-list-row--detail"><a class="header" href="#3-typed-value-editors-per-type--embedded-in-list-row--detail">3. Typed Value Editors (per type — embedded in list row + detail)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Editor</th><th>Value type</th><th>Notes</th></tr></thead><tbody> <tr><td>String editor</td><td><code>string</code></td><td><code>AppTextField</code>; saved as-is</td></tr> <tr><td>Number editor</td><td><code>number</code></td><td><code>AppTextField</code> numeric + <code>inputFormatters</code>; float-safe (server stores JSON number)</td></tr> <tr><td>Boolean editor</td><td><code>boolean</code></td><td><code>AppSwitch</code> row; <strong>no Save needed if instant-save toggle</strong> (see 06 §S2 decision)</td></tr> <tr><td>JSON editor</td><td>object/array/null</td><td><code>AppJsonEditor</code> (multi-line mono + live parse check); null treated as JSON <code>null</code></td></tr> <tr><td>Unknown</td><td>anything else</td><td>fall back to JSON editor</td></tr> </tbody></table> </div> <p>Type inference: from the runtime JSON type of the fetched <code>value</code> (<code>setting.schema.ts:21-22</code>); string-y numbers stay strings (no coercion — server is byte-transparent).</p> <h2 id="4-new-setting-fab-flow--sheet"><a class="header" href="#4-new-setting-fab-flow--sheet">4. New Setting (FAB flow) — sheet</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Create an arbitrary key</td></tr> <tr><td>Form</td><td><code>key</code> (required, non-empty, trim), <code>group</code> dropdown (default GENERAL), <code>value</code> typed editor (JSON box)</td></tr> <tr><td>Submit</td><td><code>PUT /settings</code> upsert — idempotent; on E11000/500 (recreate-after-delete, OQ-5) → error banner</td></tr> <tr><td>Exit</td><td>success → row appears (server-sorted position)</td></tr> </tbody></table> </div> <h2 id="5-delete-confirm-dialogsheet"><a class="header" href="#5-delete-confirm-dialogsheet">5. Delete Confirm (dialog/sheet)</a></h2> <p>Confirm (destructive) → <code>DELETE /settings/:key</code> → snackbar; 404 → treat as removed. Soft delete server-side (<code>base.repository.ts:68-74</code>) — <strong>no undo</strong> (recreate broken, OQ-5).</p> <h2 id="6-history--not-available"><a class="header" href="#6-history--not-available">6. History — NOT AVAILABLE</a></h2> <div class="table-wrapper"><table><thead><tr><th>Item</th><th>Status</th></tr></thead><tbody> <tr><td>Per-setting change history / audit trail in the UI</td><td><code>(planned)</code> — no settings history endpoint exists (audit module logs events generically; settings emits none). OQ-7.</td></tr> </tbody></table> </div> <h2 id="7-cross-surface-references-not-module-owned"><a class="header" href="#7-cross-surface-references-not-module-owned">7. Cross-surface references (not module-owned)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Owner</th><th>Route</th></tr></thead><tbody> <tr><td>Org embedded settings tabs</td><td>Organizations</td><td><code>/organization</code> (S4)</td></tr> <tr><td>Feature flags list</td><td>Organizations (uses Feature Flags module API)</td><td><code>/organization</code> flags tab</td></tr> <tr><td>Roles/members</td><td>RBAC</td><td><code>/settings/roles</code>, <code>/settings/members</code></td></tr> <tr><td>Security hub</td><td>Auth</td><td><code>/settings/security</code></td></tr> </tbody></table> </div> <h2 id="shared-components-used-21"><a class="header" href="#shared-components-used-21">Shared components used</a></h2> <p><code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppErrorState</code>, <code>AppOfflineBanner</code>, <code>AppSnackbar</code>, <code>AppSearchBar</code>, <code>AppChips</code>, <code>AppDropdown</code>, <code>AppTextField</code>, <code>AppSwitch</code>, <code>AppCard</code>, <code>AppListTile</code>, <code>AppMenu</code>, <code>AppDialog</code>, <code>AppBottomSheet</code>, <code>AppFAB</code>, <code>AppBadge</code>. Module-specific: <code>SettingRow</code>, <code>AppTypedValueEditor</code>, <code>AppJsonEditor</code>, <code>SettingGroupChips</code> (<a href="settings/07_Component_Library.html">07_Component_Library.md</a>).</p> <h2 id="analytics-events-proposed-18"><a class="header" href="#analytics-events-proposed-18">Analytics events (proposed)</a></h2> <p><code>settings.list.{view,group_tap,search}</code>, <code>settings.detail.view</code>, <code>settings.edit.{save,error}</code>, <code>settings.create.{submit,error}</code>, <code>settings.bulk.{save,n,failed_m_of_n}</code>, <code>settings.delete.{confirm,completed}</code>.</p> <h2 id="keyboard--landscape--tablet--desktop"><a class="header" href="#keyboard--landscape--tablet--desktop">Keyboard / landscape / tablet / desktop</a></h2> <ul> <li>Phone: single pane; landscape scrolls; keyboard avoidance on editors.</li> <li>Tablet/desktop: master-detail; <code>Ctrl+F</code> search; hover row highlight; <code>Enter</code> saves editor.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-settings-module"><a class="header" href="#06--screen-specifications-settings-module">06 — Screen Specifications (Settings Module)</a></h1> <ul> <li><a href="settings/06_Screen_Specifications.html#s1--settings-list-settings">S1 — Settings List <code>/settings</code></a></li> <li><a href="settings/06_Screen_Specifications.html#s2--setting-detail--typed-editors-module-core">S2 — Setting Detail / Typed Editors (module core)</a> <ul> <li><a href="settings/06_Screen_Specifications.html#editor-by-value-type-client-inference-05-3">Editor by value type (client inference, <code>05 §3</code>)</a></li> </ul> </li> <li><a href="settings/06_Screen_Specifications.html#s3--new-setting-fab-sheet">S3 — New Setting (FAB sheet)</a></li> <li><a href="settings/06_Screen_Specifications.html#s4--delete-flow">S4 — Delete flow</a></li> <li><a href="settings/06_Screen_Specifications.html#s5--cross-link-banner-list-top-dismissible--context-clarity">S5 — Cross-link banner (list top, dismissible) — context clarity</a></li> <li><a href="settings/06_Screen_Specifications.html#shared-micro-interactions--motion-all-screens">Shared micro-interactions & motion (all screens)</a></li> </ul> <blockquote> <p>Production-grade specs for each Settings screen: chrome, states, components, micro-interactions, a11y, responsive, dark/light, keyboard, motion. Tokens: <code>Sp</code> = <a href="settings/../00-shared/02_Design_Tokens.html">00-shared/02 §3</a>, <code>Ty</code> = typography §2, <code>El</code> = elevation §5, <code>R</code> = radius §4, <code>Mo</code> = motion §6. All components reference <a href="settings/../00-shared/03_Component_Library.html">00-shared/03</a>.</p> </blockquote> <hr /> <h2 id="s1--settings-list-settings"><a class="header" href="#s1--settings-list-settings">S1 — Settings List <code>/settings</code></a></h2> <p><strong>Chrome:</strong> standard shell AppBar (<code>Icons.settings</code>, <code>headlineMedium</code> "Settings"); horizontal padding 16 (phone) / 24 (tablet+); content in <code>ListView.builder</code> (never <code>Column</code> of cards — keys can number in the hundreds).</p> <p><strong>Layout (phone):</strong></p> <pre><code>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 </code></pre> <p><strong>Layout (desktop ≥840 dp):</strong> master-detail — left pane list (max width 480), right pane = detail S2 for the selected row; selection persists in <code>SettingsCubit</code>.</p> <p><strong>States:</strong></p> <ul> <li>loading → <code>AppSkeleton(list)</code> (≤200 ms target).</li> <li>success+empty → <code>AppEmptyState</code> <code>Icons.tune</code> "No settings yet — add your first key."</li> <li>group-empty → inline <code>bodySmall</code> "Nothing in {group} yet."</li> <li>error → <code>AppErrorState(code)</code> (401 → refresh flow; 5xx → generic + requestId + Retry).</li> <li>offline → <code>AppOfflineBanner</code> + last-good rows; editors disabled with tooltip.</li> <li>permission → route guard to <code>/settings/403</code> (no <code>settings.read</code>).</li> </ul> <p><strong>Group chips:</strong> server-filtered (<code>GET /settings?group=</code>) — tapping a chip triggers a <strong>server refetch</strong> (not client filter), because the contract filters server-side (<code>settings.controller.ts:26-28</code>). The search box filters the <strong>current</strong> fetch client-side (no <code>q</code> param exists — <code>00-shared/07 §5</code> applies only where controllers support it).</p> <p><strong>Row (SettingRow):</strong></p> <div class="table-wrapper"><table><thead><tr><th>Element</th><th>Spec</th></tr></thead><tbody> <tr><td>Leading</td><td>group icon map (school/event_available/grading/notifications/palette/settings)</td></tr> <tr><td>Title</td><td>key in <code>mono</code> <code>titleMedium</code> (<code>FontFeature.tabularFigures()</code> for numeric keys)</td></tr> <tr><td>Subtitle</td><td>value preview, truncated 1 line: strings quoted, numbers plain, booleans as <code>true/false</code>, objects <code>{…}</code> + count, arrays <code>[… n]</code></td></tr> <tr><td>Trailing</td><td>type chip (<code>AppBadge</code>: STR / NUM / BOOL / JSON), <code>AppMenu</code> (Edit, Duplicate key, Delete)</td></tr> <tr><td>Tap</td><td>→ detail pane/page</td></tr> <tr><td>Dirty marker</td><td>when edited in place: left <code>primary</code> indicator dot + row tinted <code>primaryContainer</code></td></tr> </tbody></table> </div> <p><strong>Batch mode:</strong> long-press enters selection; <code>AppBottomSheet</code> fixed bottom bar "Save all (N)" — only dirty keys serialized (<code>03 §3</code>). Per-row saved state clears the dot (<code>Mo.m-fast</code> fade).</p> <p><strong>Motion:</strong> list entrance <code>m-entrance</code> stagger 40 ms; chip indicator slide <code>m-base</code>; FAB hide on scroll-down, show on scroll-up (<code>00-shared/03 D</code>).</p> <p><strong>A11y:</strong> search <code>Semantics(liveRegion)</code> for result count ("12 of 48"); chips announce selected state; rows are single <code>Semantics(button)</code> with key label; dirty state announced via <code>Semantics(label: "unsaved")</code>.</p> <p><strong>Dark/light:</strong> token-only; dirty tint <code>primaryContainer</code> on dark = elevated contrast.</p> <hr /> <h2 id="s2--setting-detail--typed-editors-module-core"><a class="header" href="#s2--setting-detail--typed-editors-module-core">S2 — Setting Detail / Typed Editors (module core)</a></h2> <p><strong>Chrome:</strong> detail pane (desktop) or pushed page (phone). Header: key (<code>mono</code> <code>titleLarge</code>), group dropdown, <code>isEncrypted</code> badge (<code>(planned)</code>), meta footer (<code>bodySmall</code>: "created {date} · v{version}" — <code>base.schema.ts:30-34</code>; <code>createdBy/updatedBy</code> when present).</p> <h3 id="editor-by-value-type-client-inference-05-3"><a class="header" href="#editor-by-value-type-client-inference-05-3">Editor by value type (client inference, <code>05 §3</code>)</a></h3> <div class="table-wrapper"><table><thead><tr><th>Type</th><th>Editor</th><th>Save behavior</th><th>Validation</th></tr></thead><tbody> <tr><td><code>string</code></td><td><code>AppTextField</code>, single line, <code>mono</code> for likely IDs/URLs</td><td>explicit Save button (or Enter)</td><td>non-empty unless empty allowed by key; no server rule (<code>update-setting.dto.ts:5-10</code>)</td></tr> <tr><td><code>number</code></td><td><code>AppTextField</code> keyboard <code>numberWithOptions(decimal: true)</code>, formatter <code>^\d*\.?\d*$</code></td><td>explicit Save</td><td>parseable float/int; saved as JSON number (no string coercion)</td></tr> <tr><td><code>boolean</code></td><td><code>AppSwitch</code> row</td><td><strong>instant save on toggle</strong> (safe mutation — <code>00-shared/06 §3.5</code>); server-confirm then reflect; rollback + snackbar on error</td><td>n/a</td></tr> <tr><td><code>object</code> / <code>array</code> / <code>null</code></td><td><code>AppJsonEditor</code> (mono multi-line, min 6 rows, grow)</td><td>explicit Save (disabled while invalid)</td><td>live JSON parse; save blocked on parse error with inline message</td></tr> <tr><td>unknown type</td><td>fall back to JSON editor</td><td>same as JSON</td><td>—</td></tr> </tbody></table> </div> <p><strong>Save semantics (from source):</strong></p> <ul> <li>Full replacement of <code>value</code>: <code>$set: {value}</code> (<code>setting.repository.ts:37</code>) — never merge.</li> <li><code>group</code> is sent with the save and persisted if present (<code>setting.repository.ts:37</code>); omitting it keeps the existing group (only <code>value</code>+<code>group</code> are ever written — the DTO's <code>label</code>/<code>description</code> are dropped by <code>settings.service.ts:25</code>).</li> <li>Upsert idempotent (<code>{upsert:true, new:true}</code>); server returns the saved doc → detail reconciles to server truth.</li> <li><strong>No optimistic write</strong> for explicit-save editors; boolean toggle is optimistic with rollback (<code>00-shared/06 §3.5</code>).</li> </ul> <p><strong>Validation errors (client-first, server shadows):</strong></p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Client rule</th><th>Server</th><th>UI</th></tr></thead><tbody> <tr><td><code>key</code> (create/rename)</td><td>non-empty trimmed string</td><td>no decorators — missing key → Mongoose required → <strong>500</strong> (OQ-3)</td><td>required field error</td></tr> <tr><td><code>group</code></td><td>∈ enum</td><td><code>@IsEnum(SettingGroup)</code> (400 VALIDATION_ERROR)</td><td>dropdown restricts options</td></tr> <tr><td><code>value</code></td><td>any JSON</td><td><code>value: unknown</code> — any JSON accepted</td><td>typed editors</td></tr> <tr><td><code>label</code> / <code>description</code></td><td>—</td><td><code>@IsString()</code> optional</td><td>hidden (unused by service)</td></tr> </tbody></table> </div> <p><strong>Save button:</strong> loading spinner replaces label (<code>Mo.m-base</code>); disabled while pending (anti-double-submit <code>00-shared/08 §6</code>); on success <code>AppSnackbar</code> "Saved" (<3 s) + <code>HapticFeedback.lightImpact()</code>.</p> <p><strong>Error surfaces:</strong> 400 → inline field errors (from <code>details[]</code>, <code>http-exception.filter.ts:103-108</code>); 5xx → snackbar generic + requestId, form kept (retry safe — upsert idempotent).</p> <hr /> <h2 id="s3--new-setting-fab-sheet"><a class="header" href="#s3--new-setting-fab-sheet">S3 — New Setting (FAB sheet)</a></h2> <p><code>AppBottomSheet</code> (full height on phone, centered card ≤520 on desktop):</p> <ul> <li><code>key</code> field (required, autofocus, <code>mono</code> hint "e.g. attendance.lateThreshold").</li> <li>group <code>AppDropdown</code> (enum, default GENERAL — <code>setting.schema.ts:24-25</code>).</li> <li>value: type <strong>unknown</strong> at creation → JSON editor (accepts string/number/bool/object); after first save the row gains its concrete type chip.</li> <li>CTA "Create" → <code>PUT /settings</code>; success → snackbar + list refetch; 500 duplicate-key (recreate-after-delete, OQ-5) → banner explaining the key is soft-deleted.</li> </ul> <hr /> <h2 id="s4--delete-flow"><a class="header" href="#s4--delete-flow">S4 — Delete flow</a></h2> <p><code>AppDialog</code> (destructive): title "Delete {key}?"; body explains soft-delete + that the key cannot be re-created today (OQ-5 warning). Confirm → <code>DELETE /settings/:key</code> (<code>settings.controller.ts:49-53</code>); loading on confirm; success → row fade-out (<code>Mo.m-base</code>) + snackbar; 404 → row removed silently.</p> <hr /> <h2 id="s5--cross-link-banner-list-top-dismissible--context-clarity"><a class="header" href="#s5--cross-link-banner-list-top-dismissible--context-clarity">S5 — Cross-link banner (list top, dismissible) — context clarity</a></h2> <p><code>AppBanner(info)</code>: "Attendance rules, theme and working days also live under Organization" → <code>/organization</code> (embedded settings surface, <code>organization.schema.ts:96-112</code>). One-time dismiss persisted in <code>shared_preferences</code>. Rationale: dual-surface confusion is a known risk (<code>design-docs/organizations/09 §8</code> — "Which settings apply?").</p> <hr /> <h2 id="shared-micro-interactions--motion-all-screens"><a class="header" href="#shared-micro-interactions--motion-all-screens">Shared micro-interactions & motion (all screens)</a></h2> <ul> <li>Route transitions <code>Mo.m-base</code>; AppBar <code>El-1</code>; cards <code>El-e1</code>; sheets/dialogs <code>e-4/e-5</code>.</li> <li>Snackbar durations: success < 3 s, error ≥ 4 s (<code>00-shared/03 A</code>).</li> <li>Keyboard: <code>Enter</code> saves editors, <code>Esc</code> closes sheet/confirm, <code>Ctrl+F</code> search (desktop), <code>Ctrl/Cmd+Enter</code> submits JSON editor (<code>00-shared/08 §2</code>).</li> <li>Safe areas: bottom <code>Save all</code> bar respects nav bar height on phone.</li> <li>Reduced motion: no stagger, no shake — fades/instants only (<code>00-shared/08 §4</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-settings-module"><a class="header" href="#07--component-library-settings-module">07 — Component Library (Settings Module)</a></h1> <ul> <li><a href="settings/07_Component_Library.html#a-setting-row--list-artifacts">A. Setting row & list artifacts</a> <ul> <li><a href="settings/07_Component_Library.html#appsettingrow">AppSettingRow</a></li> <li><a href="settings/07_Component_Library.html#appgroupchips">AppGroupChips</a></li> <li><a href="settings/07_Component_Library.html#appsettingssectionheader">AppSettingsSectionHeader</a></li> </ul> </li> <li><a href="settings/07_Component_Library.html#b-typed-value-editors">B. Typed value editors</a> <ul> <li><a href="settings/07_Component_Library.html#apptypedvalueeditor-dispatcher">AppTypedValueEditor (dispatcher)</a></li> <li><a href="settings/07_Component_Library.html#appjsoneditor">AppJsonEditor</a></li> <li><a href="settings/07_Component_Library.html#appboolrow">AppBoolRow</a></li> </ul> </li> <li><a href="settings/07_Component_Library.html#c-save--feedback">C. Save & feedback</a> <ul> <li><a href="settings/07_Component_Library.html#appsavebar-batch">AppSaveBar (batch)</a></li> <li><a href="settings/07_Component_Library.html#appsettingnotfound">AppSettingNotFound</a></li> </ul> </li> <li><a href="settings/07_Component_Library.html#d-cross-surface-teaser">D. Cross-surface teaser</a> <ul> <li><a href="settings/07_Component_Library.html#apporgsettingslink-banner">AppOrgSettingsLink (banner)</a></li> </ul> </li> <li><a href="settings/07_Component_Library.html#e-flutter-widget-map-module">E. Flutter widget map (module)</a></li> <li><a href="settings/07_Component_Library.html#f-golden-tests-base-00-shared03-g">F. Golden tests (base 00-shared/03 G)</a></li> </ul> <blockquote> <p>Module-specific components built from shared <code>App*</code> primitives (<a href="settings/../00-shared/03_Component_Library.html">00-shared/03</a>). Props, states, animation, a11y, tokens, Flutter mapping. Reuse over new — only components that don't exist in the shared library are defined here.</p> </blockquote> <hr /> <h2 id="a-setting-row--list-artifacts"><a class="header" href="#a-setting-row--list-artifacts">A. Setting row & list artifacts</a></h2> <h3 id="appsettingrow"><a class="header" href="#appsettingrow">AppSettingRow</a></h3> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Spec</th></tr></thead><tbody> <tr><td>Purpose</td><td>One setting in the list (data-driven — renders whatever <code>GET /settings</code> returns)</td></tr> <tr><td>Props</td><td><code>key</code>, <code>value</code> (any), <code>group</code>, <code>dirty</code>, <code>editing</code>, <code>onTap</code>, <code>onMenu</code>, <code>saving</code></td></tr> <tr><td>Type chip</td><td>derived from runtime value: <code>str</code> / <code>num</code> / <code>bool</code> / <code>json</code> (<code>AppBadge</code>)</td></tr> <tr><td>Rendering</td><td><code>AppCard</code> flat (<code>e-0</code>) + <code>AppListTile</code>-style: leading group icon, title <code>mono</code> key, subtitle truncated value preview, trailing type chip + <code>AppMenu</code></td></tr> <tr><td>Dirty</td><td><code>primary</code> left indicator dot + <code>primaryContainer</code> tint, announced via semantics "unsaved"</td></tr> <tr><td>Saving</td><td>trailing <code>CircularProgressIndicator</code> 16 dp replaces chip while <code>PUT</code> in flight</td></tr> <tr><td>a11y</td><td>single <code>Semantics(button, label: "<key>, value <preview>, <type>")</code></td></tr> <tr><td>Flutter</td><td><code>Card</code> + <code>InkWell</code> + <code>Row</code>; icons from group map</td></tr> </tbody></table> </div> <h3 id="appgroupchips"><a class="header" href="#appgroupchips">AppGroupChips</a></h3> <ul> <li>Filter chips for the 6 enum groups + "All" (<code>setting.schema.ts:7-14</code>); server-refetch per chip (<code>GET /settings?group=</code>, <code>settings.controller.ts:26-28</code>).</li> <li>Selected chip <code>secondaryContainer</code>; horizontal scroll on phone (<code>SingleChildScrollView</code>).</li> <li>a11y: <code>Semantics(toggled:)</code> per chip; group order = enum declaration order.</li> </ul> <h3 id="appsettingssectionheader"><a class="header" href="#appsettingssectionheader">AppSettingsSectionHeader</a></h3> <ul> <li><code>AppSectionHeader</code> variant: group name + count from the fetched array ("Attendance (12)"), <code>titleMedium</code>, sticky on desktop pane.</li> </ul> <h2 id="b-typed-value-editors"><a class="header" href="#b-typed-value-editors">B. Typed value editors</a></h2> <h3 id="apptypedvalueeditor-dispatcher"><a class="header" href="#apptypedvalueeditor-dispatcher">AppTypedValueEditor (dispatcher)</a></h3> <ul> <li>Props: <code>value</code> (any), <code>onChanged</code>, <code>onSave</code>, <code>saving</code>, <code>compact</code>.</li> <li>Dispatches by runtime type → <code>AppTextField</code> (string), numeric field (number), <code>AppSwitch</code> row (boolean), <code>AppJsonEditor</code> (object/array/unknown). Compact mode = single line for list rows; expanded = detail pane.</li> </ul> <h3 id="appjsoneditor"><a class="header" href="#appjsoneditor">AppJsonEditor</a></h3> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Spec</th></tr></thead><tbody> <tr><td>Purpose</td><td>Edit object/array/<code>null</code> values with live parse feedback (server stores raw JSON — <code>setting.schema.ts:21-22</code>)</td></tr> <tr><td>Props</td><td><code>value</code>, <code>onChanged</code>, <code>onSave</code>, <code>minLines</code> (6), <code>expands</code></td></tr> <tr><td>Rendering</td><td>multi-line <code>TextFormField</code> <code>mono</code> 14, no syntax highlight (keep dependency-free), grow with content</td></tr> <tr><td>Validation</td><td>live <code>jsonDecode</code>; valid → <code>primary</code> check icon + Save enabled; invalid → <code>error</code> outline + inline "Not valid JSON" (<code>bodySmall</code>) + Save disabled</td></tr> <tr><td>Save</td><td><code>Ctrl/Cmd+Enter</code> (desktop) / Save button; pretty-prints (<code>JsonEncoder.withIndent(' ')</code>) on success</td></tr> <tr><td>a11y</td><td><code>Semantics(error)</code> on parse failure, announced live; label "JSON value"</td></tr> <tr><td>Flutter</td><td><code>TextFormField(maxLines: null, keyboardType: TextInputType.multiline)</code></td></tr> </tbody></table> </div> <h3 id="appboolrow"><a class="header" href="#appboolrow">AppBoolRow</a></h3> <ul> <li><code>AppSwitch</code> in a <code>ListTile</code>; <strong>instant-save</strong> on toggle (optimistic, rollback on error — <a href="settings/../00-shared/06_State_Management.html">00-shared/06 §3.5</a>); <code>saving</code> disables switch; subtitle shows last save status.</li> </ul> <h2 id="c-save--feedback"><a class="header" href="#c-save--feedback">C. Save & feedback</a></h2> <h3 id="appsavebar-batch"><a class="header" href="#appsavebar-batch">AppSaveBar (batch)</a></h3> <ul> <li>Bottom bar in selection mode: "Save all (N)" <code>FilledButton</code> full width + "Cancel" <code>TextButton</code>; counts only dirty rows; on save → per-row status (✓ saved / ✗ failed, tap row retries).</li> <li>a11y: bar announced; result via snackbar "Saved 4 of 5 settings".</li> </ul> <h3 id="appsettingnotfound"><a class="header" href="#appsettingnotfound">AppSettingNotFound</a></h3> <ul> <li><code>AppEmptyState</code> variant for <code>GET /settings/:key</code> 404 (<code>settings.service.ts:20</code>): icon <code>tune</code>, "Setting not found", back button.</li> </ul> <h2 id="d-cross-surface-teaser"><a class="header" href="#d-cross-surface-teaser">D. Cross-surface teaser</a></h2> <h3 id="apporgsettingslink-banner"><a class="header" href="#apporgsettingslink-banner">AppOrgSettingsLink (banner)</a></h3> <ul> <li>Dismissible <code>AppBanner(info)</code> pointing at <code>/organization</code> (embedded settings surface); persisted dismissal key <code>org_settings_link_dismissed</code> (<code>shared_preferences</code>).</li> </ul> <h2 id="e-flutter-widget-map-module"><a class="header" href="#e-flutter-widget-map-module">E. Flutter widget map (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Component</th><th>Flutter widgets</th></tr></thead><tbody> <tr><td>AppSettingRow</td><td><code>Card</code> + <code>InkWell</code> + <code>ListTile</code> + <code>MenuAnchor</code></td></tr> <tr><td>AppGroupChips</td><td><code>FilterChip</code> row in <code>SingleChildScrollView</code></td></tr> <tr><td>AppSettingsSectionHeader</td><td><code>Padding</code> + <code>Row</code> + <code>Text</code></td></tr> <tr><td>AppTypedValueEditor</td><td>switch on runtime type → fields below</td></tr> <tr><td>AppJsonEditor</td><td><code>TextFormField</code> + <code>jsonDecode</code> validator</td></tr> <tr><td>AppBoolRow</td><td><code>SwitchListTile</code></td></tr> <tr><td>AppSaveBar</td><td><code>Material</code> + <code>SafeArea</code> + <code>FilledButton</code></td></tr> <tr><td>AppSettingNotFound</td><td><code>AppEmptyState</code></td></tr> <tr><td>AppOrgSettingsLink</td><td><code>MaterialBanner</code></td></tr> </tbody></table> </div> <h2 id="f-golden-tests-base-00-shared03-g"><a class="header" href="#f-golden-tests-base-00-shared03-g">F. Golden tests (base <a href="settings/../00-shared/03_Component_Library.html">00-shared/03 G</a>)</a></h2> <p>Every module component golden at phone/tablet/desktop + dark; <code>AppJsonEditor</code> valid/invalid states; <code>AppSettingRow</code> clean/dirty/saving; <code>AppSaveBar</code> 0/1/N dirty.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-settings-module"><a class="header" href="#08--form-specifications-settings-module">08 — Form Specifications (Settings Module)</a></h1> <ul> <li><a href="settings/08_Form_Specifications.html#1-upsert-form--put-settings-update-settingdtots">1. Upsert Form — <code>PUT /settings</code> (<code>update-setting.dto.ts</code>)</a></li> <li><a href="settings/08_Form_Specifications.html#2-value-type-specific-editing-core-form-behavior">2. Value-type-specific editing (core form behavior)</a></li> <li><a href="settings/08_Form_Specifications.html#3-new-setting-form-fab-sheet">3. New Setting Form (FAB sheet)</a></li> <li><a href="settings/08_Form_Specifications.html#4-batch-save--put-settingsbulk">4. Batch Save — <code>PUT /settings/bulk</code></a></li> <li><a href="settings/08_Form_Specifications.html#5-delete--no-form">5. Delete — no form</a></li> <li><a href="settings/08_Form_Specifications.html#form-level-rules-all">Form-level rules (all)</a></li> <li><a href="settings/08_Form_Specifications.html#client-side-error-priority">Client-side error priority</a></li> </ul> <blockquote> <p>Every field of every Settings form/editor, field-by-field. Validation mirrors the class-validator decorators <strong>exactly</strong> from <code>src/modules/settings/dto/update-setting.dto.ts</code>; where the DTO has <strong>no decorator</strong> (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 for <code>group</code> enum.</p> </blockquote> <hr /> <h2 id="1-upsert-form--put-settings-update-settingdtots"><a class="header" href="#1-upsert-form--put-settings-update-settingdtots">1. Upsert Form — <code>PUT /settings</code> (<code>update-setting.dto.ts</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Label</th><th>Type / Editor</th><th>Required</th><th>Validation (server)</th><th>Client UX</th></tr></thead><tbody> <tr><td>1</td><td><code>key</code></td><td>Key</td><td>text, <code>mono</code></td><td>yes (TS <code>!</code>)</td><td><strong>no decorators</strong> (<code>update-setting.dto.ts:6-7</code>) — missing/undefined → Mongoose required error → <strong>500</strong> (OQ-3)</td><td>trim; non-empty; pattern hint <code>^[a-z0-9._-]+$</code> (convention, not enforced); error: "Enter a key"</td></tr> <tr><td>2</td><td><code>value</code></td><td>Value</td><td>typed editor by runtime type (string / number / boolean / JSON)</td><td>yes (TS <code>!</code>)</td><td><strong>no decorators</strong> (<code>update-setting.dto.ts:9-10</code>) — any JSON accepted; stored raw (<code>setting.schema.ts:21-22</code>)</td><td>type-specific (see §2); never coerced</td></tr> <tr><td>3</td><td><code>group</code></td><td>Group</td><td><code>AppDropdown</code> (enum)</td><td>no</td><td><code>@IsEnum(SettingGroup)</code> optional (<code>update-setting.dto.ts:12-15</code>) → 400 <code>VALIDATION_ERROR</code> with <code>details</code></td><td>default GENERAL (<code>setting.schema.ts:24-25</code>); dropdown restricts to 6 values</td></tr> <tr><td>4</td><td><code>label</code></td><td>Label</td><td>text</td><td>no</td><td><code>@IsString()</code> optional (<code>update-setting.dto.ts:17-20</code>)</td><td><strong>hidden — never persisted</strong> (<code>settings.service.ts:25</code> drops it)</td></tr> <tr><td>5</td><td><code>description</code></td><td>Description</td><td>text</td><td>no</td><td><code>@IsString()</code> optional (<code>update-setting.dto.ts:22-25</code>)</td><td><strong>hidden — never persisted</strong></td></tr> </tbody></table> </div> <p><strong>Server 400 details mapping:</strong> invalid <code>group</code> → <code>details: [{message: "group must be one of the following values: academic, attendance, grading, notification, theme, general"}]</code> (<code>http-exception.filter.ts:103-108</code>).</p> <p><strong>Submit behavior:</strong> save → <code>PUT /settings</code> → 200 returns the saved doc (<code>setting.repository.ts:35-39</code>, <code>new: true</code>); form reconciles to server truth. Retry-safe (idempotent upsert) — a network retry never duplicates.</p> <h2 id="2-value-type-specific-editing-core-form-behavior"><a class="header" href="#2-value-type-specific-editing-core-form-behavior">2. Value-type-specific editing (core form behavior)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Type</th><th>Editor</th><th>Formatting / formatters</th><th>Local validation</th><th>Server</th></tr></thead><tbody> <tr><td><code>string</code></td><td>single-line <code>AppTextField</code></td><td>none; <code>mono</code> for key-like values</td><td>non-empty (unless the key was created with an empty string — allowed)</td><td>stored as-is</td></tr> <tr><td><code>number</code></td><td>numeric <code>AppTextField</code></td><td><code>FilteringTextInputFormatter.allow(RegExp(r'[0-9.\-]'))</code></td><td><code>double.tryParse != null</code></td><td>stored as JSON number (<code>value: unknown</code> → <code>$set {value}</code> raw, <code>setting.repository.ts:37</code>)</td></tr> <tr><td><code>boolean</code></td><td><code>AppSwitch</code> (<code>SwitchListTile</code>)</td><td>—</td><td>—</td><td>stored as JSON <code>true/false</code>; <strong>instant save</strong> on toggle</td></tr> <tr><td><code>object</code> / <code>array</code> / <code>null</code></td><td><code>AppJsonEditor</code> (mono, multi-line)</td><td>pretty-print on save (<code>JsonEncoder.withIndent(' ')</code>)</td><td><code>jsonDecode</code> must succeed</td><td>stored as raw JSON object</td></tr> <tr><td>unknown</td><td>JSON editor fallback</td><td>same</td><td>same</td><td>—</td></tr> </tbody></table> </div> <p>Rule: <strong>no type coercion on save</strong> — a numeric-looking string stays a string; the client never guesses. Type chips in the list re-derive from the last server response.</p> <h2 id="3-new-setting-form-fab-sheet"><a class="header" href="#3-new-setting-form-fab-sheet">3. New Setting Form (FAB sheet)</a></h2> <p>Fields: <code>key</code> (1), <code>group</code> (3), <code>value</code> as <strong>JSON editor</strong> (type unknown at creation). On success → row appears in its group; group move after creation = edit the group dropdown and save again (upsert persists <code>group</code> when present, <code>setting.repository.ts:37</code>).</p> <h2 id="4-batch-save--put-settingsbulk"><a class="header" href="#4-batch-save--put-settingsbulk">4. Batch Save — <code>PUT /settings/bulk</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Contract</th></tr></thead><tbody> <tr><td>Request</td><td>JSON array of <code>UpdateSettingDto</code> (<code>settings.controller.ts:43-47</code>)</td></tr> <tr><td>Server</td><td>sequential upsert loop, all results returned (<code>settings.service.ts:28-34</code>) — <strong>not transactional</strong> (OQ-4)</td></tr> <tr><td>Client</td><td>only dirty keys serialized; each item = the <strong>full</strong> <code>{key, value, group}</code> (never partial <code>{value}</code> — an omitted group on an existing row is fine, but sending the known group is the stable form)</td></tr> <tr><td>Failure</td><td>"Saved N of M" + per-row retry; re-send the full dirty set (idempotent)</td></tr> <tr><td>Duplicate keys in one batch</td><td>last item wins (loop order) — client dedupes by key before sending</td></tr> </tbody></table> </div> <h2 id="5-delete--no-form"><a class="header" href="#5-delete--no-form">5. Delete — no form</a></h2> <p><code>DELETE /settings/:key</code> (<code>settings.controller.ts:49-53</code>) — 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.</p> <h2 id="form-level-rules-all-5"><a class="header" href="#form-level-rules-all-5">Form-level rules (all)</a></h2> <ul> <li>Double-submit: Save disabled while pending (<a href="settings/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §6</a>).</li> <li>Optimistic: explicit-save editors are <strong>server-confirm</strong>; boolean toggle is the <strong>only</strong> optimistic write (rollback on error — <a href="settings/../00-shared/06_State_Management.html">00-shared/06 §3.5</a>).</li> <li>Undo: none for delete (soft-delete not reversible through the API — OQ-5); value edits undo = re-edit (last-write-wins).</li> <li>Abandonment: dirty explicit editors prompt "Discard changes?"; batch mode Cancel restores last-server values.</li> <li>Keyboard: <code>Enter</code> saves single-line editors, <code>Ctrl/Cmd+Enter</code> saves JSON editor, <code>Tab</code> field order key → group → value; <code>Esc</code> cancels.</li> <li>Error copy: business 4xx <code>message</code> allowed; codes drive the rest (<a href="settings/../00-shared/07_API_Conventions.html">00-shared/07 §11</a>).</li> </ul> <h2 id="client-side-error-priority-4"><a class="header" href="#client-side-error-priority-4">Client-side error priority</a></h2> <ol> <li>400 VALIDATION → field (group enum; JSON parse is client-side pre-submit).</li> <li>401 UNAUTHENTICATED → silent refresh; fail → session expiry.</li> <li>404 RESOURCE_NOT_FOUND (detail/delete) → treat as removed.</li> <li>500 INTERNAL_SERVER_ERROR → generic + requestId; form kept (retry safe).</li> <li>429 RATE_LIMITED → countdown (global <code>api</code> tier, <a href="settings/../00-shared/07_API_Conventions.html">00-shared/07 §4</a>).</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-settings-module"><a class="header" href="#09--user-behaviour-settings-module">09 — User Behaviour (Settings Module)</a></h1> <ul> <li><a href="settings/09_User_Behaviour.html#1-expected-behaviour-server-contract-driven">1. Expected behaviour (server-contract-driven)</a></li> <li><a href="settings/09_User_Behaviour.html#2-common-mistakes--countermeasures">2. Common mistakes & countermeasures</a></li> <li><a href="settings/09_User_Behaviour.html#3-power--new-users">3. Power & new users</a></li> <li><a href="settings/09_User_Behaviour.html#4-mobile--a11y-users">4. Mobile & a11y users</a></li> <li><a href="settings/09_User_Behaviour.html#5-interrupted-sessions--retries">5. Interrupted sessions & retries</a></li> <li><a href="settings/09_User_Behaviour.html#6-abandonment">6. Abandonment</a></li> <li><a href="settings/09_User_Behaviour.html#7-frustration-points--ux-treatments-summary">7. Frustration points → UX treatments (summary)</a></li> <li><a href="settings/09_User_Behaviour.html#8-cross-device--conflict">8. Cross-device & conflict</a></li> <li><a href="settings/09_User_Behaviour.html#9-adoption-path-holistic">9. Adoption path (holistic)</a></li> </ul> <blockquote> <p>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 <em>user</em> does so the flow never surprises.</p> </blockquote> <hr /> <h2 id="1-expected-behaviour-server-contract-driven-7"><a class="header" href="#1-expected-behaviour-server-contract-driven-7">1. Expected behaviour (server-contract-driven)</a></h2> <ul> <li><strong>List is always server-sorted</strong>: <code>{group: 1, key: 1}</code> (<code>settings.service.ts:11</code>) — the UI must never re-sort into a different order than the API returns; grouping is presentational.</li> <li><strong>Group chip = server filter</strong>: tapping "Attendance" refetches (<code>settings.controller.ts:26-28</code>) — users expect the list to shrink server-authoritatively.</li> <li><strong>Save = full value replacement</strong> (<code>setting.repository.ts:37</code>): editing an object and forgetting a key inside it <strong>loses that nested key</strong>. UI: JSON editor starts from the current serialized value; never from an empty box.</li> <li><strong>Boolean toggles save instantly</strong>; text/number/JSON need an explicit save — users learn the pattern from the chip (BOOL rows show no Save button).</li> <li><strong>Delete is final-looking</strong>: soft delete means the row disappears but the key is burned (recreate → 500, OQ-5) — the confirm dialog must say "cannot be re-created".</li> <li><strong>Permissions are client-only today</strong> (OQ-2): a user without <code>settings.*</code> sees nothing (guard-hidden), but the server would serve them if called directly.</li> </ul> <h2 id="2-common-mistakes--countermeasures-7"><a class="header" href="#2-common-mistakes--countermeasures-7">2. Common mistakes & countermeasures</a></h2> <div class="table-wrapper"><table><thead><tr><th>Mistake</th><th>Related</th><th>UX countermeasure</th></tr></thead><tbody> <tr><td>Editing an object and losing nested fields</td><td>full-replace <code>$set {value}</code> (<code>setting.repository.ts:37</code>)</td><td>JSON editor always prefilled from server value; never blank; "replace whole value" helper text</td></tr> <tr><td>Not knowing a value's type until it breaks</td><td><code>value: unknown</code> (<code>update-setting.dto.ts:10</code>)</td><td>type chip on every row; typed editors per value (<a href="settings/06_Screen_Specifications.html">06 §S2</a>)</td></tr> <tr><td>Batch save with unrelated edits mixed in</td><td>sequential loop (<code>settings.service.ts:28-34</code>)</td><td>dirty-dot per row; "Save all (N)" counts only dirty; per-row retry on partial failure</td></tr> <tr><td>Typing "5" expecting number, saved as string</td><td>no coercion server-side</td><td>number editor for <code>num</code> values; string stays string — chip shows type so it's discoverable</td></tr> <tr><td>Searching for a group word in search box</td><td>no server <code>q</code> param</td><td>group chips do the filtering; search box says "search keys" in hint</td></tr> <tr><td>Deleting then recreating a key</td><td>unique index <code>{tenantId,key}</code> (<code>setting.schema.ts:38</code>) + soft delete</td><td>confirm copy warns; error banner on E11000 explains soft-delete state (OQ-5)</td></tr> <tr><td>Editing org settings in the wrong surface</td><td>dual surface (<code>organization.schema.ts:96-112</code> vs settings collection)</td><td><code>AppOrgSettingsLink</code> banner → /organization (06 §S5)</td></tr> </tbody></table> </div> <h2 id="3-power--new-users-7"><a class="header" href="#3-power--new-users-7">3. Power & new users</a></h2> <ul> <li><strong>Power user (admin):</strong> keyboard-first on desktop — <code>Ctrl+F</code> search, <code>Enter</code> save, <code>N</code> new setting; batch-selects a group and edits in one pass; uses group chips to narrow.</li> <li><strong>New user (first-time admin):</strong> lands on the list; the <code>AppOrgSettingsLink</code> banner is the first thing they see; empty state teaches "add your first key" with the FAB.</li> </ul> <h2 id="4-mobile--a11y-users-7"><a class="header" href="#4-mobile--a11y-users-7">4. Mobile & a11y users</a></h2> <ul> <li><strong>One-thumb phone:</strong> FAB reachable; row menus on tap (not long-press-first); editors keyboard-avoidance.</li> <li><strong>TalkBack/VoiceOver:</strong> rows announced with key + type + value; dirty state announced; JSON parse errors announced on focus.</li> <li><strong>Dynamic type 200%:</strong> key <code>mono</code> wraps (ellipsis only on preview subtitle — never on the key itself).</li> <li><strong>Reduced motion:</strong> no stagger/shake; fades only.</li> </ul> <h2 id="5-interrupted-sessions--retries-4"><a class="header" href="#5-interrupted-sessions--retries-4">5. Interrupted sessions & retries</a></h2> <ul> <li><strong>Suspend/resume:</strong> list cache re-shown (stale-while-revalidate); editors restore last server value (never partial draft on relaunch — drafts are in-memory only).</li> <li><strong>Mid-save interruption (app kill):</strong> upsert is idempotent — on next open the value is whatever the server has; the row reconciles from a refetch.</li> <li><strong>Retry after 5xx:</strong> form kept, "Retry" snackbar action re-submits the same payload.</li> <li><strong>429:</strong> countdown; no auto-retry (<a href="settings/../00-shared/07_API_Conventions.html">00-shared/07 §4</a>).</li> </ul> <h2 id="6-abandonment-4"><a class="header" href="#6-abandonment-4">6. Abandonment</a></h2> <ul> <li>Where people quit and what to do: <ul> <li>Dirty explicit editor + back → "Discard changes?" dialog.</li> <li>Batch mode + back → Cancel restores last-server values (dirty dots clear).</li> <li>New-setting sheet half-filled → close = discard (no draft).</li> </ul> </li> <li>No server drafts exist — everything in-memory is forfeit on close; the list itself is safe to re-fetch.</li> </ul> <h2 id="7-frustration-points--ux-treatments-summary-3"><a class="header" href="#7-frustration-points--ux-treatments-summary-3">7. Frustration points → UX treatments (summary)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Frustration</th><th>Treatment</th></tr></thead><tbody> <tr><td>"Which surface owns attendance?"</td><td>cross-link banner (S5) + group copy</td></tr> <tr><td>"My JSON got mangled"</td><td>pretty-print on save; parse-error blocked before send; never blank prefill</td></tr> <tr><td>"It saved half my batch"</td><td>per-row status in <code>AppSaveBar</code>, retry per row (03 §3)</td></tr> <tr><td>"Key disappeared after delete"</td><td>warning copy + E11000 banner (OQ-5)</td></tr> <tr><td>"I can't find the key"</td><td>client search + group chips + server-sorted groups</td></tr> </tbody></table> </div> <h2 id="8-cross-device--conflict-3"><a class="header" href="#8-cross-device--conflict-3">8. Cross-device & conflict</a></h2> <ul> <li>Two admins editing the same key: <strong>last write wins</strong> (no optimistic-lock check on the upsert path — <code>setting.repository.ts:35-39</code>; <code>version</code> exists on the schema (<code>base.schema.ts:30-31</code>) 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).</li> <li>Concurrent batch saves on different keys: both succeed (per-key docs).</li> </ul> <h2 id="9-adoption-path-holistic-4"><a class="header" href="#9-adoption-path-holistic-4">9. Adoption path (holistic)</a></h2> <p>P0: list + group filter + single save + delete. P1: batch save, JSON editor polish, client search. P2 <code>(planned)</code>: settings history (OQ-7), encrypted values (OQ-6), settings registry with defaults, permission enforcement wiring (OQ-2), <code>COACHING</code> group (<code>IMPLEMENTATION_PLAN.md:773</code>).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-settings-module"><a class="header" href="#10--interaction-specification-settings-module">10 — Interaction Specification (Settings Module)</a></h1> <ul> <li><a href="settings/10_Interaction_Specification.html#1-phone--touch">1. Phone / touch</a></li> <li><a href="settings/10_Interaction_Specification.html#2-keyboard--desktop--mouse">2. Keyboard / desktop / mouse</a></li> <li><a href="settings/10_Interaction_Specification.html#3-haptics-mobile">3. Haptics (mobile)</a></li> <li><a href="settings/10_Interaction_Specification.html#4-motion-language-module-extension-of-00-shared08-4">4. Motion language (module extension of 00-shared/08 §4)</a></li> <li><a href="settings/10_Interaction_Specification.html#5-micro-interactions-catalogue">5. Micro-interactions catalogue</a></li> <li><a href="settings/10_Interaction_Specification.html#6-frustration-controls-shared-08-6--module">6. Frustration controls (shared 08 §6 + module)</a></li> <li><a href="settings/10_Interaction_Specification.html#7-special-interactions">7. Special interactions</a></li> <li><a href="settings/10_Interaction_Specification.html#8-validation-of-interaction-to-server-semantics">8. Validation of interaction to server semantics</a></li> </ul> <blockquote> <p>Module-specific interactions precise for the settings list, typed editors and batch save, layered on shared <a href="settings/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>. Covers every input modality on phone/tablet/desktop.</p> </blockquote> <hr /> <h2 id="1-phone--touch-7"><a class="header" href="#1-phone--touch-7">1. Phone / touch</a></h2> <div class="table-wrapper"><table><thead><tr><th>Gesture</th><th>Behaviour</th><th>Where</th></tr></thead><tbody> <tr><td>Tap</td><td>open row, open editor, toggle switch, chip filter, FAB new</td><td>all</td></tr> <tr><td>Tap row menu</td><td><code>AppMenu</code> (Edit value, Duplicate key, Delete)</td><td>rows</td></tr> <tr><td>Long-press</td><td>enter batch selection mode (multi-select)</td><td>rows</td></tr> <tr><td>Double-tap</td><td>none (no media)</td><td>—</td></tr> <tr><td>Swipe left</td><td>none — destructive swipe disabled (delete needs confirm)</td><td>—</td></tr> <tr><td>Pull down</td><td><code>RefreshIndicator</code> → refetch current group (bypasses cache)</td><td>list</td></tr> <tr><td>Pinch/drag</td><td>none</td><td>—</td></tr> <tr><td>Hardware back</td><td>close editor (discard prompt if dirty) → list; on root → exit</td><td>all</td></tr> <tr><td>Edge swipe</td><td>shell drawer (authed)</td><td>shell</td></tr> </tbody></table> </div> <h2 id="2-keyboard--desktop--mouse-7"><a class="header" href="#2-keyboard--desktop--mouse-7">2. Keyboard / desktop / mouse</a></h2> <div class="table-wrapper"><table><thead><tr><th>Input</th><th>Behaviour</th></tr></thead><tbody> <tr><td>Tab</td><td>search → chips → rows → FAB (desktop pane: then detail fields key→group→value)</td></tr> <tr><td>Enter</td><td>save active editor (single-line); submit Create sheet</td></tr> <tr><td>Esc</td><td>close editor/sheet/dialog → focus returns to trigger; cancels selection mode</td></tr> <tr><td>Ctrl/Cmd+F</td><td>focus client search (<a href="settings/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §2</a>)</td></tr> <tr><td>Ctrl/Cmd+Enter</td><td>save JSON editor</td></tr> <tr><td><code>+</code> / <code>N</code></td><td>new setting (list screen, desktop)</td></tr> <tr><td>Arrows</td><td>navigate group chips (focused chip), dropdown options</td></tr> <tr><td>Right-click</td><td>context menu (same as long-press: row menu, multi-select toggle)</td></tr> <tr><td>Hover</td><td>row tint <code>m-fast</code>, tooltip after 500 ms (key copy button, type chip)</td></tr> <tr><td>Focus</td><td>visible <code>primary</code> ring on all controls; logical order</td></tr> </tbody></table> </div> <h2 id="3-haptics-mobile-9"><a class="header" href="#3-haptics-mobile-9">3. Haptics (mobile)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Haptic</th><th>Ref</th></tr></thead><tbody> <tr><td>Save success</td><td><code>lightImpact</code></td><td>—</td></tr> <tr><td>Save/batch failure</td><td><code>mediumImpact</code></td><td>—</td></tr> <tr><td>Boolean toggle applied</td><td><code>selectionClick</code></td><td>—</td></tr> <tr><td>Delete confirm</td><td><code>heavyImpact</code></td><td>—</td></tr> <tr><td>Rule</td><td>disabled when system haptics off; none on desktop</td><td><a href="settings/08_Form_Specifications.html">08 §3</a></td></tr> </tbody></table> </div> <h2 id="4-motion-language-module-extension-of-00-shared08-4-1"><a class="header" href="#4-motion-language-module-extension-of-00-shared08-4-1">4. Motion language (module extension of <a href="settings/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §4</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Transition</th><th>Duration/curve</th><th>Used by</th></tr></thead><tbody> <tr><td>Route push (list→detail)</td><td><code>m-base</code> slide</td><td>phone</td></tr> <tr><td>Detail pane swap (master-detail)</td><td><code>m-base</code> fade</td><td>tablet/desktop</td></tr> <tr><td>Row saved → dirty dot clears</td><td><code>m-fast</code> fade</td><td>list</td></tr> <tr><td>Row removed (delete)</td><td><code>m-base</code> fade-out</td><td>list</td></tr> <tr><td>Batch bar appears</td><td><code>m-entrance</code> (300 ms, easeOutBack)</td><td>list</td></tr> <tr><td>JSON editor error</td><td><code>m-fast</code> outline color swap + <code>AnimatedSwitcher</code> message (no shake)</td><td>editors</td></tr> <tr><td>FAB hide/show on scroll</td><td><code>m-entrance</code>/fade, scroll-dir driven</td><td>list</td></tr> </tbody></table> </div> <p>Reduced-motion: fades + instant only; no stagger, no entrance bounce (<a href="settings/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §4</a>).</p> <h2 id="5-micro-interactions-catalogue-5"><a class="header" href="#5-micro-interactions-catalogue-5">5. Micro-interactions catalogue</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Detail</th></tr></thead><tbody> <tr><td>Save pressed</td><td>button loading spinner swap <code>m-base</code>; double-tap locked; on success check + snackbar</td></tr> <tr><td>Boolean toggle</td><td>switch animates <code>m-fast</code>, optimistic; failure → rollback + error snackbar</td></tr> <tr><td>Group chip tap</td><td>chip indicator slide <code>m-base</code>; list refetches (server filter)</td></tr> <tr><td>Dirty edit</td><td>row dot <code>primary</code> pops in <code>m-fast</code>; Save bar count updates</td></tr> <tr><td>Batch save completes</td><td>per-row ✓/✗ (<code>m-fast</code>); snackbar "Saved N of M"</td></tr> <tr><td>Type chip tap</td><td>tooltip "value type: number" (desktop) / no-op (phone)</td></tr> <tr><td>Key copy (detail)</td><td><code>Clipboard.setData</code> + snackbar "Copied" + <code>selectionClick</code></td></tr> </tbody></table> </div> <h2 id="6-frustration-controls-shared-08-6--module-6"><a class="header" href="#6-frustration-controls-shared-08-6--module-6">6. Frustration controls (shared <a href="settings/08_Form_Specifications.html">08 §6</a> + module)</a></h2> <ul> <li>All presses respond < 100 ms with <code>m-fast</code> ripple.</li> <li>Every network action shows pending state (row spinner, button spinner).</li> <li>Save buttons disabled while pending — no double-submit.</li> <li>429 → never auto-retry; countdown surfaced.</li> <li>Errors persist until corrected/dismissed (banner or inline field error).</li> </ul> <h2 id="7-special-interactions-1"><a class="header" href="#7-special-interactions-1">7. Special interactions</a></h2> <ul> <li><strong>Batch retry:</strong> tap a failed row's ✓/✗ chip → re-sends the full dirty set for that key (idempotent upsert, <a href="settings/08_Form_Specifications.html">08 §4</a>).</li> <li><strong>JSON paste:</strong> editor accepts multi-line paste; parse check runs debounced (300 ms); invalid → Save disabled until fixed.</li> <li><strong>Deep links <code>(forward-looking)</code>:</strong> <code>studylyon://settings/group/:group</code> pre-selects chip; <code>studylyon://settings/:key</code> opens detail directly.</li> </ul> <h2 id="8-validation-of-interaction-to-server-semantics-7"><a class="header" href="#8-validation-of-interaction-to-server-semantics-7">8. Validation of interaction to server semantics</a></h2> <ul> <li>"Save all" only ever sends <strong>dirty</strong> keys, each as full <code>{key, value, group}</code> — matches <code>PUT /settings/bulk</code> (<code>settings.controller.ts:43-47</code>).</li> <li>No optimistic saves for explicit editors (server truth returned in <code>data</code>, <code>setting.repository.ts:39</code>).</li> <li>Delete never offers undo (soft-delete is irreversible through the API — OQ-5).</li> <li>Group chips always refetch server-side — never client-filter the full list, because the API contract filters on the server (<code>settings.controller.ts:26-28</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-settings-module"><a class="header" href="#11--design-system-mapping-settings-module">11 — Design System Mapping (Settings Module)</a></h1> <ul> <li><a href="settings/11_Design_System_Mapping.html#1-theme-recipes-module">1. Theme recipes (module)</a></li> <li><a href="settings/11_Design_System_Mapping.html#2-widget--token">2. Widget → token</a></li> <li><a href="settings/11_Design_System_Mapping.html#3-typography-mapping">3. Typography mapping</a></li> <li><a href="settings/11_Design_System_Mapping.html#4-iconography">4. Iconography</a></li> <li><a href="settings/11_Design_System_Mapping.html#5-motion-tokens-values-00-shared02-6">5. Motion tokens (values 00-shared/02 §6)</a></li> <li><a href="settings/11_Design_System_Mapping.html#6-contrast--dark-parity">6. Contrast & dark parity</a></li> <li><a href="settings/11_Design_System_Mapping.html#7-breakpoint-behaviour">7. Breakpoint behaviour</a></li> <li><a href="settings/11_Design_System_Mapping.html#8-cross-surface-brand-override">8. Cross-surface brand override</a></li> </ul> <blockquote> <p>Settings module → Material 3 / Flutter / token mapping, extending <a href="settings/../00-shared/04_Design_System_Mapping.html">00-shared/04</a>. Every color, type, spacing, elevation, radius, icon, motion reference traces to <a href="settings/../00-shared/02_Design_Tokens.html">00-shared/02_Design_Tokens.md</a>.</p> </blockquote> <hr /> <h2 id="1-theme-recipes-module-7"><a class="header" href="#1-theme-recipes-module-7">1. Theme recipes (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screens</th><th>Theme</th><th>Exception</th></tr></thead><tbody> <tr><td>Settings list (hub)</td><td>standard shell (<code>surface</code>)</td><td>dirty rows <code>primaryContainer</code> tint; type chips <code>surfaceVariant</code></td></tr> <tr><td>Setting detail/editors</td><td>standard shell</td><td>JSON editor <code>surfaceVariant</code> fill; error <code>error</code> outline</td></tr> <tr><td>Batch mode</td><td>standard shell</td><td>bottom bar <code>surfaceContainerHigh</code>, <code>e-3</code></td></tr> <tr><td>Cross-link banner</td><td><code>AppBanner(info)</code></td><td><code>primaryContainer</code></td></tr> <tr><td>Empty/not-found states</td><td><code>surface</code></td><td><code>AppEmptyState</code> tokens</td></tr> </tbody></table> </div> <h2 id="2-widget--token-7"><a class="header" href="#2-widget--token-7">2. Widget → token</a></h2> <div class="table-wrapper"><table><thead><tr><th>Component</th><th>M3 / Flutter</th><th>Tokens</th><th>Elev</th><th>Radius</th></tr></thead><tbody> <tr><td>Setting row card</td><td><code>AppCard</code> flat</td><td><code>surfaceContainerLow</code></td><td><code>e-0</code></td><td><code>r-md</code></td></tr> <tr><td>Dirty row</td><td><code>AppCard</code> tonal</td><td><code>primaryContainer</code> / <code>onPrimaryContainer</code></td><td><code>e-0</code></td><td><code>r-md</code></td></tr> <tr><td>Type chip</td><td><code>AppBadge</code></td><td><code>surfaceVariant</code> / <code>onSurfaceVariant</code></td><td>—</td><td><code>r-sm</code></td></tr> <tr><td>Group chips</td><td><code>FilterChip</code></td><td>selected <code>secondaryContainer</code>, unselected <code>surfaceVariant</code></td><td>—</td><td><code>r-sm</code></td></tr> <tr><td>Key text</td><td><code>Text</code></td><td><code>mono</code>, <code>titleMedium</code></td><td>—</td><td>—</td></tr> <tr><td>Value preview</td><td><code>Text</code></td><td><code>bodyMedium</code> <code>onSurfaceVariant</code>, 1-line ellipsis</td><td>—</td><td>—</td></tr> <tr><td>Editors (string/number)</td><td><code>AppTextField</code></td><td>fill <code>surfaceVariant</code>, border <code>outline</code>, focus <code>primary</code>, error <code>error</code></td><td>—</td><td><code>r-md</code></td></tr> <tr><td>JSON editor</td><td><code>TextFormField</code> mono</td><td>same; valid <code>primary</code> check, invalid <code>error</code></td><td>—</td><td><code>r-md</code></td></tr> <tr><td>Boolean row</td><td><code>SwitchListTile</code></td><td>switch <code>primary</code></td><td>—</td><td>—</td></tr> <tr><td>Save button</td><td><code>FilledButton</code></td><td><code>primary</code>/<code>onPrimary</code></td><td>—</td><td><code>r-md</code></td></tr> <tr><td>Batch bar</td><td><code>Material</code> + <code>FilledButton</code></td><td><code>surfaceContainerHigh</code>, <code>e-3</code></td><td><code>e-3</code></td><td>—</td></tr> <tr><td>Delete confirm</td><td><code>AppDialog</code></td><td><code>surfaceContainerHigh</code>, destructive <code>error</code></td><td><code>e-4</code></td><td><code>r-lg</code></td></tr> <tr><td>FAB (new setting)</td><td><code>FloatingActionButton.extended</code></td><td><code>primaryContainer</code></td><td><code>e-3</code></td><td><code>r-full</code></td></tr> <tr><td>Cross-link banner</td><td><code>MaterialBanner</code></td><td><code>primaryContainer</code></td><td><code>e-1</code></td><td>—</td></tr> </tbody></table> </div> <h2 id="3-typography-mapping-9"><a class="header" href="#3-typography-mapping-9">3. Typography mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Token</th><th>Screen element</th></tr></thead><tbody> <tr><td><code>headlineMedium</code></td><td>"Settings" screen title</td></tr> <tr><td><code>titleLarge</code></td><td>detail key header, dialog titles</td></tr> <tr><td><code>titleMedium</code></td><td>section headers ("Attendance (12)"), row keys (mono)</td></tr> <tr><td><code>bodyMedium</code></td><td>value preview, labels, helper text</td></tr> <tr><td><code>bodySmall</code></td><td>meta footer (created/version), "Nothing in this group", JSON error line</td></tr> <tr><td><code>labelLarge</code></td><td>buttons, group chip labels</td></tr> <tr><td><code>mono</code></td><td>keys, JSON editor, numeric values</td></tr> </tbody></table> </div> <p>Numerals (versions, counts) use <code>FontFeature.tabularFigures()</code> (<a href="settings/../00-shared/02_Design_Tokens.html">00-shared/02 §2</a>).</p> <h2 id="4-iconography-12"><a class="header" href="#4-iconography-12">4. Iconography</a></h2> <p>Material Symbols outlined; module map: <code>settings</code>, <code>tune</code>, <code>school</code> (academic), <code>event_available</code> (attendance), <code>grading</code>, <code>notifications</code> (notification), <code>palette</code> (theme), <code>manage_search</code> (general), <code>add</code>, <code>content_copy</code>, <code>delete</code>, <code>check_circle</code>, <code>error</code>, <code>chevron_right</code>, <code>data_object</code> (JSON).</p> <p>Group → icon map (leading icon per group):</p> <div class="table-wrapper"><table><thead><tr><th>Group</th><th>Icon</th></tr></thead><tbody> <tr><td>academic</td><td><code>school</code></td></tr> <tr><td>attendance</td><td><code>event_available</code></td></tr> <tr><td>grading</td><td><code>grading</code></td></tr> <tr><td>notification</td><td><code>notifications</code></td></tr> <tr><td>theme</td><td><code>palette</code></td></tr> <tr><td>general</td><td><code>manage_search</code></td></tr> </tbody></table> </div> <h2 id="5-motion-tokens-values-00-shared02-6-7"><a class="header" href="#5-motion-tokens-values-00-shared02-6-7">5. Motion tokens (values <a href="settings/../00-shared/02_Design_Tokens.html">00-shared/02 §6</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Purpose</th><th>Token</th><th>Applied to</th></tr></thead><tbody> <tr><td>Save → result</td><td><code>m-base</code></td><td>snackbar/check swap</td></tr> <tr><td>Dirty dot pop / clear</td><td><code>m-fast</code></td><td>row indicator</td></tr> <tr><td>Row delete exit</td><td><code>m-base</code></td><td>fade-out</td></tr> <tr><td>Batch bar entrance</td><td><code>m-entrance</code></td><td>bottom bar</td></tr> <tr><td>Chip indicator</td><td><code>m-base</code></td><td>group chips</td></tr> <tr><td>Editor error</td><td><code>m-fast</code></td><td>outline/message swap (no shake)</td></tr> <tr><td>List entrance</td><td><code>m-entrance</code> stagger 40 ms</td><td>first paint</td></tr> </tbody></table> </div> <p>Rules: reduced-motion substitutes fades/instants (<a href="settings/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §4</a>).</p> <h2 id="6-contrast--dark-parity-7"><a class="header" href="#6-contrast--dark-parity-7">6. Contrast & dark parity</a></h2> <ul> <li>All text ≥ 4.5:1; interactive chrome ≥ 3:1 (<a href="settings/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §2</a>).</li> <li>Dark: dirty tint <code>primaryContainer</code> dark variant (TBD by designer, <a href="settings/02_User_Personas.html">02 §1.4</a>); JSON editor fill <code>surfaceVariant</code>; type chips readable (<code>onSurfaceVariant</code>).</li> <li>No sole-color status: dirty = dot + "unsaved" semantics; saved = snackbar + check; type = chip label (never color-only, <a href="settings/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §9</a>).</li> </ul> <h2 id="7-breakpoint-behaviour-7"><a class="header" href="#7-breakpoint-behaviour-7">7. Breakpoint behaviour</a></h2> <div class="table-wrapper"><table><thead><tr><th>Class</th><th>List</th><th>Detail</th></tr></thead><tbody> <tr><td>phone <600</td><td>single pane, chips scroll horizontally</td><td>pushed page</td></tr> <tr><td>600–839</td><td>list full width; detail sheet-like push</td><td>push</td></tr> <tr><td>840–1199</td><td>master-detail two-pane (list ≤ 480)</td><td>right pane</td></tr> <tr><td>≥1200</td><td>master-detail; hover rings; keyboard shortcuts</td><td>right pane</td></tr> </tbody></table> </div> <h2 id="8-cross-surface-brand-override"><a class="header" href="#8-cross-surface-brand-override">8. Cross-surface brand override</a></h2> <p>Org <code>branding.primaryColor</code>/<code>settings.theme.primaryColor</code> (<code>organization.schema.ts:108-120</code>) re-seeds the <code>ColorScheme</code> at runtime (<a href="settings/../00-shared/04_Design_System_Mapping.html">00-shared/04 §7.5</a>) — the Settings hub renders under the same overridden theme; a <code>theme</code> group value change takes effect on next theme reload <code>(proposed wiring)</code>.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-settings-module"><a class="header" href="#12--api-mapping-settings-module">12 — API Mapping (Settings Module)</a></h1> <ul> <li><a href="settings/12_API_Mapping.html#0-module-wide-request-envelope--client-policy">0. Module-wide request envelope & client policy</a></li> <li><a href="settings/12_API_Mapping.html#e1--list-settings">E1 — List settings</a></li> <li><a href="settings/12_API_Mapping.html#e2--get-setting-by-key">E2 — Get setting by key</a></li> <li><a href="settings/12_API_Mapping.html#e3--upsert-setting">E3 — Upsert setting</a></li> <li><a href="settings/12_API_Mapping.html#e4--bulk-update">E4 — Bulk update</a></li> <li><a href="settings/12_API_Mapping.html#e5--delete-setting">E5 — Delete setting</a></li> <li><a href="settings/12_API_Mapping.html#related-surfaces-context-not-module-owned">Related surfaces (context, not module-owned)</a> <ul> <li><a href="settings/12_API_Mapping.html#e6--org-embedded-settings-design-docsorganizations12">E6 — Org embedded settings (<code>design-docs/organizations/12</code>)</a></li> <li><a href="settings/12_API_Mapping.html#e7--feature-flags">E7 — Feature flags</a></li> </ul> </li> <li><a href="settings/12_API_Mapping.html#loading--streaming--realtime">Loading / streaming / realtime</a></li> <li><a href="settings/12_API_Mapping.html#client-side-error-mapping-table-module">Client-side error mapping table (module)</a></li> <li><a href="settings/12_API_Mapping.html#optimistic--undo">Optimistic / undo</a></li> </ul> <blockquote> <p>Exact wire contract for every screen → endpoint. Base <code>/api/v1</code>; envelope per <a href="settings/../00-shared/07_API_Conventions.html">00-shared/07</a> and <code>response-envelope.interceptor.ts</code> / <code>http-exception.filter.ts</code>. All endpoints from <code>src/modules/settings/settings.controller.ts</code>; business rules from <code>settings.service.ts</code> + <code>setting.repository.ts</code>. Global guard chain: <code>RateLimitGuard</code> → <code>JwtAuthGuard</code> → <code>RbacGuard</code> (<code>app.module.ts:129-131</code>); the controller additionally declares <code>@UseGuards(JwtAuthGuard)</code> (<code>settings.controller.ts:19</code>) and carries <strong>no <code>@Permissions</code> metadata</strong> (OQ-2 — the global RbacGuard passes because no permission is required, <code>rbac.guard.ts:29</code>).</p> </blockquote> <hr /> <h2 id="0-module-wide-request-envelope--client-policy-13"><a class="header" href="#0-module-wide-request-envelope--client-policy-13">0. Module-wide request envelope & client policy</a></h2> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Contract</th></tr></thead><tbody> <tr><td>Base</td><td><code>https://api.<domain>/api/v1</code></td></tr> <tr><td>Headers</td><td><code>Authorization: Bearer <accessToken></code>; <code>x-request-id</code> client-generated; <code>Content-Type: application/json</code></td></tr> <tr><td>Success</td><td><code>{success:true, message:"OK", data, meta?, timestamp, requestId}</code> — <code>message</code> is always <code>"OK"</code> (<code>response-envelope.interceptor.ts:48-53</code>)</td></tr> <tr><td>Error</td><td><code>{success:false, message, error:{code, details?}, timestamp, requestId}</code> — codes per <code>http-exception.filter.ts:27-35</code></td></tr> <tr><td><strong>Pagination</strong></td><td><strong>NOT paginated.</strong> <code>findAll</code> returns a bare array (<code>settings.service.ts:10-12</code>); the interceptor only emits <code>meta</code> when the payload is <code>{data, meta}</code> (<code>response-envelope.interceptor.ts:25-32</code>) → <code>meta</code> is <strong>omitted</strong> for every settings endpoint. Client renders the full array (no infinite scroll).</td></tr> <tr><td>Tenancy</td><td><code>tenantId</code> from JWT claim via <code>TenantContextService</code> (<code>setting.repository.ts:16,34</code>); <strong>never in body</strong>. Platform admin bypasses the tenant scope (<code>base.repository.ts:21-23</code>).</td></tr> <tr><td>Caching</td><td>none server-side in the settings path; client stale-while-revalidate</td></tr> <tr><td>Offline</td><td>reads from last-good cache + banner; writes blocked (no offline queue)</td></tr> <tr><td>Retry</td><td>backoff on 5xx/network; no auto-retry on 429</td></tr> </tbody></table> </div> <hr /> <h2 id="e1--list-settings"><a class="header" href="#e1--list-settings">E1 — List settings</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>GET /settings</code> (<code>settings.controller.ts:24-29</code>)</td></tr> <tr><td><strong>Query</strong></td><td><code>?group=</code> optional, ∈ <code>SettingGroup</code> enum (<code>settings.controller.ts:26</code>; <code>setting.schema.ts:7-14</code>)</td></tr> <tr><td><strong>Success</strong></td><td>200 <code>data: Setting[]</code> — sorted <code>group:1, key:1</code> (<code>settings.service.ts:11</code>); <strong>array, no meta</strong></td></tr> <tr><td><strong>Auth</strong></td><td>JWT (all roles — permission not enforced, OQ-2)</td></tr> <tr><td><strong>Errors</strong></td><td>400 if <code>group</code> not in enum (<code>VALIDATION_ERROR</code>); 401; 429; 5xx</td></tr> <tr><td><strong>Screen</strong></td><td>S1 list / group chips (<a href="settings/05_Screen_Inventory.html">05 §1</a>, <a href="settings/06_Screen_Specifications.html">06 §S1</a>)</td></tr> </tbody></table> </div> <p><code>Setting</code> doc shape (<code>setting.schema.ts:17-35</code> + <code>base.schema.ts:10-34</code>):</p> <pre><code class="language-json">{ "_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": "..." } </code></pre> <p>(<code>label</code>/<code>description</code> are schema fields but <strong>never written</strong> by the service — <code>settings.service.ts:25</code>; <code>isEncrypted</code> is never set — OQ-6.)</p> <h2 id="e2--get-setting-by-key"><a class="header" href="#e2--get-setting-by-key">E2 — Get setting by key</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>GET /settings/:key</code> (<code>settings.controller.ts:31-35</code>)</td></tr> <tr><td><strong>Success</strong></td><td>200 <code>data: Setting</code></td></tr> <tr><td><strong>404</strong></td><td><code>RESOURCE_NOT_FOUND</code>, message <code>Setting "«key»" not found.</code> (<code>settings.service.ts:20</code>)</td></tr> <tr><td><strong>Screen</strong></td><td>S2 detail (<code>06 §S2</code>) — client treats 404 as "not found" empty state</td></tr> </tbody></table> </div> <h2 id="e3--upsert-setting"><a class="header" href="#e3--upsert-setting">E3 — Upsert setting</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>PUT /settings</code> (<code>settings.controller.ts:37-41</code>)</td></tr> <tr><td><strong>Request</strong></td><td><code>UpdateSettingDto</code> (<code>update-setting.dto.ts:5-26</code>): <code>key</code> (req, unvalidated), <code>value</code> (req, unvalidated — any JSON), <code>group?</code> (<code>@IsEnum</code>), <code>label?</code>/<code>description?</code> (<code>@IsString</code> — <strong>dropped by service</strong>)</td></tr> <tr><td><strong>Success</strong></td><td>200 <code>data: Setting</code> (the saved doc, <code>new: true</code> — <code>setting.repository.ts:39</code>)</td></tr> <tr><td><strong>Semantics</strong></td><td>Idempotent upsert: <code>findOneAndUpdate({tenantId,key,isDeleted:false}, {$set:{value, ...(group?{group}:{})}}, {upsert:true, new:true})</code> (<code>setting.repository.ts:35-39</code>) — <code>value</code> <strong>replaced wholesale</strong>, never merged</td></tr> <tr><td><strong>Errors</strong></td><td>400 invalid <code>group</code> (details per field); <strong>500</strong> for missing <code>key</code>/<code>value</code> (no DTO decorators → Mongoose required error, OQ-3); <strong>500</strong> E11000 on recreate-after-delete (OQ-5); 401; 429</td></tr> <tr><td><strong>Screen</strong></td><td>S2 save / S3 create (<code>06 §S2-S3</code>)</td></tr> </tbody></table> </div> <h2 id="e4--bulk-update"><a class="header" href="#e4--bulk-update">E4 — Bulk update</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>PUT /settings/bulk</code> (<code>settings.controller.ts:43-47</code>)</td></tr> <tr><td><strong>Request</strong></td><td><code>UpdateSettingDto[]</code> (JSON array)</td></tr> <tr><td><strong>Success</strong></td><td>200 <code>data: Setting[]</code> — one doc per input, in input order (<code>settings.service.ts:28-34</code>)</td></tr> <tr><td><strong>Semantics</strong></td><td>Sequential per-key upserts — <strong>no transaction</strong>; a mid-batch failure returns 5xx with earlier keys already persisted (OQ-4)</td></tr> <tr><td><strong>Client</strong></td><td>send only dirty keys; per-item retry re-sends full dirty set (idempotent)</td></tr> <tr><td><strong>Screen</strong></td><td>batch save (<code>06 §S1</code> batch mode)</td></tr> </tbody></table> </div> <h2 id="e5--delete-setting"><a class="header" href="#e5--delete-setting">E5 — Delete setting</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>DELETE /settings/:key</code> (<code>settings.controller.ts:49-53</code>)</td></tr> <tr><td><strong>Success</strong></td><td>200 <code>data: null</code> (void; envelope <code>data</code> = <code>undefined</code> → serialized as omitted/null)</td></tr> <tr><td><strong>Semantics</strong></td><td>Soft delete via <code>BaseRepository.softDelete</code> — <code>isDeleted:true, deletedAt, $inc version</code> (<code>base.repository.ts:68-74</code>); doc remains; unique index still holds the key (<code>setting.schema.ts:38</code>) → recreate fails E11000 → 500 (OQ-5)</td></tr> <tr><td><strong>Errors</strong></td><td>404 <code>RESOURCE_NOT_FOUND</code> <code>Setting "«key»" not found.</code> (<code>settings.service.ts:40</code>); 401</td></tr> <tr><td><strong>Screen</strong></td><td>S4 delete confirm (<code>06 §S4</code>) — 404 treated as already removed</td></tr> </tbody></table> </div> <hr /> <h2 id="related-surfaces-context-not-module-owned"><a class="header" href="#related-surfaces-context-not-module-owned">Related surfaces (context, not module-owned)</a></h2> <h3 id="e6--org-embedded-settings-design-docsorganizations12"><a class="header" href="#e6--org-embedded-settings-design-docsorganizations12">E6 — Org embedded settings (<code>design-docs/organizations/12</code>)</a></h3> <ul> <li><code>GET /organizations/:id/settings</code> → <code>data = org.settings ?? {}</code> (<code>organizations.controller.ts:56-62</code>)</li> <li><code>PATCH /organizations/:id/settings</code> → <strong>full-replace</strong> <code>$set {settings: dto}</code> (<code>organizations.service.ts:129-137</code>) — client must always submit the complete object.</li> </ul> <h3 id="e7--feature-flags"><a class="header" href="#e7--feature-flags">E7 — Feature flags</a></h3> <ul> <li><code>GET /feature-flags[?module=]</code>, <code>GET /feature-flags/enabled</code>, <code>GET /feature-flags/:key</code>, <code>PUT /feature-flags</code>, <code>PUT /feature-flags/bulk</code>, <code>DELETE /feature-flags/:key</code> (<code>feature-flags.controller.ts:23-58</code>); flag doc <code>{key, enabled, label?, description?, module?}</code> (<code>feature-flag.schema.ts:9-23</code>).</li> </ul> <hr /> <h2 id="loading--streaming--realtime-7"><a class="header" href="#loading--streaming--realtime-7">Loading / streaming / realtime</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Loading</th><th>Streaming</th><th>Realtime</th></tr></thead><tbody> <tr><td>list</td><td><code>AppSkeleton(list)</code></td><td>—</td><td>— (no WS topic for settings; <code>00-shared/07 §8</code> has none)</td></tr> <tr><td>detail</td><td>row/panel skeleton</td><td>—</td><td>—</td></tr> <tr><td>save</td><td>button/row spinner</td><td>—</td><td>—</td></tr> <tr><td>batch</td><td>per-row status</td><td>—</td><td>—</td></tr> </tbody></table> </div> <h2 id="client-side-error-mapping-table-module-5"><a class="header" href="#client-side-error-mapping-table-module-5">Client-side error mapping table (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>code</th><th>UI</th></tr></thead><tbody> <tr><td>list/detail</td><td>401</td><td>silent refresh → session expiry</td></tr> <tr><td>list</td><td>5xx</td><td><code>AppErrorState</code> + retry</td></tr> <tr><td>detail</td><td>404</td><td><code>AppSettingNotFound</code> empty state</td></tr> <tr><td>save</td><td>400</td><td>inline field error (group enum details)</td></tr> <tr><td>save</td><td>500</td><td>generic + requestId, form kept (retry safe — idempotent upsert)</td></tr> <tr><td>save (E11000 recreate)</td><td>500</td><td>banner: key is soft-deleted, cannot recreate (OQ-5)</td></tr> <tr><td>delete</td><td>404</td><td>treat as removed</td></tr> <tr><td>any</td><td>429</td><td>countdown, no auto-retry</td></tr> <tr><td>any (future)</td><td>403</td><td>shared 403 screen — server does not emit today (OQ-2)</td></tr> </tbody></table> </div> <h2 id="optimistic--undo-8"><a class="header" href="#optimistic--undo-8">Optimistic / undo</a></h2> <ul> <li>Explicit-save editors: <strong>server-confirm</strong> (config values; rollback never needed).</li> <li>Boolean toggle: optimistic with rollback (<code>00-shared/06 §3.5</code>).</li> <li>Delete: no undo (soft-delete not reversible via API).</li> <li>Upsert idempotency makes retries safe everywhere (<code>setting.repository.ts:35-39</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-settings-module"><a class="header" href="#13--state-management-settings-module">13 — State Management (Settings Module)</a></h1> <ul> <li><a href="settings/13_State_Management.html#1-cubit-map">1. Cubit map</a></li> <li><a href="settings/13_State_Management.html#2-state-objects-concise">2. State objects (concise)</a></li> <li><a href="settings/13_State_Management.html#3-cache--staleness">3. Cache & staleness</a></li> <li><a href="settings/13_State_Management.html#4-optimistic-vs-full-save-per-source-semantics">4. Optimistic vs full-save (per source semantics)</a></li> <li><a href="settings/13_State_Management.html#5-realtime">5. Realtime</a></li> <li><a href="settings/13_State_Management.html#6-error-states-per-action">6. Error states per action</a></li> <li><a href="settings/13_State_Management.html#7-cross-cutting-interplay">7. Cross-cutting interplay</a></li> <li><a href="settings/13_State_Management.html#8-testing-hooks-00-shared06-6">8. Testing hooks (<code>00-shared/06 §6</code>)</a></li> </ul> <blockquote> <p>Per-screen Cubits (Flutter/bloc; proposal, <a href="settings/../00-shared/06_State_Management.html">00-shared/06</a>) backed by <code>SettingsRepository</code> (dio) which calls the endpoints in <a href="settings/12_API_Mapping.html">12_API_Mapping.md</a>. Server truth is the upsert response — config state must never diverge from it.</p> </blockquote> <hr /> <h2 id="1-cubit-map-7"><a class="header" href="#1-cubit-map-7">1. Cubit map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Cubit</th><th>Events → State</th></tr></thead><tbody> <tr><td>List (hub)</td><td><code>SettingsListCubit</code></td><td><code>Load(group?)</code>, <code>Refresh()</code>, <code>GroupChanged(group)</code>, <code>Search(q)</code>, <code>EditRow(key)</code>, <code>ValueChanged(key, value)</code>, <code>SaveRow(key)</code>, <code>ToggleBool(key, value)</code>, <code>EnterBatch()</code>, <code>SaveAll()</code>, <code>ExitBatch()</code> → {<code>LoadState</code>, <code>settings[]</code> (grouped), <code>selectedGroup</code>, <code>query</code>, <code>dirty: Map<key, SettingDraft></code>, <code>batchMode</code>, <code>savingKeys: Set</code>}</td></tr> <tr><td>Detail</td><td><code>SettingDetailCubit</code></td><td><code>Load(key)</code>, <code>FieldChanged(field, value)</code>, <code>Save()</code>, <code>Delete()</code>, <code>DuplicateKey()</code> → {<code>LoadState</code>, <code>setting</code>, <code>draft</code>, <code>saving</code>, <code>deleted</code>}</td></tr> <tr><td>Create (sheet)</td><td><code>CreateSettingCubit</code></td><td><code>Create(form)</code> → {<code>idle, saving, created(setting), error(code)</code>}</td></tr> </tbody></table> </div> <p><strong>Key reducer (list):</strong></p> <ul> <li><code>GroupChanged(g)</code> → <code>repository.list(group: g)</code> (server filter — <code>settings.controller.ts:26-28</code>), clears <code>query</code> + dirty set.</li> <li><code>SaveRow(key)</code> → builds full dto <code>{key, value: draft.value, group: draft.group}</code> → <code>PUT /settings</code> → on success <strong>replace row from server doc</strong> (<code>data: Setting</code>, <code>setting.repository.ts:39</code>), remove from <code>dirty</code>, snackbar; on error keep <code>dirty</code> + snackbar (retry safe — idempotent).</li> <li><code>SaveAll()</code> → serializes <strong>only dirty rows</strong> (full dto each) → <code>PUT /settings/bulk</code> (<code>settings.service.ts:28-34</code>) → on 200 replace all rows from response array; on 5xx keep dirty set, snackbar "Saved N of M" with per-key retry.</li> <li><code>Search(q)</code> — <strong>client-side</strong> filter of the current fetch (no server <code>q</code> param).</li> </ul> <h2 id="2-state-objects-concise"><a class="header" href="#2-state-objects-concise">2. State objects (concise)</a></h2> <pre><code class="language-dart">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; } </code></pre> <h2 id="3-cache--staleness"><a class="header" href="#3-cache--staleness">3. Cache & staleness</a></h2> <div class="table-wrapper"><table><thead><tr><th>Surface</th><th>Cache</th><th>TTL</th><th>Policy</th></tr></thead><tbody> <tr><td>List per <code>(group?)</code></td><td>in-memory + <code>shared_preferences</code> last-good</td><td>5 min (config can change via API/other admins)</td><td>stale-while-revalidate (<code>00-shared/06 §3.3</code>); <code>RefreshIndicator</code> bypasses</td></tr> <tr><td>Detail</td><td>none</td><td>—</td><td>always fetch on open (<code>GET /settings/:key</code>)</td></tr> <tr><td>Server cache</td><td>none in settings path</td><td>—</td><td>API is single-doc Mongo reads</td></tr> </tbody></table> </div> <p>Bump the list cache key whenever a save/delete succeeds (<code>{tenant}:settings:{group}</code>) so sibling devices see fresh values on next focus. On screen focus → <code>Refresh()</code>.</p> <h2 id="4-optimistic-vs-full-save-per-source-semantics"><a class="header" href="#4-optimistic-vs-full-save-per-source-semantics">4. Optimistic vs full-save (per source semantics)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Operation</th><th>Strategy</th><th>Reason (source)</th></tr></thead><tbody> <tr><td>Boolean toggle</td><td><strong>optimistic + rollback</strong></td><td>safe mutation class (<code>00-shared/06 §3.5</code>); instant feedback expected</td></tr> <tr><td>String/number/JSON save</td><td><strong>server-confirm</strong></td><td>value replaced wholesale (<code>setting.repository.ts:37</code>); must reflect server truth</td></tr> <tr><td>Batch save</td><td>server-confirm, per-item status</td><td>sequential loop, not transactional (<code>settings.service.ts:28-34</code>)</td></tr> <tr><td>Delete</td><td>server-confirm; no undo</td><td>soft delete irreversible via API (OQ-5)</td></tr> <tr><td>Create</td><td>server-confirm</td><td>upsert idempotent — retry-safe</td></tr> </tbody></table> </div> <p>No offline write queue (<code>00-shared/07 §10</code>): offline → banner, writes blocked with guidance; last-good list still renders.</p> <h2 id="5-realtime"><a class="header" href="#5-realtime">5. Realtime</a></h2> <ul> <li><strong>No WS topic for settings</strong> (<code>00-shared/07 §8</code> enumerates no settings channel) → no push-driven invalidation. Refresh-on-focus is the correctness mechanism; a future <code>settings.changed</code> topic is <code>(planned)</code> (OQ-7/8).</li> </ul> <h2 id="6-error-states-per-action-1"><a class="header" href="#6-error-states-per-action-1">6. Error states per action</a></h2> <div class="table-wrapper"><table><thead><tr><th>Action</th><th>Error</th><th>State →</th></tr></thead><tbody> <tr><td>load</td><td>401</td><td>silent refresh → <code>sessionExpired</code> → login</td></tr> <tr><td>load</td><td>5xx</td><td><code>AppErrorState(code, retry)</code></td></tr> <tr><td>detail load</td><td>404</td><td><code>AppSettingNotFound</code> (empty state)</td></tr> <tr><td>save</td><td>400</td><td>inline field errors (group enum details)</td></tr> <tr><td>save</td><td>500</td><td>snackbar + form kept (idempotent retry)</td></tr> <tr><td>save (E11000)</td><td>500</td><td>banner "key cannot be re-created" (OQ-5)</td></tr> <tr><td>bulk</td><td>5xx mid-loop</td><td>"Saved N of M"; per-key retry re-sends full dirty set</td></tr> <tr><td>delete</td><td>404</td><td>treat as removed</td></tr> <tr><td>any</td><td>429</td><td>countdown, disable submit, no auto-retry</td></tr> </tbody></table> </div> <h2 id="7-cross-cutting-interplay"><a class="header" href="#7-cross-cutting-interplay">7. Cross-cutting interplay</a></h2> <ul> <li><code>ConnectivityCubit</code> gates writes (offline → editors disabled + banner).</li> <li><code>AuthCubit</code> provides tenantId/user (displayed in detail meta when <code>createdBy</code> present).</li> <li><code>FeatureFlagsCubit</code> not consumed by this module (settings surface is not flag-gated).</li> <li>Permission changes (role edit) → route rebuild hides <code>/settings</code> without <code>settings.read</code> (<code>00-shared/06 §3.6</code>); server not enforcing (OQ-2).</li> </ul> <h2 id="8-testing-hooks-00-shared06-6-4"><a class="header" href="#8-testing-hooks-00-shared06-6-4">8. Testing hooks (<code>00-shared/06 §6</code>)</a></h2> <ul> <li>Unit: <code>SettingsListCubit</code> — group switch triggers server filter call; dirty map lifecycle; <code>SaveAll</code> serializes only dirty full dtos; partial-failure state ("Saved N of M").</li> <li>Unit: retry-after-500 keeps dirty set.</li> <li>Widget: list loading/error/empty/dirty; JSON editor valid/invalid; batch bar counts.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-settings-module"><a class="header" href="#14--qa-checklist-settings-module">14 — QA Checklist (Settings Module)</a></h1> <ul> <li><a href="settings/14_QA_Checklist.html#1-functional">1. Functional</a></li> <li><a href="settings/14_QA_Checklist.html#2-tenancy--security">2. Tenancy & security</a></li> <li><a href="settings/14_QA_Checklist.html#3-visual--layout">3. Visual / layout</a></li> <li><a href="settings/14_QA_Checklist.html#4-accessibility-00-shared09">4. Accessibility (00-shared/09)</a></li> <li><a href="settings/14_QA_Checklist.html#5-performance">5. Performance</a></li> <li><a href="settings/14_QA_Checklist.html#6-offline--network-00-shared10-2">6. Offline & network (00-shared/10 §2)</a></li> <li><a href="settings/14_QA_Checklist.html#7-tablet--desktop">7. Tablet / desktop</a></li> <li><a href="settings/14_QA_Checklist.html#8-localization">8. Localization</a></li> <li><a href="settings/14_QA_Checklist.html#9-dark-mode--animations">9. Dark mode / animations</a></li> <li><a href="settings/14_QA_Checklist.html#10-server-backed-edge-mirrors">10. Server-backed edge mirrors</a></li> <li><a href="settings/14_QA_Checklist.html#qa-scripts">QA scripts</a></li> </ul> <blockquote> <p>Module QA specific to the framework + shared <a href="settings/../00-shared/10_QA_Baseline.html">00-shared/10</a>. Every check derives from a specific backend behavior so failure analysis is direct.</p> </blockquote> <hr /> <h2 id="1-functional-5"><a class="header" href="#1-functional-5">1. Functional</a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>GET /settings</code> returns a <strong>bare array</strong> (no <code>meta</code>) sorted <code>group,key</code> (<code>settings.service.ts:10-12</code>; <code>response-envelope.interceptor.ts:25-32</code>).</li> <li><input disabled="" type="checkbox"/> <code>GET /settings?group=attendance</code> returns only that group; invalid group value → 400 <code>VALIDATION_ERROR</code> with <code>details</code> (<code>settings.controller.ts:26-28</code>; <code>update-setting.dto.ts:14</code>).</li> <li><input disabled="" type="checkbox"/> <code>GET /settings/:key</code> → 200 doc; unknown key → 404 <code>Setting "«key»" not found.</code> (<code>settings.service.ts:20</code>).</li> <li><input disabled="" type="checkbox"/> <code>PUT /settings</code> upsert: new key creates; existing key <strong>replaces value</strong> (verify a nested object field dropped from payload is gone after save — <code>setting.repository.ts:37</code>); response is the saved doc (<code>new: true</code>).</li> <li><input disabled="" type="checkbox"/> <code>PUT /settings</code> omitting <code>group</code> keeps existing group; sending <code>group</code> moves the key.</li> <li><input disabled="" type="checkbox"/> <code>label</code>/<code>description</code> sent in <code>PUT /settings</code> are <strong>not persisted</strong> (verify DB — <code>settings.service.ts:25</code>).</li> <li><input disabled="" type="checkbox"/> <code>PUT /settings/bulk</code> saves all; response array order matches input order (<code>settings.service.ts:28-34</code>).</li> <li><input disabled="" type="checkbox"/> Boolean value round-trips as JSON <code>true/false</code>; number stays number; numeric string stays string (no coercion).</li> <li><input disabled="" type="checkbox"/> <code>DELETE /settings/:key</code> → 200; second delete → 404 (<code>settings.service.ts:40</code>).</li> <li><input disabled="" type="checkbox"/> Re-create a deleted key → expect <strong>E11000 → 500</strong> (OQ-5) — QA documents current behavior; UI shows the soft-delete banner.</li> <li><input disabled="" type="checkbox"/> Soft-deleted doc stays in DB with <code>isDeleted:true, deletedAt</code> (<code>base.repository.ts:68-74</code>).</li> <li><input disabled="" type="checkbox"/> Bulk with a duplicate key inside the batch → last item wins (client dedupes first).</li> </ul> <h2 id="2-tenancy--security"><a class="header" href="#2-tenancy--security">2. Tenancy & security</a></h2> <ul> <li><input disabled="" type="checkbox"/> Tenant A's token can never read/write tenant B's settings (unique index <code>{tenantId,key}</code>, <code>setting.schema.ts:38</code>; <code>scopedFilter</code>, <code>base.repository.ts:20-30</code>).</li> <li><input disabled="" type="checkbox"/> No <code>tenantId</code> accepted from the body — only from token (repo injects, <code>setting.repository.ts:34</code>).</li> <li><input disabled="" type="checkbox"/> Platform admin token → <code>GET /settings</code> returns <strong>cross-tenant</strong> rows (scope bypass <code>base.repository.ts:21-23</code>) — UI labels this mode; QA confirms no accidental write to wrong tenant.</li> <li><input disabled="" type="checkbox"/> Permission matrix: any authenticated user can currently CRUD settings (no <code>@Permissions</code> on controller, OQ-2). QA verifies <strong>client guard</strong> behavior (hidden routes); flag server enforcement as pending.</li> </ul> <h2 id="3-visual--layout-1"><a class="header" href="#3-visual--layout-1">3. Visual / layout</a></h2> <ul> <li><input disabled="" type="checkbox"/> Long keys wrap without clipping at 200% text scale; preview ellipsis only.</li> <li><input disabled="" type="checkbox"/> JSON values render truncated preview + expand into editor prefilled (never blank).</li> <li><input disabled="" type="checkbox"/> Dark-light parity: dirty tint <code>primaryContainer</code>, JSON editor fill, chips.</li> <li><input disabled="" type="checkbox"/> Master-detail ≥840 dp; batch bar respects nav bar height on phone.</li> </ul> <h2 id="4-accessibility-00-shared09-1"><a class="header" href="#4-accessibility-00-shared09-1">4. Accessibility (<a href="settings/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>)</a></h2> <ul> <li><input disabled="" type="checkbox"/> TalkBack: browse → edit → save → delete fully operable without sight.</li> <li><input disabled="" type="checkbox"/> VoiceOver separately.</li> <li><input disabled="" type="checkbox"/> Keyboard-only desktop: search (<code>Ctrl+F</code>), Enter saves, Esc closes, focus ring visible.</li> <li><input disabled="" type="checkbox"/> Dirty rows announced ("unsaved"); JSON parse errors announced on focus.</li> <li><input disabled="" type="checkbox"/> Group chips announce selected state; type chips are labeled (no color-only status).</li> <li><input disabled="" type="checkbox"/> Contrast: body 4.5:1, icons/chips 3:1; reduced-motion honored.</li> </ul> <h2 id="5-performance-1"><a class="header" href="#5-performance-1">5. Performance</a></h2> <ul> <li><input disabled="" type="checkbox"/> List first frame ≤ 300 ms (cache) / skeleton ≤ 200 ms, content ≤ 2 s (network).</li> <li><input disabled="" type="checkbox"/> No duplicate parallel requests (group switch cancels in-flight previous fetch).</li> <li><input disabled="" type="checkbox"/> <code>ListView.builder</code> only; no full-page rebuild on row save.</li> <li><input disabled="" type="checkbox"/> Memory stable over 20-min session (100+ keys).</li> </ul> <h2 id="6-offline--network-00-shared10-2-1"><a class="header" href="#6-offline--network-00-shared10-2-1">6. Offline & network (<a href="settings/../00-shared/10_QA_Baseline.html">00-shared/10 §2</a>)</a></h2> <ul> <li><input disabled="" type="checkbox"/> Offline open → cached rows + banner; editors disabled with guidance.</li> <li><input disabled="" type="checkbox"/> Slow network: row spinner; retry works; no silent double-write (idempotent PUT — two same payloads ⇒ one final value).</li> <li><input disabled="" type="checkbox"/> Mid-bulk failure → "Saved N of M" + per-row retry sends full dirty set.</li> </ul> <h2 id="7-tablet--desktop-1"><a class="header" href="#7-tablet--desktop-1">7. Tablet / desktop</a></h2> <ul> <li><input disabled="" type="checkbox"/> Master-detail two-pane; hover row tint; right-click row menu.</li> <li><input disabled="" type="checkbox"/> <code>Ctrl/Cmd+Enter</code> saves JSON editor; <code>N</code>/<code>+</code> new setting; Esc exits batch mode.</li> </ul> <h2 id="8-localization-4"><a class="header" href="#8-localization-4">8. Localization</a></h2> <ul> <li><input disabled="" type="checkbox"/> All strings via i18n; group labels localized (<code>attendance</code> → "Attendance"); enum values are the wire values (never translated in requests).</li> <li><input disabled="" type="checkbox"/> RTL: group chips scroll direction, JSON editor LTR-aligned (<code>mono</code> block).</li> </ul> <h2 id="9-dark-mode--animations"><a class="header" href="#9-dark-mode--animations">9. Dark mode / animations</a></h2> <ul> <li><input disabled="" type="checkbox"/> Full visual pass both modes; tokens only; motion tokens only; reduced-motion pass.</li> </ul> <h2 id="10-server-backed-edge-mirrors"><a class="header" href="#10-server-backed-edge-mirrors">10. Server-backed edge mirrors</a></h2> <ul> <li><input disabled="" type="checkbox"/> 400 group enum details rendered inline.</li> <li><input disabled="" type="checkbox"/> 404 detail/delete treated as removed.</li> <li><input disabled="" type="checkbox"/> 500 (missing key/value, E11000) → generic + requestId, form kept, banner for soft-delete case (OQ-3, OQ-5).</li> <li><input disabled="" type="checkbox"/> 429 → countdown, no auto-retry.</li> </ul> <hr /> <h2 id="qa-scripts-8"><a class="header" href="#qa-scripts-8">QA scripts</a></h2> <ul> <li>Golden per component (light/dark × phone/tablet/desktop × dirty/saving/error states).</li> <li>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).</li> <li>Tenancy smoke: second tenant token → expect 404/empty, never other tenant's rows.</li> <li>Upsert idempotency smoke: same PUT twice → identical <code>version</code> increment pattern (second call may bump version — verify no duplicate key rows).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-settings-module"><a class="header" href="#15--flutter-implementation-guide-settings-module">15 — Flutter Implementation Guide (Settings Module)</a></h1> <ul> <li><a href="settings/15_Flutter_Implementation_Guide.html#1-folder-structure">1. Folder structure</a></li> <li><a href="settings/15_Flutter_Implementation_Guide.html#2-dependencies">2. Dependencies</a></li> <li><a href="settings/15_Flutter_Implementation_Guide.html#3-settingsrepository-single">3. SettingsRepository (single)</a></li> <li><a href="settings/15_Flutter_Implementation_Guide.html#4-cubits">4. Cubits</a></li> <li><a href="settings/15_Flutter_Implementation_Guide.html#5-navigation">5. Navigation</a></li> <li><a href="settings/15_Flutter_Implementation_Guide.html#6-theme">6. Theme</a></li> <li><a href="settings/15_Flutter_Implementation_Guide.html#7-key-type-handling-module-core">7. Key type handling (module core)</a></li> <li><a href="settings/15_Flutter_Implementation_Guide.html#8-localization-keys">8. Localization keys</a></li> <li><a href="settings/15_Flutter_Implementation_Guide.html#9-error-handling">9. Error handling</a></li> <li><a href="settings/15_Flutter_Implementation_Guide.html#10-testing">10. Testing</a></li> <li><a href="settings/15_Flutter_Implementation_Guide.html#11-performance">11. Performance</a></li> <li><a href="settings/15_Flutter_Implementation_Guide.html#12-proposals-flagged-to-the-team">12. Proposals flagged to the team</a></li> </ul> <blockquote> <p>How to build the Settings feature in the Flutter client on top of <a href="settings/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a>. Forward-looking spec; no client repo exists yet. Data-driven module: no hardcoded key catalog — render what <code>GET /settings</code> returns (<code>settings.controller.ts:24-29</code>).</p> </blockquote> <hr /> <h2 id="1-folder-structure-10"><a class="header" href="#1-folder-structure-10">1. Folder structure</a></h2> <pre><code>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 </code></pre> <h2 id="2-dependencies-8"><a class="header" href="#2-dependencies-8">2. Dependencies</a></h2> <p><code>flutter_bloc</code>, <code>dio</code> (<code>AppDio</code> interceptors), <code>go_router</code>, <code>get_it</code>, <code>shared_preferences</code> (cache + banner dismissal), <code>intl</code> (dates). <strong>No JSON editor package</strong> — hand-rolled <code>jsonDecode</code> validation keeps the dependency surface zero (<code>07 §B</code>).</p> <h2 id="3-settingsrepository-single"><a class="header" href="#3-settingsrepository-single">3. SettingsRepository (single)</a></h2> <pre><code class="language-dart">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 } </code></pre> <ul> <li>All through <code>AppDio</code>; envelopes mapped in <code>setting_dto.dart</code>; <strong>non-paginated arrays</strong> (no meta parsing — <code>12 §0</code>).</li> <li>Cache: <code>shared_preferences</code> last-good per <code>{tenant}:settings:{group}</code>, TTL 5 min, stale-while-revalidate (<code>13 §3</code>).</li> </ul> <h2 id="4-cubits-2"><a class="header" href="#4-cubits-2">4. Cubits</a></h2> <ul> <li><code>SettingsListCubit</code> — <code>Load(group?)</code>, <code>Refresh()</code>, <code>GroupChanged</code>, <code>Search</code>, <code>SaveRow</code>, <code>ToggleBool</code>, <code>EnterBatch</code>, <code>SaveAll</code>, <code>ExitBatch</code> (<code>13 §1</code>).</li> <li><code>SettingDetailCubit</code> — <code>Load(key)</code>, <code>FieldChanged</code>, <code>Save</code>, <code>Delete</code>, <code>DuplicateKey</code>.</li> <li><code>CreateSettingCubit</code> — <code>Create(form)</code>.</li> <li>All pure-Dart; DI via <code>get_it</code> lazy factories (<code>00-shared/11 §2</code>).</li> </ul> <h2 id="5-navigation-4"><a class="header" href="#5-navigation-4">5. Navigation</a></h2> <pre><code class="language-dart">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']!)), </code></pre> <ul> <li>Master-detail via <code>StatefulShellBranch</code> at ≥840 dp (<code>00-shared/05 §3</code>).</li> <li><code>_permGuard</code> reads <code>user.permissions</code> — client-side only until server enforces <code>@Permissions('settings.*')</code> (OQ-2; then 403 → same redirect via error-code map).</li> <li>Deep links <code>(forward-looking)</code>: <code>studylyon://settings</code>, <code>studylyon://settings/:key</code>.</li> </ul> <h2 id="6-theme-5"><a class="header" href="#6-theme-5">6. Theme</a></h2> <p><code>AppTheme.light()/dark()</code> unchanged; org branding override re-seeds <code>ColorScheme</code> (<code>00-shared/04 §7.5</code>); no module tokens beyond <code>11_Design_System_Mapping.md</code>.</p> <h2 id="7-key-type-handling-module-core"><a class="header" href="#7-key-type-handling-module-core">7. Key type handling (module core)</a></h2> <pre><code class="language-dart">enum ValueKind { str, num, bool_, json } ValueKind kindOf(Object? v) => switch (v) { String() => ValueKind.str, num() => ValueKind.num, bool() => ValueKind.bool_, _ => ValueKind.json }; </code></pre> <ul> <li>Editors dispatch on <code>kindOf</code> (<code>06 §S2</code>); <strong>no coercion on save</strong> — serialize with <code>jsonEncode</code> only for the JSON editor; strings/numbers/booleans sent raw (<code>value: unknown</code> wire type, <code>update-setting.dto.ts:10</code>).</li> <li><code>SettingGroup</code> enum uses <strong>wire values</strong> (<code>setting.schema.ts:7-14</code>) for request/response; display labels via i18n keys.</li> </ul> <h2 id="8-localization-keys-5"><a class="header" href="#8-localization-keys-5">8. Localization keys</a></h2> <p><code>settings.list.title</code>, <code>settings.list.search.hint</code>, <code>settings.group.{academic|attendance|grading|notification|theme|general}</code>, <code>settings.row.type.{str|num|bool|json}</code>, <code>settings.row.dirty</code>, <code>settings.save.all(n)</code>, <code>settings.save.saved_n_of_m(n,m)</code>, <code>settings.save.error</code>, <code>settings.delete.confirm</code>, <code>settings.delete.softdelete.warn</code>, <code>settings.json.invalid</code>, <code>settings.notfound</code>, <code>settings.orglink.banner</code>, <code>settings.create.title</code>, <code>settings.create.key.hint</code>, <code>settings.create.duplicate.warn</code>. Server business-4xx messages mapped to keys; codes drive the rest (<code>00-shared/11 §9</code>).</p> <h2 id="9-error-handling"><a class="header" href="#9-error-handling">9. Error handling</a></h2> <ul> <li><code>ApiException(code, status)</code> from <code>AppDio</code> interceptor (<code>00-shared/11 §5</code>).</li> <li>400 → inline field errors from <code>details[]</code>; 404 → <code>AppSettingNotFound</code>; 500 → generic + requestId; 429 → countdown; 401 → refresh → session expiry.</li> <li>E11000 recreate case → dedicated banner copy (<code>14 §10</code>, OQ-5).</li> </ul> <h2 id="10-testing-7"><a class="header" href="#10-testing-7">10. Testing</a></h2> <ul> <li><strong>Unit:</strong> cubits with mocked repository (group-switch server-call assert; dirty lifecycle; <code>SaveAll</code> sends only dirty full dtos; partial-failure "Saved N of M" + retry keeps dirty).</li> <li><strong>Unit:</strong> <code>kindOf</code> dispatch; JSON editor <code>jsonDecode</code> validator.</li> <li><strong>Widget:</strong> list loading/error/empty/dirty/batch; typed editors per kind; save bar counts.</li> <li><strong>Golden:</strong> <code>setting_row</code> (clean/dirty/saving), <code>json_editor</code> (valid/invalid), <code>save_bar</code> — light/dark × 3 sizes.</li> <li><strong>Integration:</strong> seed tenant (20 keys, all groups) → list → filter → edit string → toggle bool (instant save) → batch save → delete → verify DB state via API.</li> <li><strong>E2E (P0):</strong> admin edits two settings across groups and batch-saves on device cloud.</li> </ul> <h2 id="11-performance-7"><a class="header" href="#11-performance-7">11. Performance</a></h2> <ul> <li><code>ListView.builder</code> + <code>const</code> constructors; row save updates a single item (identity-keyed list diff), never full rebuild.</li> <li>Group switch cancels the in-flight previous fetch (client-visible latency guard).</li> <li>JSON editor <code>TextEditingController</code> debounced (300 ms) for parse checks.</li> <li>No images, no heavy widgets — the list is text-dense; profile against <code>00-shared/10 §1</code> budgets.</li> </ul> <h2 id="12-proposals-flagged-to-the-team-6"><a class="header" href="#12-proposals-flagged-to-the-team-6">12. Proposals flagged to the team</a></h2> <ol> <li>When server adds <code>@Permissions('settings.*')</code> (<code>permissions.constants.ts:75-77</code>), enable server-truth 403 handling (OQ-2).</li> <li>When the audit module exposes settings history, add the detail "History" tab (OQ-7).</li> <li>When encryption pipeline lands, surface <code>isEncrypted</code> badge + secret editors (OQ-6).</li> <li>When <code>organization_settings</code> collection / <code>COACHING</code> group land (<code>COLLECTIONS.md:767-783</code>, <code>IMPLEMENTATION_PLAN.md:773</code>), extend groups and cross-links.</li> <li>If a settings registry with defaults ships, replace type-inference heuristics with registry-driven editors.</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-staff-module"><a class="header" href="#01--product-overview-staff-module">01 — Product Overview (Staff Module)</a></h1> <ul> <li><a href="staff/01_Product_Overview.html#1-purpose">1. Purpose</a></li> <li><a href="staff/01_Product_Overview.html#2-module-boundary-staff-vs-teachers-vs-users">2. Module boundary: Staff vs Teachers vs Users</a></li> <li><a href="staff/01_Product_Overview.html#3-staff-lifecycle-end-to-end">3. Staff lifecycle (end-to-end)</a></li> <li><a href="staff/01_Product_Overview.html#4-department--designation-catalog-lifecycle">4. Department & designation catalog lifecycle</a></li> <li><a href="staff/01_Product_Overview.html#5-role--permission-mapping">5. Role & permission mapping</a></li> <li><a href="staff/01_Product_Overview.html#6-events--downstream-consumers">6. Events & downstream consumers</a></li> <li><a href="staff/01_Product_Overview.html#7-data-model-summary">7. Data model summary</a></li> <li><a href="staff/01_Product_Overview.html#8-dependencies">8. Dependencies</a></li> <li><a href="staff/01_Product_Overview.html#9-edge-cases--invariants">9. Edge cases & invariants</a></li> <li><a href="staff/01_Product_Overview.html#10-prd--mobile-forward-note">10. PRD & mobile-forward note</a></li> </ul> <blockquote> <p>StudyLyon — multi-tenant ERP / School Management API. This package designs the <strong>Staff module client</strong> (Flutter, forward-looking spec) against the implemented NestJS backend. Every endpoint, field, enum, event, and rule below is derived from <code>src/modules/staff/**</code>, <code>src/modules/rbac/**</code>, <code>src/database/**</code>, <code>src/infrastructure/bullmq/event-queue-map.ts</code>, and the blueprint (<code>studylyon-blueprint/04-Modules/Staff.md</code>, <code>03-Database/COLLECTIONS.md</code>). Nothing is invented; gaps live in <strong>Assumptions & Open Questions</strong> and are marked <code>(planned)</code> / <code>(proposed)</code>.</p> </blockquote> <hr /> <h2 id="1-purpose-23"><a class="header" href="#1-purpose-23">1. Purpose</a></h2> <p>The Staff module is the <strong>non-teaching employment domain</strong>: "who works the office, finance desk, front desk, library, transport, HR" of an institution. It owns:</p> <div class="table-wrapper"><table><thead><tr><th>Responsibility</th><th>Source</th></tr></thead><tbody> <tr><td>Staff profile CRUD (create / list / detail / update / deactivate)</td><td><code>staff.controller.ts:24-38</code></td></tr> <tr><td>Duplicate guards (employeeNumber) on create</td><td><code>staff.service.ts:31-36</code></td></tr> <tr><td>Soft-delete (deactivate) with <code>isDeleted</code> flag — no re-activation endpoint</td><td><code>base.repository.ts:68-74</code>; <code>staff.service.ts:93-104</code></td></tr> <tr><td>Employment type enum (<code>full_time/part_time/contract/intern</code>)</td><td><code>staff.schema.ts:14-19</code></td></tr> <tr><td>Staff status enum (<code>active/inactive/on_leave/terminated</code>)</td><td><code>staff.schema.ts:7-12</code></td></tr> <tr><td>Department catalog CRUD (name, code, head)</td><td><code>department.controller.ts:26-43</code>; <code>department.schema.ts:9-19</code></td></tr> <tr><td>Designation catalog CRUD (name, level, optional department link)</td><td><code>designation.controller.ts:26-43</code>; <code>designation.schema.ts:9-19</code></td></tr> <tr><td>Domain events → in-app notification, audit-write, search index</td><td><code>staff.service.ts:44-54,82-90,96-103</code>; <code>event-queue-map.ts:34-36</code>; <code>search-indexer.service.ts:11,18,25</code></td></tr> </tbody></table> </div> <p>Blueprint definition: <code>Staff.md:3</code> — "Non-teaching employees." Collections: <code>staff</code>, <code>departments</code>, <code>designations</code> (<code>Staff.md:7</code>).</p> <h2 id="2-module-boundary-staff-vs-teachers-vs-users"><a class="header" href="#2-module-boundary-staff-vs-teachers-vs-users">2. Module boundary: Staff vs Teachers vs Users</a></h2> <p>The backend splits "people" into parallel profile documents sharing the same employment skeleton but diverging on domain specifics:</p> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Staff (<code>staff</code>)</th><th>Teacher (<code>teachers</code>)</th></tr></thead><tbody> <tr><td>Identity</td><td>"Non-teaching employees." (<code>Staff.md:3</code>)</td><td>"Teaching profile." (blueprint)</td></tr> <tr><td>Employment fields</td><td><code>employmentType</code>, <code>salaryGrade</code> (<code>staff.schema.ts:35-47</code>)</td><td><code>qualification</code>, <code>experienceYears</code> (<code>teacher.schema.ts:38-42</code>)</td></tr> <tr><td>Academic linkage</td><td>none</td><td><code>subjects[]</code>, <code>classTeacherFor[]</code> (<code>teacher.schema.ts:44-48</code>)</td></tr> <tr><td>Status field</td><td><code>status</code> — <code>StaffStatus</code> enum (<code>staff.schema.ts:7-12,48-49</code>)</td><td><code>employmentStatus</code> — same 4 values (<code>teacher.schema.ts:7-12,31-36</code>)</td></tr> <tr><td>Unique indexes</td><td><code>tenantId+employeeNumber</code>, <code>tenantId+userId</code> (<code>staff.schema.ts:57-58</code>)</td><td>identical pattern (<code>teacher.schema.ts:56-57</code>)</td></tr> <tr><td>CRUD surface</td><td><code>staff.controller.ts:24-38</code></td><td><code>teacher.controller.ts:24-38</code></td></tr> <tr><td>Events</td><td><code>StaffCreated/Updated/Deleted</code></td><td><code>TeacherCreated/Updated/Deleted</code></td></tr> <tr><td>Reference catalogs</td><td>departments/designations <strong>owned here</strong> (<code>Staff.md:59</code>)</td><td>shared, owned by Staff module</td></tr> </tbody></table> </div> <p><strong>Users</strong> (<code>users</code> collection) holds identity/credentials; a <code>users</code> record may own multiple profiles — a single <code>userId</code> can back both a teacher and a staff record in parallel (<code>RELATIONSHIPS.md:28</code> — <code>staff (0..1) via userId</code>). The Staff screens render <strong>only</strong> staff records; the Teachers screens render only teacher records.</p> <p><strong>Two catalogs owned by this module</strong> (<code>Staff.md:59</code> — "Departments/designations are reference catalogs owned by this module"):</p> <ul> <li><strong>Departments</strong> (<code>departments</code> collection): flat org units. Example seeds from <code>COLLECTIONS.md:1520-1533</code>: Administration, Science, Commerce, Accounts, HR, Library, Transport. Fields: <code>name</code>, <code>code</code>, <code>headId</code> (ref <code>Staff</code>), <code>status</code> (<code>department.schema.ts:9-19</code>).</li> <li><strong>Designations</strong> (<code>designations</code> collection): job titles. Example seeds from <code>COLLECTIONS.md:1562-1573</code>: Principal, Vice Principal, Teacher, Librarian, Receptionist, Accountant. Fields: <code>departmentId?</code> (ref <code>Department</code>), <code>name</code>, <code>level</code> (numeric rank), <code>status</code> (<code>designation.schema.ts:9-19</code>).</li> </ul> <p>Designations optionally link to a department (<code>designation.schema.ts:9-10</code>) — the link is a <strong>reference only, not enforced</strong> (see OQ-3).</p> <h2 id="3-staff-lifecycle-end-to-end"><a class="header" href="#3-staff-lifecycle-end-to-end">3. Staff lifecycle (end-to-end)</a></h2> <pre><code>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) </code></pre> <ul> <li>Create always sets <code>status: StaffStatus.ACTIVE</code> (<code>staff.service.ts:39</code>) — the create DTO <strong>cannot</strong> set status.</li> <li><code>employmentType</code> defaults to <code>full_time</code> when omitted (<code>staff.service.ts:40-41</code>).</li> <li>Deactivation is <strong>permanent from the client's perspective</strong>: <code>DELETE</code> soft-deletes (<code>base.repository.ts:68-74</code>) and no re-activation endpoint exists.</li> <li>Plan test rows: PLAN.md:38 (3.3 "Invite staff member (non-teaching) → email sent" via <code>POST /api/v1/staff</code> → <code>StaffCreated</code>), PLAN.md:40 (3.5 "Deactivate staff member → soft-delete" → <code>StaffDeleted</code> event, queries exclude).</li> </ul> <h2 id="4-department--designation-catalog-lifecycle"><a class="header" href="#4-department--designation-catalog-lifecycle">4. Department & designation catalog lifecycle</a></h2> <pre><code>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) </code></pre> <p>Catalogs are <strong>status-flagged, not deleted from history</strong>: <code>status</code> defaults to <code>'active'</code> on create (<code>department.service.ts:28</code>, <code>designation.service.ts:31</code>); <code>DELETE</code> sets <code>isDeleted</code> so the row disappears from all lists (<code>base.repository.ts:20-30,68-74</code>). No events are emitted by either service — <strong>no <code>DepartmentCreated</code>/<code>DesignationCreated</code> events exist in the event map</strong> (<code>event-queue-map.ts</code> has no department/designation entries; both services inject only their repository, <code>department.service.ts:19-20</code>, <code>designation.service.ts:22-23</code>).</p> <h2 id="5-role--permission-mapping"><a class="header" href="#5-role--permission-mapping">5. Role & permission mapping</a></h2> <p>Permissions exist in <code>permissions.constants.ts:19-24</code>:</p> <div class="table-wrapper"><table><thead><tr><th>Permission</th><th>Intended scope</th><th>Notes</th></tr></thead><tbody> <tr><td><code>staff.read</code></td><td>View staff list/detail</td><td></td></tr> <tr><td><code>staff.create</code></td><td>Create staff profile</td><td></td></tr> <tr><td><code>staff.update</code></td><td>Edit staff profile</td><td></td></tr> <tr><td><code>staff.delete</code></td><td>Deactivate staff</td><td></td></tr> <tr><td><code>department.manage</code></td><td>Manage department catalog</td><td>single perm covers CUD</td></tr> <tr><td><code>designation.manage</code></td><td>Manage designation catalog</td><td>single perm covers CUD</td></tr> </tbody></table> </div> <p><strong>Default role wiring</strong> (<code>role.schema.ts</code>):</p> <div class="table-wrapper"><table><thead><tr><th>Role</th><th>Slug</th><th>Permissions relevant to Staff module</th></tr></thead><tbody> <tr><td>Organization Admin</td><td><code>org_admin</code></td><td><code>ALL_PERMISSIONS</code> — includes all six above (<code>role.schema.ts:17-24</code>)</td></tr> <tr><td>Staff</td><td><code>staff</code></td><td><code>['student.read']</code> only — <strong>no staff module perms</strong> (<code>role.schema.ts:33-40</code>)</td></tr> <tr><td>Teacher / Accountant / Parent / Student</td><td>—</td><td>none of the six (<code>role.schema.ts:25-48</code>)</td></tr> </tbody></table> </div> <p>Consequences for the client:</p> <ul> <li>The default <strong>Staff</strong> role cannot open the Staff section; HR/Admin must grant <code>staff.*</code> perms via a custom RBAC role (<code>rbac</code> module) before the section is usable.</li> <li><strong>Server enforcement gap:</strong> staff controllers declare only <code>@UseGuards(JwtAuthGuard)</code> (<code>staff.controller.ts:21</code>); no <code>@Permissions()</code> decorators are applied, unlike webhooks/search/files/scheduler (<code>webhooks.controller.ts:22</code>, <code>search.controller.ts:16</code>). The <code>RbacGuard</code> exists (<code>rbac.guard.ts:14-52</code>) and is wired for other modules. Until staff controllers adopt it, permission checks are <strong>client-enforced only</strong> (mirror of <code>permissions.constants.ts</code>); treat server-side RBAC on these endpoints as <code>(planned)</code> — OQ-1.</li> </ul> <h2 id="6-events--downstream-consumers"><a class="header" href="#6-events--downstream-consumers">6. Events & downstream consumers</a></h2> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Queue / job (<code>event-queue-map.ts</code>)</th><th>Consumer intent</th></tr></thead><tbody> <tr><td><code>StaffCreated</code></td><td><code>in-app</code> / <code>staff-created</code> (line 34)</td><td>In-app notification fan-out</td></tr> <tr><td><code>StaffUpdated</code></td><td><code>audit-write</code> / <code>log-staff-updated</code> (line 35)</td><td>Audit trail</td></tr> <tr><td><code>StaffDeleted</code></td><td><code>audit-write</code> / <code>log-staff-deleted</code> (line 36)</td><td>Audit trail</td></tr> <tr><td><code>StaffCreated/Updated/Deleted</code></td><td>Search indexer <code>ENTITY_EVENTS</code> (<code>search-indexer.service.ts:11,18,25</code>)</td><td>Search index upsert/remove</td></tr> </tbody></table> </div> <p>Payloads: <code>StaffCreated</code> = <code>{staffId, employeeNumber}</code> (<code>staff.service.ts:50-53</code>); <code>StaffUpdated</code> = <code>{staffId, changes: Object.keys(dto)}</code> (line 88); <code>StaffDeleted</code> = <code>{staffId}</code> (line 102).</p> <p><strong>Observed gap (flag):</strong> the search indexer resolves entity ids from <code>payload.entityId ?? payload._id ?? ...</code> (<code>search-indexer.service.ts:62-67</code>) and titles from <code>payload.name/title/firstName/...</code> (lines 68-75). Staff payloads carry <code>staffId</code> (not <code>entityId/_id</code>) and <code>employeeNumber</code> (not <code>name</code>), so staff creates exit early at <code>search-indexer.service.ts:88</code> — <strong>staff records are not indexed</strong>. Verified in OQ-6.</p> <p><strong>Email:</strong> the <code>emails</code> queue worker handles only <code>UserRegistered</code> and <code>PasswordResetRequested</code> (<code>email.worker.ts:25-42</code>); <code>StaffCreated</code> logs "No handler for email event" (line 41). PLAN.md:38's "email sent" is satisfied at <strong>user invitation</strong> time (UserRegistered), not at staff-profile creation — the client must not promise a staff-creation email.</p> <h2 id="7-data-model-summary"><a class="header" href="#7-data-model-summary">7. Data model summary</a></h2> <p>All three collections extend <code>BaseSchema</code> (<code>base.schema.ts:8-35</code>): <code>tenantId</code> (required), <code>createdBy/updatedBy/deletedBy</code>, <code>isDeleted</code> (soft-delete, default false), <code>deletedAt</code>, <code>version</code> (optimistic lock, <code>$inc</code> on every write — <code>base.repository.ts:63,71</code>), <code>createdAt</code>, <code>updatedAt</code>. Every repository query injects <code>tenantId</code> + <code>isDeleted: false</code> (<code>base.repository.ts:20-30</code>); platform admin bypasses tenant scope but never sees deleted rows.</p> <div class="table-wrapper"><table><thead><tr><th>Collection</th><th>Unique index</th><th>Ref fields</th></tr></thead><tbody> <tr><td><code>staff</code></td><td><code>{tenantId, employeeNumber}</code>; <code>{tenantId, userId}</code> (<code>staff.schema.ts:57-58</code>)</td><td><code>userId</code>→User (required), <code>departmentId</code>→Department, <code>designationId</code>→Designation</td></tr> <tr><td><code>departments</code></td><td><code>{tenantId, name}</code> (<code>department.schema.ts:24</code>)</td><td><code>headId</code>→Staff</td></tr> <tr><td><code>designations</code></td><td><code>{tenantId, name}</code> (<code>designation.schema.ts:24</code>)</td><td><code>departmentId</code>→Department</td></tr> </tbody></table> </div> <p><code>metadata</code> is an open <code>Object</code> on staff (<code>staff.schema.ts:51-52</code>; <code>update-staff.dto.ts:42</code>) — any key/value passthrough, unvalidated.</p> <h2 id="8-dependencies"><a class="header" href="#8-dependencies">8. Dependencies</a></h2> <ul> <li><strong>Users</strong> — identity link (<code>userId</code>, <code>Staff.md:49</code>). A staff record requires an existing <code>userId</code> (<code>create-staff.dto.ts:5-7</code>).</li> <li><strong>RBAC</strong> — permission gating (<code>Staff.md:50</code>).</li> <li><strong>Attendance / Leave / Payroll</strong> — <code>(planned)</code> future consumers (<code>Staff.md:51-52</code>; salary grade is a reference, not payroll — <code>Staff.md:60</code>).</li> </ul> <h2 id="9-edge-cases--invariants"><a class="header" href="#9-edge-cases--invariants">9. Edge cases & invariants</a></h2> <ul> <li>Duplicate <code>employeeNumber</code> within tenant → 409 <code>DUPLICATE_RESOURCE</code> (<code>staff.service.ts:32-36</code>); duplicate <code>userId</code> within tenant → unique index violation → 409 (same code path via <code>http-exception.filter.ts:32</code>).</li> <li>Duplicate department/designation <code>name</code> within tenant → 409 (<code>department.service.ts:23-25</code>, <code>designation.service.ts:25-28</code>).</li> <li>Unknown/invalid <code>:id</code> → 404 <code>RESOURCE_NOT_FOUND</code> (<code>staff.service.ts:60</code>; <code>http-exception.filter.ts:47-48,91-92</code> maps CastError → 400).</li> <li>Cross-tenant IDs: repository tenant-scoping makes them return 404, never leak (<code>base.repository.ts:20-30</code>).</li> <li>Soft-deleted staff never reappear in lists; a second <code>DELETE</code> on a deleted record → 404 (<code>staff.service.ts:94-95</code>).</li> <li>Page/limit: <code>page</code> ≥ 1, <code>limit</code> 1–100 default 20 (<code>pagination-query.dto.ts:5-30</code>). <code>sort</code>/<code>q</code> are accepted by the DTO but <strong>not applied</strong> by staff list queries (<code>staff.service.ts:64-76</code> — see OQ-2).</li> </ul> <h2 id="10-prd--mobile-forward-note"><a class="header" href="#10-prd--mobile-forward-note">10. PRD & mobile-forward note</a></h2> <p>The PRD puts native mobile apps out of Phase 1 scope (<code>PRODUCT_REQUIREMENTS_DOCUMENT.md:144</code>) — per the shared-package decision (<code>00-shared/01 §9</code>, <code>00-shared/12 A1</code>) these docs specify a full-featured Flutter client to the complete API surface. For Staff specifically, mobile matters most for <strong>HR on the go</strong>: 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 <code>00-shared/02 §8</code>.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-staff-module"><a class="header" href="#02--user-personas-staff-module">02 — User Personas (Staff Module)</a></h1> <ul> <li><a href="staff/02_User_Personas.html#p1--org-admin-primary-actor">P1 — Org Admin (primary actor)</a></li> <li><a href="staff/02_User_Personas.html#p2--hr-manager-primary-daily-actor">P2 — HR Manager (primary daily actor)</a></li> <li><a href="staff/02_User_Personas.html#p3--staff-member-self-service-read-mostly">P3 — Staff Member (self-service, read-mostly)</a></li> <li><a href="staff/02_User_Personas.html#p4--accountant--receptionist-as-staff-types">P4 — Accountant / Receptionist as staff types</a></li> <li><a href="staff/02_User_Personas.html#p5--designationdepartment-viewer-cross-module-proposed">P5 — Designation/Department viewer (cross-module <code>(proposed)</code>)</a></li> </ul> <blockquote> <p>Personas for the Staff module, derived from the blueprint personas (<code>studylyon-blueprint/01-Product/USER_PERSONAS.md</code>), the default role catalog (<code>role.schema.ts</code>), and the module's permission set (<code>permissions.constants.ts:19-24</code>). <code>(proposed)</code> marks behavioral detail not derivable from source.</p> </blockquote> <h2 id="p1--org-admin-primary-actor"><a class="header" href="#p1--org-admin-primary-actor">P1 — Org Admin (primary actor)</a></h2> <blockquote> <p>"Configure branches, invite staff, oversee operations." (<code>USER_PERSONAS.md:22-25</code>)</p> </blockquote> <ul> <li><strong>Who:</strong> tenant owner/principal who runs the institution; has the <code>org_admin</code> role with <code>ALL_PERMISSIONS</code> (<code>role.schema.ts:17-24</code>), so all six staff permissions (<code>permissions.constants.ts:19-24</code>).</li> <li><strong>Goals:</strong> <ul> <li>Stand up the org structure once: create departments, then designations (<code>department.controller.ts:26-43</code>, <code>designation.controller.ts:26-43</code>).</li> <li>Invite non-teaching staff: create the profile against an existing user (<code>create-staff.dto.ts:5-7</code>), watch the <code>StaffCreated</code> event fan out (<code>event-queue-map.ts:34</code>).</li> <li>Deactivate leavers so they drop out of every query (<code>base.repository.ts:20-30,68-74</code>).</li> </ul> </li> <li><strong>Frustrations:</strong> duplicate employee numbers causing 409s (<code>staff.service.ts:32-36</code>); forgetting a leaver and having them still appear.</li> <li><strong>Success metric:</strong> onboarding a new hire in < 2 minutes; deactivating in < 30 s.</li> </ul> <h2 id="p2--hr-manager-primary-daily-actor"><a class="header" href="#p2--hr-manager-primary-daily-actor">P2 — HR Manager (primary daily actor)</a></h2> <blockquote> <p>A custom RBAC role is the only way to grant non-admin staff management — the default Staff role has just <code>student.read</code> (<code>role.schema.ts:33-40</code>). The HR manager is a <code>(proposed)</code> concrete persona for "user with <code>staff.*</code> permissions" — the <code>rbac</code> module creates it (<code>rbac.controller.ts</code>), but no default role carries these perms except <code>org_admin</code>.</p> </blockquote> <ul> <li><strong>Who:</strong> runs employee records day-to-day: onboarding, transfers, status changes.</li> <li><strong>Permissions used:</strong> <code>staff.read</code>, <code>staff.create</code>, <code>staff.update</code>, <code>staff.delete</code>, <code>department.manage</code>, <code>designation.manage</code> (<code>permissions.constants.ts:19-24</code>).</li> <li><strong>Goals:</strong> <ul> <li>Find any employee by employee number or name — <strong>search is not server-side</strong> for staff lists (<code>staff.service.ts:64-76</code>, OQ-2); HR relies on pagination + client-side filtering.</li> <li>Maintain employment terms: <code>employmentType</code>, <code>joiningDate</code>, <code>salaryGrade</code>, <code>departmentId</code>, <code>designationId</code> (<code>update-staff.dto.ts:6-33</code>).</li> <li>Set status explicitly (<code>active/inactive/on_leave/terminated</code> via <code>update-staff.dto.ts:35-38</code>; enum <code>staff.schema.ts:7-12</code>) — e.g., "on_leave" for sabbaticals without deleting.</li> <li>Keep the department catalog tidy (rename, recode, set <code>headId</code> <code>department.dto.ts:20-39</code>) and the designation catalog ranked (<code>level</code> <code>designation.dto.ts:14-17</code>).</li> </ul> </li> <li><strong>Frustrations:</strong> no bulk import endpoint for staff (<code>Staff.md:17</code> lists bulk import as a responsibility — <strong>not implemented</strong>, OQ-5); client-side-only filtering on big rosters.</li> </ul> <h2 id="p3--staff-member-self-service-read-mostly"><a class="header" href="#p3--staff-member-self-service-read-mostly">P3 — Staff Member (self-service, read-mostly)</a></h2> <ul> <li><strong>Who:</strong> non-teaching employee with the default <code>staff</code> role — <code>['student.read']</code> (<code>role.schema.ts:33-40</code>). <strong>Cannot see the Staff module</strong> without an RBAC grant (OQ-1) and has no profile-self-edit endpoint: <code>update-staff.dto.ts</code> has no self-scoping; PATCH is by <code>:id</code> (<code>staff.controller.ts:33</code>).</li> <li><strong>Goals <code>(proposed)</code>:</strong> 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).</li> <li><strong>Reality check:</strong> the backend offers <strong>no self-service staff endpoint</strong>. If the product needs self-view, it must be built on <code>GET /staff/:id</code> guarded client-side (any caller with <code>staff.read</code> can read any staff record) — flag to product (OQ-7).</li> </ul> <h2 id="p4--accountant--receptionist-as-staff-types"><a class="header" href="#p4--accountant--receptionist-as-staff-types">P4 — Accountant / Receptionist as staff types</a></h2> <ul> <li><strong>Who:</strong> two of the module's actual designated staff: the blueprint seeds <code>Accountant</code> and <code>Receptionist</code> designations (<code>COLLECTIONS.md:1571-1572</code>) and departments <code>Accounts</code> (<code>COLLECTIONS.md:1529</code>). These are <strong>staff profiles</strong>, not separate roles: their login roles are <code>accountant</code> (<code>role.schema.ts:41-48</code>, permissions <code>fees.collect</code>, <code>student.read</code>) or <code>staff</code> — the designation is data, the role is RBAC.</li> <li><strong>Goals:</strong> <ul> <li>Accountant: fee desk work (Fees module) while being listed under <code>Accounts</code> department with designation <code>Accountant</code> for the org chart.</li> <li>Receptionist: front-desk support (CRM/leads <a href="staff/05_Screen_Inventory.html">05_Global_Information_Architecture.md:38</a>), reachable in the staff directory.</li> </ul> </li> <li><strong>Key UX insight:</strong> department/designation pickers must offer the seeded catalogs immediately (<code>COLLECTIONS.md:1520-1533,1562-1573</code>) and stay empty-state friendly when an org hasn't configured them yet.</li> </ul> <h2 id="p5--designationdepartment-viewer-cross-module-proposed"><a class="header" href="#p5--designationdepartment-viewer-cross-module-proposed">P5 — Designation/Department viewer (cross-module <code>(proposed)</code>)</a></h2> <ul> <li>Teachers reference the same <code>departments</code>/<code>designations</code> catalogs (<code>teacher.schema.ts:22-26</code>), but the catalogs are <strong>owned by this module</strong> (<code>Staff.md:59</code>). 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.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journeys-staff-module"><a class="header" href="#03--user-journeys-staff-module">03 — User Journeys (Staff Module)</a></h1> <ul> <li><a href="staff/03_User_Journey.html#j1--create-a-staff-member-org-admin--hr">J1 — Create a staff member (Org Admin / HR)</a></li> <li><a href="staff/03_User_Journey.html#j2--manage-departments-org-admin--hr">J2 — Manage departments (Org Admin / HR)</a></li> <li><a href="staff/03_User_Journey.html#j3--manage-designations-org-admin--hr">J3 — Manage designations (Org Admin / HR)</a></li> <li><a href="staff/03_User_Journey.html#j4--edit-a-staff-profile-hr">J4 — Edit a staff profile (HR)</a></li> <li><a href="staff/03_User_Journey.html#j5--deactivate-a-staff-member-hr--org-admin">J5 — Deactivate a staff member (HR / Org Admin)</a></li> <li><a href="staff/03_User_Journey.html#j6--browse-the-staff-directory-read-only-proposed">J6 — Browse the staff directory (read-only <code>(proposed)</code>)</a></li> </ul> <blockquote> <p>End-to-end journeys mapped to the exact API surface. All backend behavior cited from source; flows marked <code>(proposed)</code> for client-only decisions.</p> </blockquote> <h2 id="j1--create-a-staff-member-org-admin--hr"><a class="header" href="#j1--create-a-staff-member-org-admin--hr">J1 — Create a staff member (Org Admin / HR)</a></h2> <p>Backend steps: <code>POST /staff</code> (<code>staff.controller.ts:24</code>) → duplicate guard (<code>staff.service.ts:31-36</code>) → create with <code>status: ACTIVE</code>, <code>employmentType</code> defaulting to <code>full_time</code> (<code>staff.service.ts:37-43</code>) → <code>StaffCreated</code> event (<code>staff.service.ts:44-54</code> → <code>in-app</code> queue, <code>event-queue-map.ts:34</code>).</p> <pre class="mermaid">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] </pre> <ul> <li>Form fields: <code>userId</code> (required picker), <code>employeeNumber</code> (required), <code>departmentId</code>, <code>designationId</code>, <code>employmentType</code>, <code>joiningDate</code>, <code>salaryGrade</code> (<code>create-staff.dto.ts:5-38</code>).</li> <li><code>status</code> is <strong>not settable at creation</strong> (<code>staff.service.ts:39</code>).</li> <li>Haptic: <code>lightImpact</code> on success (<code>00-shared/08 §3</code>).</li> </ul> <h2 id="j2--manage-departments-org-admin--hr"><a class="header" href="#j2--manage-departments-org-admin--hr">J2 — Manage departments (Org Admin / HR)</a></h2> <p>Backend steps: <code>POST /departments</code> duplicate-name guard (<code>department.service.ts:22-30</code>); list via <code>GET /departments</code> (<code>department.controller.ts:29</code>); update <code>PATCH /departments/:id</code>; delete <code>DELETE /departments/:id</code> (soft). No events emitted by the catalog services (<code>department.service.ts:19-20</code>).</p> <pre class="mermaid">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] </pre> <ul> <li><code>headId</code> picker sources staff (<code>department.schema.ts:15-16</code>); a soft-deleted head renders as unlinked (<code>(proposed)</code> — backend stores the bare ref).</li> <li>Dept→staff counts and "reassign members before deleting" are <strong>not enforced</strong> server-side — see OQ-4; the client must present the risk in the confirm dialog.</li> </ul> <h2 id="j3--manage-designations-org-admin--hr"><a class="header" href="#j3--manage-designations-org-admin--hr">J3 — Manage designations (Org Admin / HR)</a></h2> <p>Backend steps: <code>POST /designations</code> duplicate-name guard (<code>designation.service.ts:25-28</code>); <code>level</code> is <code>IsInt</code> (<code>designation.dto.ts:14-17</code>); optional <code>departmentId</code> (<code>designation.schema.ts:9-10</code>).</p> <pre class="mermaid">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] </pre> <ul> <li><code>level</code> defaults to <code>0</code> (<code>designation.schema.ts:15-16</code>); sort-by-level is <code>(proposed)</code> — the API returns insertion order (<code>designation.service.ts:41-50</code>).</li> </ul> <h2 id="j4--edit-a-staff-profile-hr"><a class="header" href="#j4--edit-a-staff-profile-hr">J4 — Edit a staff profile (HR)</a></h2> <p>Backend steps: <code>PATCH /staff/:id</code> (<code>staff.controller.ts:33</code>) → existence check 404 (<code>staff.service.ts:79</code>) → <code>$set</code> + <code>version++</code> (<code>base.repository.ts:57-66</code>) → <code>StaffUpdated</code> with changed keys (<code>staff.service.ts:82-90</code>).</p> <pre class="mermaid">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] </pre> <ul> <li>All fields optional on update (<code>update-staff.dto.ts:5-42</code>) — send only changed fields; <code>changes</code> list is the audit delta (<code>staff.service.ts:88</code>).</li> <li><code>status</code> may be set here — this is the sanctioned way to move a member to <code>on_leave</code>/<code>inactive</code>/<code>terminated</code> (<code>update-staff.dto.ts:35-38</code>).</li> </ul> <h2 id="j5--deactivate-a-staff-member-hr--org-admin"><a class="header" href="#j5--deactivate-a-staff-member-hr--org-admin">J5 — Deactivate a staff member (HR / Org Admin)</a></h2> <p>Backend steps: <code>DELETE /staff/:id</code> (<code>staff.controller.ts:36</code>) → soft delete sets <code>isDeleted + deletedAt + deletedBy</code> and bumps <code>version</code> (<code>base.repository.ts:68-74</code>) → 404 when already deleted (<code>staff.service.ts:94-95</code>) → <code>StaffDeleted</code> audit event (<code>staff.service.ts:96-103</code>).</p> <pre class="mermaid">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] </pre> <ul> <li><strong>No restore path</strong> exists (no un-delete endpoint) — the dialog must say so (<code>base.repository.ts:68-74</code>; OQ-8 for a future restore).</li> <li>The list refetches because soft-deleted rows are excluded from every query (<code>base.repository.ts:20-30</code>).</li> </ul> <h2 id="j6--browse-the-staff-directory-read-only-proposed"><a class="header" href="#j6--browse-the-staff-directory-read-only-proposed">J6 — Browse the staff directory (read-only <code>(proposed)</code>)</a></h2> <p><code>GET /staff?page=&limit=</code> (<code>staff.controller.ts:27</code>) — no server-side <code>q</code>/<code>sort</code> for staff (<code>staff.service.ts:64-76</code>, 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 <code>StaffStatus</code> (<code>staff.schema.ts:7-12</code>).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-staff-module"><a class="header" href="#04--information-architecture-staff-module">04 — Information Architecture (Staff Module)</a></h1> <ul> <li><a href="staff/04_Information_Architecture.html#1-position-in-the-global-ia">1. Position in the global IA</a></li> <li><a href="staff/04_Information_Architecture.html#2-staff-module-map">2. Staff module map</a></li> <li><a href="staff/04_Information_Architecture.html#3-entry-points">3. Entry points</a></li> <li><a href="staff/04_Information_Architecture.html#4-information-hierarchy-of-a-staff-record">4. Information hierarchy of a staff record</a></li> <li><a href="staff/04_Information_Architecture.html#5-navigation-model-per-shared-shell">5. Navigation model (per shared shell)</a></li> <li><a href="staff/04_Information_Architecture.html#6-screen-inventory-short-form">6. Screen inventory (short form)</a></li> <li><a href="staff/04_Information_Architecture.html#7-information-hygiene-rules">7. Information hygiene rules</a></li> </ul> <blockquote> <p>Where Staff screens live in the global shell (<a href="staff/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a>) and how they nest. Navigation/routing decisions not fixed by backend are <code>(proposed)</code>; the permission gate is the client mirror of <code>permissions.constants.ts:19-24</code> (server RBAC on these endpoints is not wired — <code>staff.controller.ts:21</code>, OQ-1).</p> </blockquote> <h2 id="1-position-in-the-global-ia"><a class="header" href="#1-position-in-the-global-ia">1. Position in the global IA</a></h2> <p>Global destination table: <strong>Staff → <code>/staff</code> → roles: admin, HR</strong> (<a href="staff/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §2</a>). Phone: bottom <code>NavigationBar</code> (≤ 5 destinations) with Staff under the drawer when not a primary tab; tablet/desktop: <code>NavigationRail</code> (<a href="staff/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §3</a>).</p> <h2 id="2-staff-module-map"><a class="header" href="#2-staff-module-map">2. Staff module map</a></h2> <pre><code>/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 </code></pre> <p>Route constants mirror the API nouns exactly (<code>staff.controller.ts:20</code>, <code>department.controller.ts:22</code>, <code>designation.controller.ts:22</code>).</p> <h2 id="3-entry-points"><a class="header" href="#3-entry-points">3. Entry points</a></h2> <div class="table-wrapper"><table><thead><tr><th>From</th><th>To</th><th>Why</th></tr></thead><tbody> <tr><td>Dashboard</td><td><code>/staff</code></td><td>Org chart / roster overview <code>(proposed)</code></td></tr> <tr><td>Navigation (admin/HR)</td><td><code>/staff</code></td><td>Primary management surface (<code>00-shared/05 §2</code>)</td></tr> <tr><td>Users detail</td><td><code>/staff</code> (filtered)</td><td>Link <code>userId</code> → staff profile <code>(proposed)</code> — users and staff are separate collections (<code>RELATIONSHIPS.md:28</code>)</td></tr> <tr><td>Teachers detail</td><td><code>/departments/:id</code></td><td>Teacher references the same catalogs (<code>teacher.schema.ts:22-26</code>)</td></tr> <tr><td>Global search (when backend supports staff)</td><td><code>/staff/:id</code></td><td>Search indexer currently skips staff (OQ-6)</td></tr> </tbody></table> </div> <h2 id="4-information-hierarchy-of-a-staff-record"><a class="header" href="#4-information-hierarchy-of-a-staff-record">4. Information hierarchy of a staff record</a></h2> <ol> <li><strong>Identity anchor:</strong> <code>employeeNumber</code> — unique per tenant (<code>staff.schema.ts:57</code>) and the label users type/quote.</li> <li><strong>Org placement:</strong> <code>departmentId</code> → <code>designationId</code> (both optional refs, <code>staff.schema.ts:29-33</code>).</li> <li><strong>Employment terms:</strong> <code>employmentType</code> (enum, default <code>full_time</code>), <code>joiningDate</code>, <code>salaryGrade</code> (<code>staff.schema.ts:35-47</code>).</li> <li><strong>Lifecycle:</strong> <code>status</code> enum (<code>staff.schema.ts:7-12,48-49</code>).</li> <li><strong>System meta:</strong> <code>version</code>, <code>createdAt</code>, <code>updatedAt</code> (<code>base.schema.ts:29-34</code>) — displayed in an "Audit" tab <code>(proposed)</code>; <code>metadata</code> passthrough (<code>staff.schema.ts:51-52</code>).</li> </ol> <h2 id="5-navigation-model-per-shared-shell"><a class="header" href="#5-navigation-model-per-shared-shell">5. Navigation model (per shared shell)</a></h2> <ul> <li><strong>Phone:</strong> list → push detail (stack); tabs inside detail; FAB per screen (<code>00-shared/05 §6-7</code>).</li> <li><strong>Tablet ≥ 840 dp:</strong> master-detail — list left, detail right (<code>00-shared/04 §6</code>, <code>00-shared/05 §3</code>).</li> <li><strong>Desktop:</strong> same master-detail + breadcrumbs (<code>Staff / Administration / HR</code>) at depth > 2 (<code>00-shared/05 §3</code>).</li> <li><strong>Deep links <code>(proposed)</code>:</strong> <code>studylyon://staff/:id</code>, <code>studylyon://departments/:id</code>, <code>studylyon://designations/:id</code>.</li> </ul> <h2 id="6-screen-inventory-short-form"><a class="header" href="#6-screen-inventory-short-form">6. Screen inventory (short form)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Screen</th><th>Route</th><th>Perm</th><th>Backend source</th></tr></thead><tbody> <tr><td>S1</td><td>Staff list</td><td><code>/staff</code></td><td><code>staff.read</code></td><td><code>staff.controller.ts:27-28</code></td></tr> <tr><td>S2</td><td>Staff detail</td><td><code>/staff/:id</code></td><td><code>staff.read</code></td><td><code>staff.controller.ts:30-31</code></td></tr> <tr><td>S3</td><td>Create staff</td><td><code>/staff/new</code></td><td><code>staff.create</code></td><td><code>staff.controller.ts:24-25</code></td></tr> <tr><td>S4</td><td>Edit staff</td><td><code>/staff/:id/edit</code></td><td><code>staff.update</code></td><td><code>staff.controller.ts:33-34</code></td></tr> <tr><td>S5</td><td>Deactivate flow</td><td>modal on S1/S2</td><td><code>staff.delete</code></td><td><code>staff.controller.ts:36-37</code></td></tr> <tr><td>S6</td><td>Department list</td><td><code>/departments</code></td><td><code>department.manage</code></td><td><code>department.controller.ts:29-30</code></td></tr> <tr><td>S7</td><td>Department detail</td><td><code>/departments/:id</code></td><td><code>department.manage</code></td><td><code>department.controller.ts:32-33</code></td></tr> <tr><td>S8</td><td>Department form (create/edit)</td><td><code>/departments/new</code>, <code>/departments/:id/edit</code></td><td><code>department.manage</code></td><td><code>department.controller.ts:26-27,35-40</code></td></tr> <tr><td>S9</td><td>Designation list</td><td><code>/designations</code></td><td><code>designation.manage</code></td><td><code>designation.controller.ts:29-30</code></td></tr> <tr><td>S10</td><td>Designation detail</td><td><code>/designations/:id</code></td><td><code>designation.manage</code></td><td><code>designation.controller.ts:32-33</code></td></tr> <tr><td>S11</td><td>Designation form (create/edit)</td><td><code>/designations/new</code>, <code>/designations/:id/edit</code></td><td><code>designation.manage</code></td><td><code>designation.controller.ts:26-27,35-40</code></td></tr> </tbody></table> </div> <p>Full detail in <code>05_Screen_Inventory.md</code>, <code>06_Screen_Specifications.md</code>.</p> <h2 id="7-information-hygiene-rules"><a class="header" href="#7-information-hygiene-rules">7. Information hygiene rules</a></h2> <ul> <li>Employee number and names display in <code>mono</code> / tabular figures (<code>00-shared/02 §2</code>).</li> <li>Status is never color-only: badge + icon + label (<code>00-shared/09 §9</code>).</li> <li>Missing optional refs (no department/designation) render "—" with an "assign" affordance, never a broken ref.</li> <li>Deleted heads (<code>headId</code> refs a soft-deleted staff) render as unassigned <code>(proposed)</code> — server stores the bare ObjectId (<code>department.schema.ts:15-16</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-staff-module"><a class="header" href="#05--screen-inventory-staff-module">05 — Screen Inventory (Staff Module)</a></h1> <ul> <li><a href="staff/05_Screen_Inventory.html#s1--staff-list-staff">S1 — Staff List (<code>/staff</code>)</a></li> <li><a href="staff/05_Screen_Inventory.html#s2--staff-detail-staffid">S2 — Staff Detail (<code>/staff/:id</code>)</a></li> <li><a href="staff/05_Screen_Inventory.html#s3--create-staff-staffnew">S3 — Create Staff (<code>/staff/new</code>)</a></li> <li><a href="staff/05_Screen_Inventory.html#s4--edit-staff-staffidedit">S4 — Edit Staff (<code>/staff/:id/edit</code>)</a></li> <li><a href="staff/05_Screen_Inventory.html#s5--deactivate-flow-modal-from-s1s2">S5 — Deactivate Flow (modal, from S1/S2)</a></li> <li><a href="staff/05_Screen_Inventory.html#s6--department-list-departments">S6 — Department List (<code>/departments</code>)</a></li> <li><a href="staff/05_Screen_Inventory.html#s7--department-detail-departmentsid">S7 — Department Detail (<code>/departments/:id</code>)</a></li> <li><a href="staff/05_Screen_Inventory.html#s8--department-form-create--edit">S8 — Department Form (create + edit)</a></li> <li><a href="staff/05_Screen_Inventory.html#s9--designation-list-designations">S9 — Designation List (<code>/designations</code>)</a></li> <li><a href="staff/05_Screen_Inventory.html#s10--designation-detail-designationsid">S10 — Designation Detail (<code>/designations/:id</code>)</a></li> <li><a href="staff/05_Screen_Inventory.html#s11--designation-form-create--edit">S11 — Designation Form (create + edit)</a></li> <li><a href="staff/05_Screen_Inventory.html#cross-screen-state-rules-shared-00-shared06-31">Cross-screen state rules (shared 00-shared/06 §3.1)</a></li> </ul> <blockquote> <p>Eleven screens across three route groups. All backend-facing behavior cited from <code>src/modules/staff/**</code>. Layout, copy, and client behaviors follow the shared tokens and components (<a href="staff/../00-shared/02_Design_Tokens.html">00-shared/02</a>, <a href="staff/03_User_Journey.html">03</a>, <code>05</code>). Screens are <code>(planned)</code> client-side; the API surface they map to is implemented.</p> </blockquote> <h2 id="s1--staff-list-staff"><a class="header" href="#s1--staff-list-staff">S1 — Staff List (<code>/staff</code>)</a></h2> <ul> <li><strong>Purpose:</strong> browse the non-teaching roster; entry to detail; quick actions.</li> <li><strong>Data:</strong> <code>GET /staff?page=&limit=</code> (<code>staff.controller.ts:27-28</code>); response <code>data: Staff[]</code>, <code>meta</code> (<code>staff.service.ts:64-76</code>; envelope <code>response-envelope.interceptor.ts:20-32,55-57</code>).</li> <li><strong>List row:</strong> avatar (initials from user — staff profile has <strong>no name field</strong>; join with users for display, <code>(proposed)</code>), <code>employeeNumber</code> (title), department/designation (subtitle), <code>status</code> badge + overflow menu (<code>staff.schema.ts:22-52</code>).</li> <li><strong>Filters <code>(proposed)</code>:</strong> client-side chips for <code>status</code> (exact enum <code>staff.schema.ts:7-12</code>) and <code>employmentType</code> (<code>staff.schema.ts:14-19</code>); search box maps to <code>q</code> param which the server <strong>accepts but ignores</strong> for staff (<code>staff.service.ts:64-76</code>, OQ-2) — label "filtering loaded results".</li> <li><strong>Pagination:</strong> page/limit controls (shared contract <a href="staff/../00-shared/07_API_Conventions.html">00-shared/07 §5</a>); infinite scroll on phone <code>(proposed)</code>; <code>meta</code> drives controls (<code>pagination-query.dto.ts:41-55</code>).</li> <li><strong>Actions:</strong> FAB "Add staff" (<code>staff.create</code>), row menu: View, Edit, Deactivate (<code>staff.delete</code>).</li> <li><strong>Empty:</strong> "No staff yet" + CTA; with filters: "No staff match filters".</li> </ul> <h2 id="s2--staff-detail-staffid"><a class="header" href="#s2--staff-detail-staffid">S2 — Staff Detail (<code>/staff/:id</code>)</a></h2> <ul> <li><strong>Data:</strong> <code>GET /staff/:id</code> (<code>staff.controller.ts:30-31</code>); 404 → not-found empty-state (<code>staff.service.ts:58-62</code>).</li> <li><strong>Header:</strong> avatar, employee number, name (joined from users <code>(proposed)</code>), status badge, edit + deactivate actions.</li> <li><strong>Tabs <code>(proposed)</code>:</strong> Overview | Employment | Metadata | Audit (see <a href="staff/06_Screen_Specifications.html">06</a>).</li> <li><strong>Related info:</strong> department and designation resolve via <code>GET /departments/:id</code>, <code>GET /designations/:id</code> — the detail payload does not embed them (<code>staff.schema.ts:29-33</code>; repositories store refs only).</li> </ul> <h2 id="s3--create-staff-staffnew"><a class="header" href="#s3--create-staff-staffnew">S3 — Create Staff (<code>/staff/new</code>)</a></h2> <ul> <li><strong>Data:</strong> <code>POST /staff</code> (<code>staff.controller.ts:24-25</code>).</li> <li><strong>Fields</strong> (exact from <code>create-staff.dto.ts:4-38</code>): <code>userId</code> (required picker), <code>employeeNumber</code> (required), <code>departmentId</code> (optional picker), <code>designationId</code> (optional picker), <code>employmentType</code> (optional dropdown — <code>full_time/part_time/ contract/intern</code>), <code>joiningDate</code> (optional date), <code>salaryGrade</code> (optional text).</li> <li><strong>Server defaults:</strong> <code>status=active</code>, <code>employmentType=full_time</code> (<code>staff.service.ts:37-43</code>) — surface as helper text, not editable.</li> <li><strong>Errors:</strong> 409 on duplicate employee number → inline field error (<code>staff.service.ts:32-36</code>); 400 validation → per-field (<code>http-exception.filter.ts:103-107</code>).</li> </ul> <h2 id="s4--edit-staff-staffidedit"><a class="header" href="#s4--edit-staff-staffidedit">S4 — Edit Staff (<code>/staff/:id/edit</code>)</a></h2> <ul> <li><strong>Data:</strong> <code>PATCH /staff/:id</code> (<code>staff.controller.ts:33-34</code>); prefilled from <code>GET /staff/:id</code>.</li> <li><strong>Extra field vs create:</strong> <code>status</code> (<code>update-staff.dto.ts:35-38</code>) and <code>metadata</code> (<code>update-staff.dto.ts:40-42</code>) — the only sanctioned way to change lifecycle state.</li> <li>Send only changed fields; audit delta = changed keys (<code>staff.service.ts:88</code>).</li> </ul> <h2 id="s5--deactivate-flow-modal-from-s1s2"><a class="header" href="#s5--deactivate-flow-modal-from-s1s2">S5 — Deactivate Flow (modal, from S1/S2)</a></h2> <ul> <li><strong>Data:</strong> <code>DELETE /staff/:id</code> (<code>staff.controller.ts:36-37</code>).</li> <li><strong>Confirm dialog:</strong> warning that deactivation is permanent (soft-delete, no restore endpoint — <code>base.repository.ts:68-74</code>; OQ-8). 404 handled as already-gone.</li> </ul> <h2 id="s6--department-list-departments"><a class="header" href="#s6--department-list-departments">S6 — Department List (<code>/departments</code>)</a></h2> <ul> <li><strong>Data:</strong> <code>GET /departments?page=&limit=</code> (<code>department.controller.ts:29-30</code>).</li> <li><strong>Row:</strong> name, code badge, head (joined <code>headId</code> <code>(proposed)</code>), status.</li> <li><strong>Actions:</strong> FAB "Add department", row menu Edit / Deactivate.</li> <li><strong>Sort:</strong> server returns insertion order (<code>department.service.ts:38-50</code>); sort by name is client-side <code>(proposed)</code>.</li> </ul> <h2 id="s7--department-detail-departmentsid"><a class="header" href="#s7--department-detail-departmentsid">S7 — Department Detail (<code>/departments/:id</code>)</a></h2> <ul> <li><strong>Data:</strong> <code>GET /departments/:id</code> (<code>department.controller.ts:32-33</code>); 404 handled.</li> <li><strong>Sections:</strong> name/code/head/status (schema <code>department.schema.ts:9-19</code>), member list <code>(proposed)</code> — computed client-side by fetching staff and filtering <code>departmentId</code> (no server endpoint returns members).</li> <li><strong>Head row:</strong> avatar + name via <code>headId</code> → <code>GET /staff/:id</code> <code>(proposed)</code>; deleted head → "—".</li> </ul> <h2 id="s8--department-form-create--edit"><a class="header" href="#s8--department-form-create--edit">S8 — Department Form (create + edit)</a></h2> <ul> <li><strong>Data:</strong> <code>POST /departments</code> / <code>PATCH /departments/:id</code> (<code>department.controller.ts:26-27,35-40</code>).</li> <li><strong>Fields</strong> (exact from <code>department.dto.ts:4-39</code>): <code>name</code> (required), <code>code</code> (optional), <code>headId</code> (optional staff picker); edit adds <code>status</code>.</li> <li><strong>409</strong> duplicate name → inline (<code>department.service.ts:23-25</code>).</li> </ul> <h2 id="s9--designation-list-designations"><a class="header" href="#s9--designation-list-designations">S9 — Designation List (<code>/designations</code>)</a></h2> <ul> <li><strong>Data:</strong> <code>GET /designations?page=&limit=</code> (<code>designation.controller.ts:29-30</code>).</li> <li><strong>Row:</strong> name, level, department (joined <code>departmentId</code> <code>(proposed)</code>), status.</li> <li><strong>Display sort:</strong> by <code>level</code> ascending <code>(proposed)</code> — server returns insertion order (<code>designation.service.ts:41-50</code>).</li> </ul> <h2 id="s10--designation-detail-designationsid"><a class="header" href="#s10--designation-detail-designationsid">S10 — Designation Detail (<code>/designations/:id</code>)</a></h2> <ul> <li><strong>Data:</strong> <code>GET /designations/:id</code> (<code>designation.controller.ts:32-33</code>).</li> <li><strong>Sections:</strong> name/level/department/status (<code>designation.schema.ts:9-19</code>); staff holding the designation <code>(proposed)</code> — client-filtered list.</li> </ul> <h2 id="s11--designation-form-create--edit"><a class="header" href="#s11--designation-form-create--edit">S11 — Designation Form (create + edit)</a></h2> <ul> <li><strong>Data:</strong> <code>POST /designations</code> / <code>PATCH /designations/:id</code> (<code>designation.controller.ts:26-27,35-40</code>).</li> <li><strong>Fields</strong> (exact from <code>designation.dto.ts:4-39</code>): <code>name</code> (required), <code>departmentId</code> (optional), <code>level</code> (optional int, default <code>0</code>); edit adds <code>status</code>.</li> <li><strong>409</strong> duplicate name → inline (<code>designation.service.ts:25-28</code>).</li> </ul> <hr /> <h2 id="cross-screen-state-rules-shared-00-shared06-31"><a class="header" href="#cross-screen-state-rules-shared-00-shared06-31">Cross-screen state rules (shared <a href="staff/../00-shared/06_State_Management.html">00-shared/06 §3.1</a>)</a></h2> <p>Every screen: <code>Initial/Loading → AppSkeleton; Error → AppErrorState(code, retry); Success+empty → AppEmptyState; Success → content</code>. Pull-to-refresh on all lists. 409 → inline duplicate message (<a href="staff/../00-shared/06_State_Management.html">00-shared/06 §5</a>); 401 → refresh flow; 403 → permission screen/action hiding; 429 → backoff copy.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-staff-module"><a class="header" href="#06--screen-specifications-staff-module">06 — Screen Specifications (Staff Module)</a></h1> <ul> <li><a href="staff/06_Screen_Specifications.html#ss1--staff-list-staff">SS1 — Staff List (<code>/staff</code>)</a> <ul> <li><a href="staff/06_Screen_Specifications.html#layout-responsive">Layout (responsive)</a></li> <li><a href="staff/06_Screen_Specifications.html#data--pagination">Data & pagination</a></li> <li><a href="staff/06_Screen_Specifications.html#states">States</a></li> <li><a href="staff/06_Screen_Specifications.html#filter-chips">Filter chips</a></li> <li><a href="staff/06_Screen_Specifications.html#row-menu">Row menu</a></li> <li><a href="staff/06_Screen_Specifications.html#a11y">A11y</a></li> </ul> </li> <li><a href="staff/06_Screen_Specifications.html#ss2--staff-detail-staffid">SS2 — Staff Detail (<code>/staff/:id</code>)</a> <ul> <li><a href="staff/06_Screen_Specifications.html#layout">Layout</a></li> <li><a href="staff/06_Screen_Specifications.html#data">Data</a></li> <li><a href="staff/06_Screen_Specifications.html#tabs">Tabs</a></li> <li><a href="staff/06_Screen_Specifications.html#actions">Actions</a></li> </ul> </li> <li><a href="staff/06_Screen_Specifications.html#ss3--create-staff-staffnew">SS3 — Create Staff (<code>/staff/new</code>)</a></li> <li><a href="staff/06_Screen_Specifications.html#ss4--edit-staff-staffidedit">SS4 — Edit Staff (<code>/staff/:id/edit</code>)</a></li> <li><a href="staff/06_Screen_Specifications.html#ss5--deactivate-confirmation-modal">SS5 — Deactivate Confirmation (modal)</a></li> <li><a href="staff/06_Screen_Specifications.html#ss6--department-list-departments">SS6 — Department List (<code>/departments</code>)</a></li> <li><a href="staff/06_Screen_Specifications.html#ss7--department-detail-departmentsid">SS7 — Department Detail (<code>/departments/:id</code>)</a></li> <li><a href="staff/06_Screen_Specifications.html#ss8--department-form-create--edit">SS8 — Department Form (create + edit)</a></li> <li><a href="staff/06_Screen_Specifications.html#ss9--designation-list-designations">SS9 — Designation List (<code>/designations</code>)</a></li> <li><a href="staff/06_Screen_Specifications.html#ss10--designation-detail-designationsid">SS10 — Designation Detail (<code>/designations/:id</code>)</a></li> <li><a href="staff/06_Screen_Specifications.html#ss11--designation-form-create--edit">SS11 — Designation Form (create + edit)</a></li> <li><a href="staff/06_Screen_Specifications.html#global-behaviors-all-screens">Global behaviors (all screens)</a></li> </ul> <blockquote> <p>Production-grade specs for the screens in <a href="staff/05_Screen_Inventory.html">05_Screen_Inventory.md</a>. Every field and behavior derives from backend DTOs/schemas (<code>src/modules/staff/**</code>); layout/copy follow shared tokens (<a href="staff/../00-shared/02_Design_Tokens.html">00-shared/02</a>, <a href="staff/03_User_Journey.html">03</a>, <a href="staff/04_Information_Architecture.html">04</a>, <a href="staff/09_User_Behaviour.html">09</a>). Screens are <code>(planned)</code>; backend behavior is cited exactly.</p> </blockquote> <hr /> <h2 id="ss1--staff-list-staff"><a class="header" href="#ss1--staff-list-staff">SS1 — Staff List (<code>/staff</code>)</a></h2> <h3 id="layout-responsive"><a class="header" href="#layout-responsive">Layout (responsive)</a></h3> <pre><code>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" │ └────────────────────────────┘ </code></pre> <ul> <li>List rows: height ≥ 56 (<code>00-shared/03 AppListTile</code>); leading avatar 40 (<code>AppAvatar</code> initials from joined user name — staff profile has no name field, <code>staff.schema.ts:22-52</code>); title <code>employeeNumber</code> in <code>mono</code>; subtitle <code>department • designation</code> (or "—"); trailing <code>status</code> badge + overflow menu.</li> <li>Master-detail at ≥ 840 dp (<code>00-shared/05 §3</code>): selected row highlights, detail in right pane; phone pushes detail.</li> </ul> <h3 id="data--pagination"><a class="header" href="#data--pagination">Data & pagination</a></h3> <ul> <li><code>GET /staff?page=1&limit=20</code> (<code>staff.controller.ts:27-28</code>); server computes <code>meta</code> (<code>staff.service.ts:64-76</code>, <code>pagination-query.dto.ts:41-55</code>).</li> <li><code>limit</code> clamp 1–100, <code>page</code> ≥ 1 (<code>pagination-query.dto.ts:6-19</code>); invalid → 400 <code>VALIDATION_ERROR</code> (<code>http-exception.filter.ts:28,103-107</code>).</li> <li><code>q</code> and <code>sort</code> accepted but <strong>ignored</strong> by <code>StaffService.find</code> (<code>staff.service.ts:64-76</code>) — the search box and sort menu are client-side <code>(proposed)</code>, labeled "filtering loaded results" (OQ-2).</li> <li>Infinite scroll on phone (append page while <code>meta.hasNext</code>); page controls on desktop (<code>pagination-query.dto.ts:51-53</code>); pull-to-refresh resets to page 1.</li> </ul> <h3 id="states-15"><a class="header" href="#states-15">States</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Render</th></tr></thead><tbody> <tr><td>Initial/Loading</td><td><code>AppSkeleton</code> list (8 rows)</td></tr> <tr><td>Success + data</td><td>rows; "end of list" footer at last page</td></tr> <tr><td>Success + empty (no filters)</td><td><code>AppEmptyState</code> "No staff yet" + CTA "Add staff"</td></tr> <tr><td>Success + empty (filters)</td><td><code>AppEmptyState</code> "No staff match the current filters"</td></tr> <tr><td>Error</td><td><code>AppErrorState(code, retry)</code>: 429 backoff copy; 5xx generic + requestId (<code>00-shared/06 §5</code>)</td></tr> </tbody></table> </div> <h3 id="filter-chips"><a class="header" href="#filter-chips">Filter chips</a></h3> <ul> <li>Status chip options map exactly to <code>StaffStatus</code>: <code>active, inactive, on_leave, terminated</code> (<code>staff.schema.ts:7-12</code>).</li> <li>Type chip options map exactly to <code>EmploymentType</code>: <code>full_time, part_time, contract, intern</code> (<code>staff.schema.ts:14-19</code>).</li> <li>Both are client-side filters over loaded pages (server has no such query params — <code>staff.service.ts:64-76</code>).</li> </ul> <h3 id="row-menu"><a class="header" href="#row-menu">Row menu</a></h3> <p>View (→ SS2), Edit (→ SS4, <code>staff.update</code>), Deactivate (→ SS5, <code>staff.delete</code>). Destructive item styled <code>error</code> (<code>00-shared/03 AppMenu</code>).</p> <h3 id="a11y-10"><a class="header" href="#a11y-10">A11y</a></h3> <p><code>AppSearchBar</code> results live region; status announced as "badge {label}"; avatar semantics = person name; <code>Semantics</code> on list position "item 3 of 12" (<code>00-shared/09 §5,7</code>).</p> <hr /> <h2 id="ss2--staff-detail-staffid"><a class="header" href="#ss2--staff-detail-staffid">SS2 — Staff Detail (<code>/staff/:id</code>)</a></h2> <h3 id="layout-18"><a class="header" href="#layout-18">Layout</a></h3> <pre><code>┌────────────────────────────────────────────┐ │ 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│ │ │ └────────────────────────────────────────┘ │ └────────────────────────────────────────────┘ </code></pre> <h3 id="data-2"><a class="header" href="#data-2">Data</a></h3> <ul> <li><code>GET /staff/:id</code> (<code>staff.controller.ts:30-31</code>); missing → 404 <code>RESOURCE_NOT_FOUND</code> (<code>staff.service.ts:58-62</code>) → not-found empty-state.</li> <li>Ref fields are bare ObjectIds (<code>staff.schema.ts:29-33</code>) — resolve department and designation by fetching <code>GET /departments/:id</code>, <code>GET /designations/:id</code> <code>(proposed)</code>. A ref that 404s (soft-deleted catalog entry) renders "—".</li> <li>Joined user name: <code>GET /users/:id</code> or a client-side join from the users list <code>(proposed)</code> — no staff endpoint returns the person's name.</li> </ul> <h3 id="tabs"><a class="header" href="#tabs">Tabs</a></h3> <div class="table-wrapper"><table><thead><tr><th>Tab</th><th>Content source</th></tr></thead><tbody> <tr><td>Overview</td><td><code>employeeNumber</code>, <code>departmentId</code>, <code>designationId</code>, <code>status</code> (<code>staff.schema.ts:22-49</code>)</td></tr> <tr><td>Employment</td><td><code>employmentType</code>, <code>joiningDate</code>, <code>salaryGrade</code> (<code>staff.schema.ts:35-47</code>)</td></tr> <tr><td>Metadata</td><td><code>metadata</code> key/values (<code>staff.schema.ts:51-52</code>); read-only; empty → "No metadata"</td></tr> <tr><td>Audit</td><td><code>version</code>, <code>createdAt</code>, <code>updatedAt</code> (<code>base.schema.ts:29-34</code>) — plain text; audit <strong>events</strong> live in the audit-write queue (<code>event-queue-map.ts:35-36</code>), not exposed by this module</td></tr> </tbody></table> </div> <p>Tab selection is client-side <code>(proposed)</code>; keep-alive via <code>AppTabs</code> (<code>00-shared/03 AppTabs</code>).</p> <h3 id="actions"><a class="header" href="#actions">Actions</a></h3> <ul> <li>Edit → SS4 (<code>staff.update</code>).</li> <li>Deactivate → SS5 confirm (<code>staff.delete</code>).</li> <li>Copy employee number (context: shared elsewhere) <code>(proposed)</code>.</li> </ul> <hr /> <h2 id="ss3--create-staff-staffnew"><a class="header" href="#ss3--create-staff-staffnew">SS3 — Create Staff (<code>/staff/new</code>)</a></h2> <p>Full form spec in <code>08_Form_Specifications.md §F1</code>. Summary:</p> <ul> <li>Two-step feel <code>(proposed)</code>: step 1 pick the <strong>User</strong> (required <code>userId</code>, <code>create-staff.dto.ts:5-7</code>) from the users directory; step 2 employment details. Or single screen with a user picker sheet — product choice; fields are the same.</li> <li><code>employeeNumber</code> required, unique per tenant (<code>create-staff.dto.ts:9-11</code>, <code>staff.schema.ts:57</code>); 409 → inline error on the field (<code>staff.service.ts:32-36</code>).</li> <li>On submit: <code>POST /staff</code> → 201; navigate to detail (SS2); snackbar "Staff created" (<code>staff.service.ts:37-55</code>; <code>00-shared/03 AppSnackbar</code>).</li> <li>Helper copy: "Profile will be created as <strong>active</strong>" (<code>staff.service.ts:39</code>) and "Employment type defaults to <strong>full-time</strong>" (<code>staff.service.ts:40-41</code>) — neither is editable at creation via the DTO (OQ-9 for a future create-status field).</li> </ul> <hr /> <h2 id="ss4--edit-staff-staffidedit"><a class="header" href="#ss4--edit-staff-staffidedit">SS4 — Edit Staff (<code>/staff/:id/edit</code>)</a></h2> <ul> <li>Prefill from <code>GET /staff/:id</code> (SS2 data). Submit <code>PATCH /staff/:id</code> with <strong>only changed fields</strong> (<code>update-staff.dto.ts:5-42</code>; <code>staff.service.ts:78-91</code>).</li> <li>Fields: <code>employeeNumber</code>, <code>departmentId</code>, <code>designationId</code>, <code>employmentType</code>, <code>joiningDate</code>, <code>salaryGrade</code>, <code>status</code> (dropdown with the 4 enum values, <code>staff.schema.ts:7-12</code>), <code>metadata</code> (key/value editor <code>(proposed)</code>).</li> <li>Changing <code>status</code> here is the deactivation alternative for <code>on_leave</code>/<code>inactive</code> without deleting (<code>update-staff.dto.ts:35-38</code>).</li> <li>409 duplicate employee number → inline; 404 → not-found state (concurrent deletion); success → snackbar "Saved" + <code>StaffUpdated</code> audit event (<code>staff.service.ts:82-90</code>).</li> </ul> <hr /> <h2 id="ss5--deactivate-confirmation-modal"><a class="header" href="#ss5--deactivate-confirmation-modal">SS5 — Deactivate Confirmation (modal)</a></h2> <ul> <li>Trigger: row menu or detail action ("Deactivate"). Requires <code>staff.delete</code>.</li> <li><code>AppDialog</code>: 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 (<code>base.repository.ts:20-30</code>) and no restore endpoint exists (<code>base.repository.ts:68-74</code>; OQ-8).</li> <li>Confirm → <code>DELETE /staff/:id</code> → success: pop back to list + snackbar "Staff deactivated"; 404 → snackbar "Already deactivated" + refresh list (<code>staff.service.ts:93-104</code>).</li> <li>Destructive action styled <code>error</code>, haptic <code>heavyImpact</code> on confirm (<code>00-shared/08 §3</code>); focus trap + ESC (<code>00-shared/09 §6</code>).</li> </ul> <hr /> <h2 id="ss6--department-list-departments"><a class="header" href="#ss6--department-list-departments">SS6 — Department List (<code>/departments</code>)</a></h2> <pre><code>┌──────────────────────────────────────┐ │ AppBar: "Departments" [search] │ │ SearchBar (client-side) │ │ ┌──────────────────────────────────┐ │ │ │ Administration [ADMIN] [▾] │ │ │ │ Head: A. Sharma [active] │ │ │ ├──────────────────────────────────┤ │ │ │ Accounts [ACCT] [▾] │ │ │ │ Head: — [active] │ │ │ └──────────────────────────────────┘ │ │ FAB: "Add department" │ └──────────────────────────────────────┘ </code></pre> <ul> <li><code>GET /departments?page=&limit=</code> (<code>department.controller.ts:29-30</code>); <code>meta</code> server-computed (<code>department.service.ts:38-50</code>). Server returns insertion order — client sorts by name <code>(proposed)</code>.</li> <li>Row: name (title), <code>code</code> chip (optional, <code>department.schema.ts:12-13</code>), head name (resolved via <code>headId</code> <code>(proposed)</code>; "—" when unset or deleted <code>department.schema.ts:15-16</code>), status badge.</li> <li>Row menu: Edit, Deactivate (both <code>department.manage</code>). FAB "Add department" → SS8 create.</li> <li>Empty: "No departments yet" + CTA. Member counts are client-computed <code>(proposed)</code> (no server endpoint; OQ-4).</li> </ul> <hr /> <h2 id="ss7--department-detail-departmentsid"><a class="header" href="#ss7--department-detail-departmentsid">SS7 — Department Detail (<code>/departments/:id</code>)</a></h2> <ul> <li><code>GET /departments/:id</code> (<code>department.controller.ts:32-33</code>); 404 → not-found.</li> <li>Header card: name, code, status; head row (avatar + name) with "Change" action (→ SS8 edit, <code>headId</code> field).</li> <li>"Members" section <code>(proposed)</code>: fetch staff list, filter <code>departmentId === :id</code> client-side (the API has no members endpoint); rows link to SS2; empty → "No staff in this department".</li> <li>Actions: Edit, Deactivate.</li> </ul> <hr /> <h2 id="ss8--department-form-create--edit"><a class="header" href="#ss8--department-form-create--edit">SS8 — Department Form (create + edit)</a></h2> <ul> <li>Fields (exact): <code>name</code> required text; <code>code</code> optional text (trimmed server-side, <code>department.schema.ts:12-13</code>); <code>headId</code> optional staff picker (<code>department.dto.ts:4-18</code>). Edit adds <code>status</code> (<code>department.dto.ts:36-39</code>).</li> <li>Submit → <code>POST /departments</code> (201 → list) or <code>PATCH /departments/:id</code> (200 → detail refresh). 409 duplicate name → inline error on <code>name</code> (<code>department.service.ts:23-25</code>). 404 on edit (deleted concurrently) → not-found.</li> <li>Success snackbar: "Department created"/"Department saved". No events fire (department service emits none — <code>department.service.ts:19-20</code>, OQ-10).</li> </ul> <hr /> <h2 id="ss9--designation-list-designations"><a class="header" href="#ss9--designation-list-designations">SS9 — Designation List (<code>/designations</code>)</a></h2> <ul> <li><code>GET /designations?page=&limit=</code> (<code>designation.controller.ts:29-30</code>).</li> <li>Row: name (title), <code>level</code> (mono, <code>designation.schema.ts:15-16</code>), department name (joined from <code>departmentId</code>, <code>(proposed)</code>, "—" if unset), status badge.</li> <li>Display order: <code>level</code> ascending, then name <code>(proposed)</code> — server returns insertion order (<code>designation.service.ts:41-50</code>).</li> <li>Row menu: Edit, Deactivate. FAB "Add designation" → SS11 create.</li> <li>Empty: "No designations yet" + CTA.</li> </ul> <hr /> <h2 id="ss10--designation-detail-designationsid"><a class="header" href="#ss10--designation-detail-designationsid">SS10 — Designation Detail (<code>/designations/:id</code>)</a></h2> <ul> <li><code>GET /designations/:id</code> (<code>designation.controller.ts:32-33</code>); 404 → not-found.</li> <li>Header card: name, level, linked department (link → SS7), status.</li> <li>"Staff with this designation" <code>(proposed)</code>: client-filtered staff list; empty → "No staff with this designation".</li> <li>Actions: Edit, Deactivate.</li> </ul> <hr /> <h2 id="ss11--designation-form-create--edit"><a class="header" href="#ss11--designation-form-create--edit">SS11 — Designation Form (create + edit)</a></h2> <ul> <li>Fields (exact): <code>name</code> required text; <code>departmentId</code> optional department picker; <code>level</code> optional <strong>integer</strong> (<code>IsInt</code> — <code>designation.dto.ts:14-17</code>), default <code>0</code> shown in helper (<code>designation.schema.ts:15-16</code>); edit adds <code>status</code> (<code>designation.dto.ts:36-39</code>).</li> <li>Submit → <code>POST /designations</code> / <code>PATCH /designations/:id</code>. 409 duplicate name → inline (<code>designation.service.ts:25-28</code>). Non-integer <code>level</code> → 400 <code>VALIDATION_ERROR</code> with details (<code>http-exception.filter.ts:103-107</code>).</li> <li>No events emitted on create/update/delete (OQ-10).</li> </ul> <hr /> <h2 id="global-behaviors-all-screens"><a class="header" href="#global-behaviors-all-screens">Global behaviors (all screens)</a></h2> <ul> <li><strong>Permissions:</strong> render/route only when the client role has the required perm (map: <code>permissions.constants.ts:19-24</code>); server RBAC not yet enforced on these endpoints (<code>staff.controller.ts:21</code>) — double-gate client-side, flag OQ-1.</li> <li><strong>Offline:</strong> reads from last-good cache + offline banner (<code>00-shared/06 §3.7</code>); writes blocked with guidance (no module offline queue).</li> <li><strong>Loading:</strong> <code>AppSkeleton</code> ≤ 200 ms; content ≤ 2 s (<code>00-shared/10 §1</code>).</li> <li><strong>Session:</strong> 401 → single-flight refresh; failure → re-login preserving nav (<code>00-shared/06 §3.6</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-staff-module"><a class="header" href="#07--component-library-staff-module">07 — Component Library (Staff Module)</a></h1> <ul> <li><a href="staff/07_Component_Library.html#1-shared-components-in-use-by-screen">1. Shared components in use (by screen)</a></li> <li><a href="staff/07_Component_Library.html#2-module-specific-components">2. Module-specific components</a> <ul> <li><a href="staff/07_Component_Library.html#staffstatusbadge">StaffStatusBadge</a></li> <li><a href="staff/07_Component_Library.html#employmenttypelabel">EmploymentTypeLabel</a></li> <li><a href="staff/07_Component_Library.html#employeenumbertext">EmployeeNumberText</a></li> <li><a href="staff/07_Component_Library.html#refchip-departmentdesignation-ref">RefChip (department/designation ref)</a></li> <li><a href="staff/07_Component_Library.html#catalogpickersheet-used-in-s3-s4-s8-s11">CatalogPickerSheet (used in S3, S4, S8, S11)</a></li> <li><a href="staff/07_Component_Library.html#headpicker-s8">HeadPicker (S8)</a></li> <li><a href="staff/07_Component_Library.html#metadataeditor-s4-metadata-field">MetadataEditor (S4 <code>metadata</code> field)</a></li> </ul> </li> <li><a href="staff/07_Component_Library.html#3-composition-rules">3. Composition rules</a></li> <li><a href="staff/07_Component_Library.html#4-golden-tests">4. Golden tests</a></li> </ul> <blockquote> <p>Module-specific components. Shared components are <strong>referenced by name</strong> from <a href="staff/../00-shared/03_Component_Library.html">00-shared/03_Component_Library.md</a>; this file adds staff-specific widgets only. All tokens per <a href="staff/../00-shared/02_Design_Tokens.html">00-shared/02</a>.</p> </blockquote> <h2 id="1-shared-components-in-use-by-screen"><a class="header" href="#1-shared-components-in-use-by-screen">1. Shared components in use (by screen)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Shared component</th><th>Screens</th></tr></thead><tbody> <tr><td><code>AppSkeleton</code></td><td>S1, S6, S9 lists</td></tr> <tr><td><code>AppEmptyState</code></td><td>S1, S2 (404), S6, S7, S9, S10</td></tr> <tr><td><code>AppErrorState</code></td><td>all (per error code)</td></tr> <tr><td><code>AppSearchBar</code></td><td>S1, S6, S9 (client-side filtering — server ignores <code>q</code> for staff, OQ-2)</td></tr> <tr><td><code>AppChips</code> (FilterChip)</td><td>S1 status/type filters</td></tr> <tr><td><code>AppListTile</code></td><td>S1, S6, S7, S9, S10 rows</td></tr> <tr><td><code>AppAvatar</code></td><td>S1 rows, S2 header, S7 head</td></tr> <tr><td><code>AppBadge</code></td><td>status badges (S1, S2, S6, S9)</td></tr> <tr><td><code>AppTabs</code></td><td>S2 detail tabs</td></tr> <tr><td><code>AppFAB</code></td><td>S1, S6, S9 ("Add staff/department/designation")</td></tr> <tr><td><code>AppButton</code></td><td>forms, dialogs</td></tr> <tr><td><code>AppDialog</code></td><td>S5 deactivate confirm; delete confirms in catalogs</td></tr> <tr><td><code>AppMenu</code></td><td>row overflow menus</td></tr> <tr><td><code>AppSnackbar</code></td><td>create/update/deactivate feedback</td></tr> <tr><td><code>AppDropdown</code></td><td>department/designation/employment-type pickers</td></tr> <tr><td><code>AppDatePicker</code></td><td>joiningDate</td></tr> <tr><td><code>AppInfoRow</code></td><td>S2 detail fields</td></tr> <tr><td><code>AppSectionHeader</code></td><td>S7/S10 sections</td></tr> <tr><td><code>AppProgress</code></td><td>submit/pending states</td></tr> </tbody></table> </div> <h2 id="2-module-specific-components"><a class="header" href="#2-module-specific-components">2. Module-specific components</a></h2> <h3 id="staffstatusbadge"><a class="header" href="#staffstatusbadge">StaffStatusBadge</a></h3> <ul> <li><strong>Props:</strong> <code>status: StaffStatus</code>, <code>size?</code>.</li> <li><strong>Behavior:</strong> maps 1:1 to the enum (<code>staff.schema.ts:7-12</code>): <code>active</code> → success (secondary), <code>inactive</code> → neutral (outline), <code>on_leave</code> → tertiary/warning, <code>terminated</code> → error.</li> <li><strong>A11y:</strong> icon + label + color — never color-only (<a href="staff/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §9</a>).</li> <li><strong>Flutter:</strong> <code>AppBadge</code> with <code>Semantics(label: 'Status: on leave')</code>.</li> </ul> <h3 id="employmenttypelabel"><a class="header" href="#employmenttypelabel">EmploymentTypeLabel</a></h3> <ul> <li><strong>Props:</strong> <code>type: EmploymentType</code>.</li> <li><strong>Behavior:</strong> renders humanized label for <code>full_time, part_time, contract, intern</code> (<code>staff.schema.ts:14-19</code>), e.g. "Full-time". Localized; unknown value falls back to the raw string (server DTO allows any string — no <code>IsEnum</code> on <code>create-staff.dto.ts:26-28</code>, OQ-9).</li> <li><strong>Flutter:</strong> simple <code>Text</code> with a const map.</li> </ul> <h3 id="employeenumbertext"><a class="header" href="#employeenumbertext">EmployeeNumberText</a></h3> <ul> <li><strong>Props:</strong> <code>employeeNumber: String</code>.</li> <li><strong>Behavior:</strong> <code>mono</code> face + <code>FontFeature.tabularFigures()</code> (<a href="staff/../00-shared/02_Design_Tokens.html">00-shared/02 §2</a>), copy affordance <code>(proposed)</code>.</li> <li><strong>Flutter:</strong> <code>Text</code> with <code>fontFamilyFallback</code> mono + <code>SelectableText</code>.</li> </ul> <h3 id="refchip-departmentdesignation-ref"><a class="header" href="#refchip-departmentdesignation-ref">RefChip (department/designation ref)</a></h3> <ul> <li><strong>Props:</strong> <code>label</code>, <code>href</code>.</li> <li><strong>Behavior:</strong> navigation chip for a resolved <code>departmentId</code>/<code>designationId</code>; renders "—" when the ref is unset <strong>or unresolvable</strong> (soft-deleted catalog entry → 404 on lookup, <code>base.repository.ts:20-30</code>).</li> <li><strong>Flutter:</strong> <code>ActionChip</code> + null-state Text.</li> </ul> <h3 id="catalogpickersheet-used-in-s3-s4-s8-s11"><a class="header" href="#catalogpickersheet-used-in-s3-s4-s8-s11">CatalogPickerSheet (used in S3, S4, S8, S11)</a></h3> <ul> <li><strong>Props:</strong> <code>endpoint</code> (departments | designations), <code>value</code>, <code>onPicked</code>, <code>allowClear</code>.</li> <li><strong>Behavior:</strong> <code>AppBottomSheet</code> list backed by <code>GET /departments</code> or <code>GET /designations</code> (pagination + client search); only <code>status: active</code> entries offered <code>(proposed)</code>; "Unassigned" clear option; empty state → "No departments yet — create one".</li> <li><strong>Flutter:</strong> <code>showModalBottomSheet</code> + <code>ListView.builder</code>.</li> </ul> <h3 id="headpicker-s8"><a class="header" href="#headpicker-s8">HeadPicker (S8)</a></h3> <ul> <li><strong>Props:</strong> <code>value</code>, <code>onPicked</code>.</li> <li><strong>Behavior:</strong> staff picker limited to <code>status: active</code> (<code>(proposed)</code> filter over staff list; backend list returns all — <code>staff.service.ts:64-76</code>); shows employee number + name.</li> <li><strong>Flutter:</strong> sheet with <code>SearchBar</code> + <code>ListTile</code>s.</li> </ul> <h3 id="metadataeditor-s4-metadata-field"><a class="header" href="#metadataeditor-s4-metadata-field">MetadataEditor (S4 <code>metadata</code> field)</a></h3> <ul> <li><strong>Props:</strong> <code>value: Map<String, Object?></code>, <code>onChanged</code>.</li> <li><strong>Behavior:</strong> editable key/value rows (add/remove) bound to the free-form <code>metadata</code> object (<code>staff.schema.ts:51-52</code>, <code>update-staff.dto.ts:40-42</code>); values serialized as JSON strings client-side <code>(proposed)</code>.</li> <li><strong>Flutter:</strong> <code>ListView</code> of <code>Row(key TextField, value TextField, remove IconButton)</code> <ul> <li>"Add field" <code>TextButton</code>.</li> </ul> </li> </ul> <h2 id="3-composition-rules"><a class="header" href="#3-composition-rules">3. Composition rules</a></h2> <ul> <li>List screens: <code>AppScaffold</code> + <code>RefreshIndicator</code> + <code>ListView.builder</code> + footer (spinner / end-of-list) (<a href="staff/../00-shared/03_Component_Library.html">00-shared/03 §F</a>).</li> <li>Detail: header <code>AppCard</code> + <code>AppTabs</code>; tab content in <code>TabBarView</code> with keep-alive.</li> <li>Forms: one screen, fields in <code>Form</code> with <code>AppTextField</code>/<code>AppDropdown</code>/<code>AppDatePicker</code> (<a href="staff/08_Form_Specifications.html">08_Form_Specifications.md</a>); submit <code>AppButton(filled, loading:)</code>.</li> </ul> <h2 id="4-golden-tests"><a class="header" href="#4-golden-tests">4. Golden tests</a></h2> <p>New components get goldens at 3 sizes + dark mode (<a href="staff/../00-shared/03_Component_Library.html">00-shared/03 §G</a>): <code>StaffStatusBadge</code> (4 states), <code>EmploymentTypeLabel</code> (4 values), <code>RefChip</code> (resolved/unset), <code>CatalogPickerSheet</code> (data/empty/error).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-staff-module"><a class="header" href="#08--form-specifications-staff-module">08 — Form Specifications (Staff Module)</a></h1> <ul> <li><a href="staff/08_Form_Specifications.html#f1--create-staff-staffnew--post-staff">F1 — Create Staff (<code>/staff/new</code>) → <code>POST /staff</code></a></li> <li><a href="staff/08_Form_Specifications.html#f2--edit-staff-staffidedit--patch-staffid">F2 — Edit Staff (<code>/staff/:id/edit</code>) → <code>PATCH /staff/:id</code></a></li> <li><a href="staff/08_Form_Specifications.html#f3--create-department-departmentsnew--post-departments">F3 — Create Department (<code>/departments/new</code>) → <code>POST /departments</code></a></li> <li><a href="staff/08_Form_Specifications.html#f4--edit-department-departmentsidedit--patch-departmentsid">F4 — Edit Department (<code>/departments/:id/edit</code>) → <code>PATCH /departments/:id</code></a></li> <li><a href="staff/08_Form_Specifications.html#f5--create-designation-designationsnew--post-designations">F5 — Create Designation (<code>/designations/new</code>) → <code>POST /designations</code></a></li> <li><a href="staff/08_Form_Specifications.html#f6--edit-designation-designationsidedit--patch-designationsid">F6 — Edit Designation (<code>/designations/:id/edit</code>) → <code>PATCH /designations/:id</code></a></li> <li><a href="staff/08_Form_Specifications.html#cross-form-rules">Cross-form rules</a></li> </ul> <blockquote> <p>Field-by-field specs for every create/update form. DTO columns cite the exact validator source. <strong>Backend note:</strong> all three modules validate via <code>class-validator</code>; failures return 400 <code>VALIDATION_ERROR</code> with <code>error.details[]</code> (<code>http-exception.filter.ts:28,103-107</code>), mapped to field errors by the client (<a href="staff/../00-shared/06_State_Management.html">00-shared/06 §5</a>).</p> </blockquote> <h2 id="f1--create-staff-staffnew--post-staff"><a class="header" href="#f1--create-staff-staffnew--post-staff">F1 — Create Staff (<code>/staff/new</code>) → <code>POST /staff</code></a></h2> <p>Source: <code>create-staff.dto.ts:4-38</code>; <code>staff.controller.ts:24-25</code>.</p> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Control</th><th>Required</th><th>Validation (source)</th><th>Server behavior</th></tr></thead><tbody> <tr><td>1</td><td><code>userId</code></td><td>User picker (sheet, searchable)</td><td><strong>Yes</strong></td><td><code>@IsMongoId()</code> (<code>create-staff.dto.ts:5-7</code>)</td><td>Stored as ref to <code>User</code> (<code>staff.schema.ts:23-24</code>)</td></tr> <tr><td>2</td><td><code>employeeNumber</code></td><td><code>AppTextField</code>, mono, <code>maxLength</code> 64 <code>(proposed)</code></td><td><strong>Yes</strong></td><td><code>@IsString()</code> (<code>create-staff.dto.ts:9-11</code>)</td><td>Trimmed (<code>staff.schema.ts:26-27</code>); <strong>unique per tenant</strong> — 409 on dup (<code>staff.service.ts:31-36</code>, <code>staff.schema.ts:57</code>)</td></tr> <tr><td>3</td><td><code>departmentId</code></td><td><code>CatalogPickerSheet</code> (departments)</td><td>No</td><td><code>@IsOptional() @IsMongoId()</code> (<code>create-staff.dto.ts:13-16</code>)</td><td>Ref to <code>Department</code> (<code>staff.schema.ts:29-30</code>)</td></tr> <tr><td>4</td><td><code>designationId</code></td><td><code>CatalogPickerSheet</code> (designations)</td><td>No</td><td><code>@IsOptional() @IsMongoId()</code> (<code>create-staff.dto.ts:18-21</code>)</td><td>Ref to <code>Designation</code> (<code>staff.schema.ts:32-33</code>)</td></tr> <tr><td>5</td><td><code>employmentType</code></td><td><code>AppDropdown</code>: <code>full_time, part_time, contract, intern</code></td><td>No</td><td><code>@IsOptional() @IsString()</code> (<code>create-staff.dto.ts:23-28</code>) — <strong>no <code>@IsEnum</code></strong>; enum is documentation only (<code>staff.schema.ts:14-19</code>)</td><td>Defaults to <code>full_time</code> when absent (<code>staff.service.ts:40-41</code>)</td></tr> <tr><td>6</td><td><code>joiningDate</code></td><td><code>AppDatePicker</code></td><td>No</td><td><code>@IsOptional() @IsDateString()</code> (<code>create-staff.dto.ts:30-33</code>)</td><td>Converted to <code>Date</code> (<code>staff.service.ts:42</code>)</td></tr> <tr><td>7</td><td><code>salaryGrade</code></td><td><code>AppTextField</code> (free text)</td><td>No</td><td><code>@IsOptional() @IsString()</code> (<code>create-staff.dto.ts:35-38</code>)</td><td>Trimmed; <strong>reference only</strong>, not payroll (<code>staff.schema.ts:45-46</code>, <code>Staff.md:60</code>)</td></tr> </tbody></table> </div> <ul> <li><strong>Not in this form:</strong> <code>status</code> (always <code>active</code> on create — <code>staff.service.ts:39</code>) and <code>metadata</code> (update-only, <code>update-staff.dto.ts:40-42</code>).</li> <li>Helper texts: "Created as <strong>active</strong>"; "Defaults to <strong>full-time</strong>".</li> <li>Submit: button <code>loading:</code> while pending, anti-double-submit (<a href="staff/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §6</a>); success → snackbar + navigate to detail (SS2).</li> </ul> <h2 id="f2--edit-staff-staffidedit--patch-staffid"><a class="header" href="#f2--edit-staff-staffidedit--patch-staffid">F2 — Edit Staff (<code>/staff/:id/edit</code>) → <code>PATCH /staff/:id</code></a></h2> <p>Source: <code>update-staff.dto.ts:4-42</code>; all fields optional; prefilled from <code>GET /staff/:id</code>. Send only changed fields (<code>staff.service.ts:78-91</code> — <code>changes</code> = audit delta, line 88).</p> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Control</th><th>Required</th><th>Validation (source)</th><th>Notes</th></tr></thead><tbody> <tr><td>1</td><td><code>employeeNumber</code></td><td><code>AppTextField</code></td><td>No</td><td><code>@IsOptional() @IsString()</code> (<code>update-staff.dto.ts:5-8</code>)</td><td>409 on duplicate (<code>staff.service.ts:31-36</code> shared check only on create — <strong>rename dup-check caveat OQ-9</strong>)</td></tr> <tr><td>2</td><td><code>departmentId</code></td><td><code>CatalogPickerSheet</code></td><td>No</td><td><code>@IsOptional() @IsMongoId()</code> (lines 10-13)</td><td>Clearing sends <code>null</code>? — see OQ-11</td></tr> <tr><td>3</td><td><code>designationId</code></td><td><code>CatalogPickerSheet</code></td><td>No</td><td><code>@IsOptional() @IsMongoId()</code> (lines 15-18)</td><td>same</td></tr> <tr><td>4</td><td><code>employmentType</code></td><td><code>AppDropdown</code> (4 values)</td><td>No</td><td><code>@IsOptional() @IsString()</code> (lines 20-23)</td><td>no enum validation</td></tr> <tr><td>5</td><td><code>joiningDate</code></td><td><code>AppDatePicker</code></td><td>No</td><td><code>@IsOptional() @IsDateString()</code> (lines 25-28)</td><td></td></tr> <tr><td>6</td><td><code>salaryGrade</code></td><td><code>AppTextField</code></td><td>No</td><td><code>@IsOptional() @IsString()</code> (lines 30-33)</td><td></td></tr> <tr><td>7</td><td><code>status</code></td><td><code>AppDropdown</code>: <code>active, inactive, on_leave, terminated</code></td><td>No</td><td><code>@IsOptional() @IsString()</code> (lines 35-38)</td><td>lifecycle enum <code>staff.schema.ts:7-12</code>; the sanctioned way to change lifecycle state</td></tr> <tr><td>8</td><td><code>metadata</code></td><td><code>MetadataEditor</code> (key/value)</td><td>No</td><td><code>@IsOptional()</code> (lines 40-42)</td><td>free-form object (<code>staff.schema.ts:51-52</code>)</td></tr> </tbody></table> </div> <h2 id="f3--create-department-departmentsnew--post-departments"><a class="header" href="#f3--create-department-departmentsnew--post-departments">F3 — Create Department (<code>/departments/new</code>) → <code>POST /departments</code></a></h2> <p>Source: <code>department.dto.ts:4-18</code>; <code>department.controller.ts:26-27</code>.</p> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Control</th><th>Required</th><th>Validation (source)</th><th>Server behavior</th></tr></thead><tbody> <tr><td>1</td><td><code>name</code></td><td><code>AppTextField</code></td><td><strong>Yes</strong></td><td><code>@IsString()</code> (<code>department.dto.ts:5-7</code>)</td><td>Trimmed (<code>department.schema.ts:9-10</code>); <strong>unique per tenant</strong> — 409 on dup (<code>department.service.ts:23-25</code>, <code>department.schema.ts:24</code>)</td></tr> <tr><td>2</td><td><code>code</code></td><td><code>AppTextField</code> (short, uppercase hint)</td><td>No</td><td><code>@IsOptional() @IsString()</code> (<code>department.dto.ts:9-12</code>)</td><td>Trimmed (<code>department.schema.ts:12-13</code>)</td></tr> <tr><td>3</td><td><code>headId</code></td><td><code>HeadPicker</code> (active staff)</td><td>No</td><td><code>@IsOptional() @IsMongoId()</code> (<code>department.dto.ts:14-17</code>)</td><td>Ref to <code>Staff</code> (<code>department.schema.ts:15-16</code>)</td></tr> </tbody></table> </div> <ul> <li>Server sets <code>status: 'active'</code> on create (<code>department.service.ts:26-29</code>).</li> <li>Success → snackbar + navigate to detail (SS7); 409 inline on <code>name</code>.</li> </ul> <h2 id="f4--edit-department-departmentsidedit--patch-departmentsid"><a class="header" href="#f4--edit-department-departmentsidedit--patch-departmentsid">F4 — Edit Department (<code>/departments/:id/edit</code>) → <code>PATCH /departments/:id</code></a></h2> <p>Source: <code>department.dto.ts:20-39</code>; all optional.</p> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Control</th><th>Validation (source)</th><th>Notes</th></tr></thead><tbody> <tr><td>1</td><td><code>name</code></td><td><code>AppTextField</code></td><td><code>@IsOptional() @IsString()</code> (lines 22-24)</td><td>409 on duplicate name (<code>department.service.ts:23-25</code> — check runs on create; rename guard applies, verify OQ-9)</td></tr> <tr><td>2</td><td><code>code</code></td><td><code>AppTextField</code></td><td><code>@IsOptional() @IsString()</code> (lines 26-29)</td><td></td></tr> <tr><td>3</td><td><code>headId</code></td><td><code>HeadPicker</code></td><td><code>@IsOptional() @IsMongoId()</code> (lines 31-34)</td><td>unset → clear</td></tr> <tr><td>4</td><td><code>status</code></td><td><code>AppDropdown</code> (<code>active</code>/other)</td><td><code>@IsOptional() @IsString()</code> (lines 36-39)</td><td>catalog soft-state; <strong>no enum</strong> (<code>department.schema.ts:18-19</code> is a plain string)</td></tr> </tbody></table> </div> <h2 id="f5--create-designation-designationsnew--post-designations"><a class="header" href="#f5--create-designation-designationsnew--post-designations">F5 — Create Designation (<code>/designations/new</code>) → <code>POST /designations</code></a></h2> <p>Source: <code>designation.dto.ts:4-18</code>; <code>designation.controller.ts:26-27</code>.</p> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Control</th><th>Required</th><th>Validation (source)</th><th>Server behavior</th></tr></thead><tbody> <tr><td>1</td><td><code>departmentId</code></td><td><code>CatalogPickerSheet</code> (departments)</td><td>No</td><td><code>@IsOptional() @IsMongoId()</code> (<code>designation.dto.ts:5-8</code>)</td><td>Ref to <code>Department</code> (<code>designation.schema.ts:9-10</code>) — reference only, not enforced (OQ-3)</td></tr> <tr><td>2</td><td><code>name</code></td><td><code>AppTextField</code></td><td><strong>Yes</strong></td><td><code>@IsString()</code> (<code>designation.dto.ts:10-12</code>)</td><td>Trimmed (<code>designation.schema.ts:12-13</code>); <strong>unique per tenant</strong> — 409 (<code>designation.service.ts:25-28</code>, <code>designation.schema.ts:24</code>)</td></tr> <tr><td>3</td><td><code>level</code></td><td><code>AppTextField</code> (number) or <code>AppStepper</code></td><td>No</td><td><code>@IsOptional() @IsInt()</code> (<code>designation.dto.ts:14-17</code>)</td><td>Defaults to <code>0</code> (<code>designation.schema.ts:15-16</code>); non-int → 400 details</td></tr> </tbody></table> </div> <ul> <li>Server sets <code>status: 'active'</code> on create (<code>designation.service.ts:29-32</code>).</li> </ul> <h2 id="f6--edit-designation-designationsidedit--patch-designationsid"><a class="header" href="#f6--edit-designation-designationsidedit--patch-designationsid">F6 — Edit Designation (<code>/designations/:id/edit</code>) → <code>PATCH /designations/:id</code></a></h2> <p>Source: <code>designation.dto.ts:20-39</code>; all optional: <code>departmentId</code> (22-24), <code>name</code> (26-29), <code>level</code> (<code>@IsInt()</code>, 31-34), <code>status</code> (36-39).</p> <hr /> <h2 id="cross-form-rules-1"><a class="header" href="#cross-form-rules-1">Cross-form rules</a></h2> <ul> <li>Errors: first invalid field receives focus on submit; errors clear on edit; error announced via <code>Semantics</code> (<a href="staff/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §10</a>, <a href="staff/../00-shared/03_Component_Library.html">00-shared/03 AppTextField</a>).</li> <li>409 <code>DUPLICATE_RESOURCE</code> → inline conflict message + suggest search/refresh (<a href="staff/../00-shared/06_State_Management.html">00-shared/06 §5</a>).</li> <li>404 on save (concurrent deletion) → not-found state, do not resubmit.</li> <li>429 → "Too many requests — retry in Ns" + backoff, no auto-retry (<a href="staff/../00-shared/07_API_Conventions.html">00-shared/07 §4</a>).</li> <li>Autofill hints on <code>employeeNumber</code> (<code>autofillHints: 'organization-identifier'</code> <code>(proposed)</code>); <code>userId</code> picker searchable.</li> <li>Haptics: success <code>lightImpact</code>, error <code>mediumImpact</code> (<a href="staff/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §3</a>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-staff-module"><a class="header" href="#09--user-behaviour-staff-module">09 — User Behaviour (Staff Module)</a></h1> <ul> <li><a href="staff/09_User_Behaviour.html#1-browse--search-behaviour">1. Browse & search behaviour</a></li> <li><a href="staff/09_User_Behaviour.html#2-create-behaviours">2. Create behaviours</a></li> <li><a href="staff/09_User_Behaviour.html#3-edit-behaviours">3. Edit behaviours</a></li> <li><a href="staff/09_User_Behaviour.html#4-deactivate-behaviour-destructive">4. Deactivate behaviour (destructive)</a></li> <li><a href="staff/09_User_Behaviour.html#5-catalog-behaviours-departmentsdesignations">5. Catalog behaviours (departments/designations)</a></li> <li><a href="staff/09_User_Behaviour.html#6-status--error-behaviours-all-screens">6. Status & error behaviours (all screens)</a></li> <li><a href="staff/09_User_Behaviour.html#7-analytics-events-proposed-00-shared10-8-naming">7. Analytics events <code>(proposed)</code> (00-shared/10 §8 naming)</a></li> </ul> <blockquote> <p>Behavioral rules for staff screens. <code>(proposed)</code> = client decision; backend-derived items cite source.</p> </blockquote> <h2 id="1-browse--search-behaviour"><a class="header" href="#1-browse--search-behaviour">1. Browse & search behaviour</a></h2> <ul> <li>HR opens Staff → list loads from <code>GET /staff</code> (page 1, limit 20 — <code>staff.controller.ts:27-28</code>, <code>pagination-query.dto.ts:13-19</code>). Skeleton ≤ 200 ms (<a href="staff/../00-shared/10_QA_Baseline.html">00-shared/10 §1</a>).</li> <li>Search box: client-side filter with 300 ms debounce (<a href="staff/../00-shared/03_Component_Library.html">00-shared/03 AppSearchBar</a>); the <code>q</code> param is sent but ignored server-side for staff (<code>staff.service.ts:64-76</code>, OQ-2) — UI labels "filtering loaded results" when a filter is active.</li> <li>Status/type chips filter loaded pages only; switching chips keeps scroll position.</li> <li>Scroll down hides FAB, scroll up restores (<a href="staff/../00-shared/03_Component_Library.html">00-shared/03 AppFAB</a>); infinite scroll appends while <code>meta.hasNext</code> (<code>pagination-query.dto.ts:52</code>).</li> <li>Pull-to-refresh resets to page 1 and bypasses cache (<a href="staff/../00-shared/06_State_Management.html">00-shared/06 §3.3</a>).</li> <li>Rows are tappable → detail; overflow menu offers View/Edit/Deactivate.</li> </ul> <h2 id="2-create-behaviours"><a class="header" href="#2-create-behaviours">2. Create behaviours</a></h2> <ul> <li>"Add staff" FAB (<code>staff.create</code>) → SS3. User picker lists users (required — <code>create-staff.dto.ts:5-7</code>); a staff profile cannot exist without a user.</li> <li>Submitting with a duplicate <code>employeeNumber</code> → 409 inline on the field (<code>staff.service.ts:31-36</code>); user edits and resubmits — no navigation loss.</li> <li>On success: navigate to detail + snackbar; <code>StaffCreated</code> fans out in-app (<code>event-queue-map.ts:34</code>). Never claim an email was sent — the emails worker has no <code>StaffCreated</code> handler (<code>email.worker.ts:25-42</code>, OQ-6).</li> </ul> <h2 id="3-edit-behaviours"><a class="header" href="#3-edit-behaviours">3. Edit behaviours</a></h2> <ul> <li>Edit prefills from detail; only changed fields sent (<code>staff.service.ts:88</code>).</li> <li>Status dropdown exposes the full enum (<code>staff.schema.ts:7-12</code>): moving someone to <code>inactive</code>/<code>on_leave</code> keeps the record (unlike deactivate).</li> <li>Metadata editor: additive key/values; empty object omitted from the PATCH <code>(proposed)</code>.</li> <li>Concurrent-edit awareness: <code>version</code> exists server-side (<code>base.repository.ts:63, 71</code>; <code>base.schema.ts:29-31</code>) but is <strong>not</strong> in the DTO (<code>update-staff.dto.ts</code>) — last-write-wins; client does not implement optimistic conflict UI (OQ-12).</li> </ul> <h2 id="4-deactivate-behaviour-destructive"><a class="header" href="#4-deactivate-behaviour-destructive">4. Deactivate behaviour (destructive)</a></h2> <ul> <li>Trigger anywhere → SS5 confirm dialog; copy is honest: "removes from all lists, no undo" (<code>base.repository.ts:68-74</code>).</li> <li>Confirm → <code>DELETE</code> → success snackbar + list refresh (row gone). 404 → "already deactivated" + refresh.</li> <li><strong>No restore flow</strong> — if the org misclicks, the record is gone from the UI (data survives in DB with <code>isDeleted: true</code>). HR must recreate (and the old <code>employeeNumber</code> remains blocked by the unique index <code>staff.schema.ts:57</code>).</li> <li>Rejected path: user without <code>staff.delete</code> sees no deactivate action (client mirror of <code>permissions.constants.ts:22</code>).</li> </ul> <h2 id="5-catalog-behaviours-departmentsdesignations"><a class="header" href="#5-catalog-behaviours-departmentsdesignations">5. Catalog behaviours (departments/designations)</a></h2> <ul> <li>Add/rename/recode/head assignment: same form rules as F3–F6; duplicate name → 409 inline (<code>department.service.ts:23-25</code>, <code>designation.service.ts:25-28</code>).</li> <li>Delete confirm: warns "existing staff references will dangle" — <strong>no server guard</strong> blocks deleting a department with members (OQ-4); UI offers no member reassignment step (<code>(proposed)</code> mitigation: show member count in the dialog).</li> <li>Catalog changes do <strong>not</strong> produce notifications (no events emitted — <code>department.service.ts:19-20</code>, <code>designation.service.ts:22-23</code>; OQ-10).</li> <li>Sorting designations by <code>level</code> and departments by name: client-side <code>(proposed)</code>.</li> </ul> <h2 id="6-status--error-behaviours-all-screens"><a class="header" href="#6-status--error-behaviours-all-screens">6. Status & error behaviours (all screens)</a></h2> <ul> <li><code>AppErrorState</code> per code (<a href="staff/../00-shared/06_State_Management.html">00-shared/06 §5</a>): 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.</li> <li>Offline: cached list + <code>AppOfflineBanner</code>; writes blocked with guidance (<a href="staff/../00-shared/06_State_Management.html">00-shared/06 §3.7</a>).</li> <li>Session expiry mid-flow: re-login preserving nav (<a href="staff/../00-shared/06_State_Management.html">00-shared/06 §3.6</a>).</li> <li>Optimistic updates: <strong>none</strong> in this module — all mutations wait for the server result (status changes included; safe per <a href="staff/../00-shared/06_State_Management.html">00-shared/06 §3.5</a>).</li> </ul> <h2 id="7-analytics-events-proposed-00-shared10-8-naming"><a class="header" href="#7-analytics-events-proposed-00-shared10-8-naming">7. Analytics events <code>(proposed)</code> (<a href="staff/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a> naming)</a></h2> <p><code>staff.list.view</code>, <code>staff.list.search</code>, <code>staff.list.filter(status|type)</code>, <code>staff.detail.view(id)</code>, <code>staff.create.submit|success|duplicate</code>, <code>staff.update.submit|success</code>, <code>staff.deactivate.confirm|success</code>, <code>department.create.success|duplicate</code>, <code>department.delete.confirm</code>, <code>designation.create.success|duplicate</code>, <code>designation.delete.confirm</code>.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-staff-module"><a class="header" href="#10--interaction-specification-staff-module">10 — Interaction Specification (Staff Module)</a></h1> <ul> <li><a href="staff/10_Interaction_Specification.html#1-gestures--inputs">1. Gestures & inputs</a></li> <li><a href="staff/10_Interaction_Specification.html#2-motion-language">2. Motion language</a></li> <li><a href="staff/10_Interaction_Specification.html#3-haptics">3. Haptics</a></li> <li><a href="staff/10_Interaction_Specification.html#4-frustration-controls">4. Frustration controls</a></li> <li><a href="staff/10_Interaction_Specification.html#5-keyboard--desktop">5. Keyboard & desktop</a></li> <li><a href="staff/10_Interaction_Specification.html#6-modal-hierarchy-shared-rule-applied">6. Modal hierarchy (shared rule applied)</a></li> </ul> <blockquote> <p>Module specialization of <a href="staff/../00-shared/08_Interaction_&_Motion.html">00-shared/08_Interaction_&_Motion.md</a>.</p> </blockquote> <h2 id="1-gestures--inputs"><a class="header" href="#1-gestures--inputs">1. Gestures & inputs</a></h2> <div class="table-wrapper"><table><thead><tr><th>Gesture</th><th>Behavior</th><th>Screen</th></tr></thead><tbody> <tr><td>Tap row</td><td>Open detail (SS2); ripple <code>m-fast</code></td><td>S1, S6, S9</td></tr> <tr><td>Tap FAB</td><td>"Add staff / department / designation" — scale-in <code>m-entrance</code></td><td>S1, S6, S9</td></tr> <tr><td>Tap row menu (⋮)</td><td><code>AppMenu</code>: View / Edit / Deactivate (destructive in <code>error</code>)</td><td>S1, S6, S9</td></tr> <tr><td>Long-press row</td><td>Same menu (phone); haptic <code>selectionClick</code></td><td>S1, S6, S9</td></tr> <tr><td>Pull down</td><td>Refresh (reset page 1, bypass cache); spinner <code>primary</code>; <code>lightImpact</code> on completion</td><td>S1, S6, S9</td></tr> <tr><td>Swipe left on row <code>(proposed)</code></td><td>Quick "Edit" only (deactivate stays in menu — destructive ops never swipe-first)</td><td>S1</td></tr> <tr><td>Back / back-gesture</td><td>Pop to list; on root tab → previous shell branch</td><td>all</td></tr> <tr><td>ESC (desktop)</td><td>Close dialog/sheet/menu; cancel selection</td><td>all</td></tr> <tr><td>Enter in form field</td><td>Next field; Ctrl/Cmd+Enter submits</td><td>S3, S4, S8, S11</td></tr> </tbody></table> </div> <h2 id="2-motion-language"><a class="header" href="#2-motion-language">2. Motion language</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Motion</th><th>Token</th></tr></thead><tbody> <tr><td>List → detail push</td><td>Slide forward + content fade/rise</td><td><code>m-base</code> / <code>m-slow</code> (<a href="staff/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §4</a>)</td></tr> <tr><td>Avatar list → detail header</td><td>Hero flight (shared element)</td><td><code>m-slow</code></td></tr> <tr><td>List rows on first load</td><td>Entrance stagger 40 ms</td><td><code>m-entrance</code></td></tr> <tr><td>Status badge change</td><td>Color/size cross-fade; icon morph via <code>AnimatedSwitcher</code></td><td><code>m-fast</code></td></tr> <tr><td>Skeleton → content</td><td>Cross-fade, no pop</td><td><code>m-slow</code></td></tr> <tr><td>Dialog (deactivate)</td><td>Scale + fade in; slide down on dismiss</td><td><code>m-base</code></td></tr> <tr><td>FAB hide/show on scroll</td><td>Fade + slide</td><td><code>m-entrance</code></td></tr> <tr><td>Filter chip select</td><td>Ink ripple + pill slide</td><td><code>m-fast</code></td></tr> <tr><td>Infinite scroll footer</td><td>Bottom spinner fade-in</td><td><code>m-instant</code></td></tr> <tr><td>Submit pending</td><td>Spinner replaces label; success → brief check + snackbar</td><td>—</td></tr> </tbody></table> </div> <p>Reduced motion (system setting): everything → fade ≤ <code>m-instant</code>; no stagger, no shimmer loop, no hero (<a href="staff/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §4</a>).</p> <h2 id="3-haptics"><a class="header" href="#3-haptics">3. Haptics</a></h2> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Haptic</th></tr></thead><tbody> <tr><td>Create/update success</td><td><code>lightImpact</code></td></tr> <tr><td>Validation/API error</td><td><code>mediumImpact</code></td></tr> <tr><td>Deactivate confirm</td><td><code>heavyImpact</code> (destructive)</td></tr> <tr><td>Menu open</td><td><code>selectionClick</code></td></tr> </tbody></table> </div> <p>Off when system haptics disabled; never on web/desktop (<a href="staff/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §3</a>).</p> <h2 id="4-frustration-controls-1"><a class="header" href="#4-frustration-controls-1">4. Frustration controls</a></h2> <ul> <li>Every interactive element gives press feedback within 100 ms (<a href="staff/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §6</a>).</li> <li>Submit buttons: disabled while pending (anti-double-submit); 409/404 never silently swallowed — inline or snackbar.</li> <li>Network-bound actions always show pending state (spinner in button, skeleton in lists) — never a silent wait.</li> <li>Long lists: keep scroll position across filter toggles; restore position after detail pop (master-detail preserves state per shell branch — <a href="staff/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §3</a>).</li> </ul> <h2 id="5-keyboard--desktop"><a class="header" href="#5-keyboard--desktop">5. Keyboard & desktop</a></h2> <ul> <li>Tab order: search → chips → rows → FAB; visible focus ring <code>primary</code> (<a href="staff/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §6</a>).</li> <li><code>Ctrl+K</code> global search (<a href="staff/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §7</a>); <code>N</code> on list screens → create <code>(proposed)</code>; ESC closes dialogs/menus.</li> <li>Right-click row = same menu as long-press (<a href="staff/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §2</a>).</li> <li>Breadcrumbs on desktop at depth > 2: <code>Staff / Departments / Administration</code> (<a href="staff/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §3</a>).</li> </ul> <h2 id="6-modal-hierarchy-shared-rule-applied"><a class="header" href="#6-modal-hierarchy-shared-rule-applied">6. Modal hierarchy (shared rule applied)</a></h2> <p>Snackbar > bottom sheet (pickers, filters) > dialog (deactivate confirm) > full-screen page (create/edit forms) (<a href="staff/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §5</a>). Deactivate is a dialog — not a sheet — because it is destructive and needs explicit confirm.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-staff-module"><a class="header" href="#11--design-system-mapping-staff-module">11 — Design System Mapping (Staff Module)</a></h1> <ul> <li><a href="staff/11_Design_System_Mapping.html#1-module-surfaces">1. Module surfaces</a></li> <li><a href="staff/11_Design_System_Mapping.html#2-typography-mapping">2. Typography mapping</a></li> <li><a href="staff/11_Design_System_Mapping.html#3-iconography">3. Iconography</a></li> <li><a href="staff/11_Design_System_Mapping.html#4-layout-tokens">4. Layout tokens</a></li> <li><a href="staff/11_Design_System_Mapping.html#5-status-visual-grammar-staff-specific">5. Status visual grammar (staff-specific)</a></li> <li><a href="staff/11_Design_System_Mapping.html#6-dark-mode-parity">6. Dark mode parity</a></li> <li><a href="staff/11_Design_System_Mapping.html#7-theming-rules-enforced-shared">7. Theming rules (enforced, shared)</a></li> </ul> <blockquote> <p>Extends <a href="staff/../00-shared/04_Design_System_Mapping.html">00-shared/04_Design_System_Mapping.md</a> with staff-specific widget → token mappings. Theme, typography, iconography, motion, layout: as shared; only module deltas listed.</p> </blockquote> <h2 id="1-module-surfaces"><a class="header" href="#1-module-surfaces">1. Module surfaces</a></h2> <div class="table-wrapper"><table><thead><tr><th>Surface</th><th>Token</th><th>Notes</th></tr></thead><tbody> <tr><td>List rows / cards</td><td><code>surfaceContainerLow</code>, <code>e-0</code>/<code>e-1</code>, <code>r-lg</code></td><td><code>AppCard</code>/<code>AppListTile</code> (<a href="staff/../00-shared/04_Design_System_Mapping.html">00-shared/04 §2</a>)</td></tr> <tr><td>Detail header card</td><td><code>surfaceContainerHigh</code>, <code>e-1</code></td><td>Hero source</td></tr> <tr><td>Selected master-detail row</td><td><code>secondaryContainer</code> highlight</td><td><a href="staff/../00-shared/04_Design_System_Mapping.html">00-shared/04 §2</a> (NavigationRail pattern)</td></tr> <tr><td>Status badge fill</td><td><code>active</code>→<code>secondaryContainer</code>, <code>inactive</code>→<code>surfaceVariant</code>, <code>on_leave</code>→<code>tertiaryContainer</code>, <code>terminated</code>→<code>errorContainer</code></td><td>icon+label, never color-only (<a href="staff/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §9</a>)</td></tr> <tr><td>Filter chips</td><td>selected <code>secondaryContainer</code>, else <code>surfaceVariant</code></td><td><a href="staff/../00-shared/04_Design_System_Mapping.html">00-shared/04 §2</a></td></tr> <tr><td>FAB</td><td><code>primaryContainer</code>, <code>e-3</code>, 56 dp</td><td><a href="staff/../00-shared/04_Design_System_Mapping.html">00-shared/04 §2</a></td></tr> <tr><td>Deactivate dialog</td><td><code>surfaceContainerHigh</code>, <code>e-4</code>, destructive action <code>error</code>/<code>onError</code></td><td><a href="staff/../00-shared/03_Component_Library.html">00-shared/03 AppDialog</a></td></tr> <tr><td>Empty states</td><td><code>displaySmall</code> title, <code>bodyMedium</code> <code>onSurfaceVariant</code></td><td><a href="staff/../00-shared/04_Design_System_Mapping.html">00-shared/04 §3</a></td></tr> </tbody></table> </div> <h2 id="2-typography-mapping-1"><a class="header" href="#2-typography-mapping-1">2. Typography mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Element</th><th>Text token</th><th>Note</th></tr></thead><tbody> <tr><td>Screen title "Staff"</td><td><code>headlineMedium</code></td><td>one per screen (<a href="staff/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §11</a>)</td></tr> <tr><td>Employee number</td><td><code>mono</code> 14 + <code>tabularFigures</code></td><td>IDs always mono (<a href="staff/../00-shared/02_Design_Tokens.html">00-shared/02 §2</a>)</td></tr> <tr><td>Row title / section header</td><td><code>titleMedium</code></td><td></td></tr> <tr><td>Subtitle (dept • designation)</td><td><code>bodyMedium</code> <code>onSurfaceVariant</code></td><td></td></tr> <tr><td>Status badge</td><td><code>labelMedium</code></td><td></td></tr> <tr><td>Form labels / helper</td><td><code>bodyMedium</code> / <code>bodySmall</code></td><td></td></tr> <tr><td>Dialog title</td><td><code>titleLarge</code></td><td></td></tr> </tbody></table> </div> <h2 id="3-iconography-3"><a class="header" href="#3-iconography-3">3. Iconography</a></h2> <p>Material Symbols outlined (<a href="staff/../00-shared/02_Design_Tokens.html">00-shared/02 §7</a>): <code>badge</code> (staff), <code>groups</code> (departments), <code>workspace_premium</code> (designations), <code>search</code>, <code>add</code>, <code>edit</code>, <code>delete</code>, <code>person_off</code> (deactivate), <code>today</code> (joining date), <code>payments</code>/<code>attach_money</code> (salary grade), <code>schedule</code> (employment type), <code>account_tree</code> (dept ref), <code>category</code> (designation ref). Status icons filled variant. Every icon paired with text label or tooltip (<a href="staff/../00-shared/02_Design_Tokens.html">00-shared/02 §7</a>).</p> <h2 id="4-layout-tokens-1"><a class="header" href="#4-layout-tokens-1">4. Layout tokens</a></h2> <ul> <li>List rows 56 dp (<a href="staff/../00-shared/02_Design_Tokens.html">00-shared/02 §9</a>); avatar 40 (list), 64 (detail header).</li> <li>Screen horizontal padding 16 (phone) / 24 (tablet+); card padding 16; section gap 24 (<a href="staff/../00-shared/04_Design_System_Mapping.html">00-shared/04 §6</a>).</li> <li>Form field gap 16; master-detail breakpoint 840 dp.</li> <li>FAB above bottom nav (phone); page controls right-aligned (desktop).</li> </ul> <h2 id="5-status-visual-grammar-staff-specific"><a class="header" href="#5-status-visual-grammar-staff-specific">5. Status visual grammar (staff-specific)</a></h2> <pre><code>active → [✓ icon] "Active" secondaryContainer pill inactive → [– icon] "Inactive" outline pill on_leave → [⏳ icon] "On leave" tertiaryContainer pill terminated → [✕ icon] "Terminated" errorContainer pill </code></pre> <p>Enum source: <code>staff.schema.ts:7-12</code>. Same grammar reused by Teachers (<code>teacher.schema.ts:7-12</code>).</p> <h2 id="6-dark-mode-parity"><a class="header" href="#6-dark-mode-parity">6. Dark mode parity</a></h2> <p>Elevation substitutes for color on dark surfaces; badges keep container-role colors at full contrast; no hardcoded <code>Color</code> — all tokens (<code>00-shared/04 §7</code>).</p> <h2 id="7-theming-rules-enforced-shared"><a class="header" href="#7-theming-rules-enforced-shared">7. Theming rules (enforced, shared)</a></h2> <ol> <li>No literal colors/spacing/durations in widgets.</li> <li>Tenant branding overrides <code>primary</code> only (<code>00-shared/02 §1</code>).</li> <li>Dark mode first-class; goldens at light+dark, 3 sizes (<code>00-shared/03 §G</code>).</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-staff-module"><a class="header" href="#12--api-mapping-staff-module">12 — API Mapping (Staff Module)</a></h1> <ul> <li><a href="staff/12_API_Mapping.html#1-staff-staffcontrollerts">1. Staff (<code>staff.controller.ts</code>)</a></li> <li><a href="staff/12_API_Mapping.html#2-departments-departmentcontrollerts">2. Departments (<code>department.controller.ts</code>)</a></li> <li><a href="staff/12_API_Mapping.html#3-designations-designationcontrollerts">3. Designations (<code>designation.controller.ts</code>)</a></li> <li><a href="staff/12_API_Mapping.html#4-error-codes-this-module-can-produce">4. Error codes this module can produce</a></li> <li><a href="staff/12_API_Mapping.html#5-envelope-examples">5. Envelope examples</a></li> <li><a href="staff/12_API_Mapping.html#6-cross-module-endpoints-the-client-needs-joins-proposed">6. Cross-module endpoints the client needs (joins <code>(proposed)</code>)</a></li> <li><a href="staff/12_API_Mapping.html#7-query-param-reality-check">7. Query-param reality check</a></li> </ul> <blockquote> <p>Exact endpoints from <code>src/modules/staff/controllers/**</code>; envelopes per <a href="staff/../00-shared/07_API_Conventions.html">00-shared/07_API_Conventions.md</a> (exact wire contract from <code>response-envelope.interceptor.ts:11-62</code> and <code>http-exception.filter.ts:27-82</code>). Base <code>/api/v1</code>; Bearer JWT; <code>tenantId</code> from token only (client never sends it — <code>base.repository.ts:32-36</code> writes it server-side).</p> </blockquote> <h2 id="1-staff-staffcontrollerts"><a class="header" href="#1-staff-staffcontrollerts">1. Staff (<code>staff.controller.ts</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Method</th><th>Path</th><th>Query/Body</th><th>Success</th><th>Errors (source)</th><th>Permission (intended)</th></tr></thead><tbody> <tr><td>E1</td><td><code>POST</code></td><td><code>/staff</code></td><td>Body <code>CreateStaffDto</code> (<code>create-staff.dto.ts:4-38</code>)</td><td>201 envelope <code>data</code>=Staff doc (<code>staff.service.ts:37-55</code>)</td><td>400 validation; 409 dup <code>employeeNumber</code> (<code>staff.service.ts:32-36</code>); 401</td><td><code>staff.create</code> (<code>permissions.constants.ts:20</code>)</td></tr> <tr><td>E2</td><td><code>GET</code></td><td><code>/staff</code></td><td><code>page, limit</code> (1–100, default 20), <code>sort</code>, <code>q</code> (<code>pagination-query.dto.ts:5-30</code>)</td><td>200 <code>data</code>=Staff[], <code>meta</code>=<code>{page,limit,totalItems,totalPages,hasNext,hasPrevious}</code> (<code>staff.service.ts:64-76</code>; <code>pagination-query.dto.ts:32-55</code>)</td><td>400 (page/limit); 401</td><td><code>staff.read</code> (line 19)</td></tr> <tr><td>E3</td><td><code>GET</code></td><td><code>/staff/:id</code></td><td>—</td><td>200 <code>data</code>=Staff doc</td><td>404 <code>RESOURCE_NOT_FOUND</code> (<code>staff.service.ts:58-62</code>); 400 invalid id (CastError → <code>http-exception.filter.ts:47-48,91-92</code>); 401</td><td><code>staff.read</code></td></tr> <tr><td>E4</td><td><code>PATCH</code></td><td><code>/staff/:id</code></td><td>Body <code>UpdateStaffDto</code> (<code>update-staff.dto.ts:4-42</code>)</td><td>200 <code>data</code>=updated doc (<code>staff.service.ts:78-91</code>); <code>StaffUpdated</code> → audit-write (<code>event-queue-map.ts:35</code>)</td><td>404 (line 79); 400; 401</td><td><code>staff.update</code> (line 21)</td></tr> <tr><td>E5</td><td><code>DELETE</code></td><td><code>/staff/:id</code></td><td>—</td><td>200 <code>data</code>=empty (soft-delete: <code>isDeleted/deletedAt/deletedBy</code> + version++ — <code>base.repository.ts:68-74</code>); <code>StaffDeleted</code> → audit-write (<code>event-queue-map.ts:36</code>)</td><td>404 (line 94-95); 401</td><td><code>staff.delete</code> (line 22)</td></tr> </tbody></table> </div> <p><strong>Staff doc shape</strong> (response <code>data</code> — <code>staff.schema.ts:21-52</code> + <code>base.schema.ts:8-35</code>):</p> <pre><code class="language-json">{ "_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" } </code></pre> <h2 id="2-departments-departmentcontrollerts"><a class="header" href="#2-departments-departmentcontrollerts">2. Departments (<code>department.controller.ts</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Method</th><th>Path</th><th>Body</th><th>Success</th><th>Errors</th><th>Permission</th></tr></thead><tbody> <tr><td>E6</td><td><code>POST</code></td><td><code>/departments</code></td><td><code>CreateDepartmentDto</code> (<code>department.dto.ts:4-18</code>)</td><td>201 <code>data</code>=doc, <code>status:'active'</code> (<code>department.service.ts:22-30</code>)</td><td>400; 409 dup <code>name</code> (<code>department.service.ts:23-25</code>); 401</td><td><code>department.manage</code> (<code>permissions.constants.ts:23</code>)</td></tr> <tr><td>E7</td><td><code>GET</code></td><td><code>/departments</code></td><td><code>page, limit, sort, q</code></td><td>200 <code>data</code>+<code>meta</code> (<code>department.service.ts:38-50</code>)</td><td>400; 401</td><td><code>department.manage</code></td></tr> <tr><td>E8</td><td><code>GET</code></td><td><code>/departments/:id</code></td><td>—</td><td>200 <code>data</code>=doc</td><td>404 (<code>department.service.ts:32-36</code>); 400; 401</td><td><code>department.manage</code></td></tr> <tr><td>E9</td><td><code>PATCH</code></td><td><code>/departments/:id</code></td><td><code>UpdateDepartmentDto</code> (lines 20-39)</td><td>200 <code>data</code>=updated</td><td>404 (line 57-58); 400; 401</td><td><code>department.manage</code></td></tr> <tr><td>E10</td><td><code>DELETE</code></td><td><code>/departments/:id</code></td><td>—</td><td>200 soft-delete (no member guard — OQ-4)</td><td>404 (line 63-64); 401</td><td><code>department.manage</code></td></tr> </tbody></table> </div> <p><strong>Department doc shape</strong> (<code>department.schema.ts:7-19</code>): <code>name</code>, <code>code?</code>, <code>headId?</code> (ref Staff), <code>status</code> (string, default <code>'active'</code>) + BaseSchema fields. <strong>No events</strong> on any department operation (<code>department.service.ts:19-20</code>).</p> <h2 id="3-designations-designationcontrollerts"><a class="header" href="#3-designations-designationcontrollerts">3. Designations (<code>designation.controller.ts</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Method</th><th>Path</th><th>Body</th><th>Success</th><th>Errors</th><th>Permission</th></tr></thead><tbody> <tr><td>E11</td><td><code>POST</code></td><td><code>/designations</code></td><td><code>CreateDesignationDto</code> (<code>designation.dto.ts:4-18</code>)</td><td>201 <code>data</code>=doc, <code>status:'active'</code>, <code>level</code> default 0 (<code>designation.service.ts:25-32</code>; <code>designation.schema.ts:15-16,18-19</code>)</td><td>400 (incl. <code>level</code> non-int); 409 dup <code>name</code> (<code>designation.service.ts:25-28</code>); 401</td><td><code>designation.manage</code> (<code>permissions.constants.ts:24</code>)</td></tr> <tr><td>E12</td><td><code>GET</code></td><td><code>/designations</code></td><td><code>page, limit, sort, q</code></td><td>200 <code>data</code>+<code>meta</code> (<code>designation.service.ts:41-50</code>)</td><td>400; 401</td><td><code>designation.manage</code></td></tr> <tr><td>E13</td><td><code>GET</code></td><td><code>/designations/:id</code></td><td>—</td><td>200 <code>data</code>=doc</td><td>404 (<code>designation.service.ts:35-39</code>); 400; 401</td><td><code>designation.manage</code></td></tr> <tr><td>E14</td><td><code>PATCH</code></td><td><code>/designations/:id</code></td><td><code>UpdateDesignationDto</code> (lines 20-39)</td><td>200 <code>data</code>=updated</td><td>404 (line 60-61); 400; 401</td><td><code>designation.manage</code></td></tr> <tr><td>E15</td><td><code>DELETE</code></td><td><code>/designations/:id</code></td><td>—</td><td>200 soft-delete</td><td>404 (line 66-67); 401</td><td><code>designation.manage</code></td></tr> </tbody></table> </div> <p><strong>Designation doc shape</strong> (<code>designation.schema.ts:7-19</code>): <code>departmentId?</code> (ref Department), <code>name</code>, <code>level</code> (int, default 0), <code>status</code> (string, default <code>'active'</code>) + BaseSchema fields. <strong>No events</strong> (<code>designation.service.ts:22-23</code>).</p> <h2 id="4-error-codes-this-module-can-produce"><a class="header" href="#4-error-codes-this-module-can-produce">4. Error codes this module can produce</a></h2> <div class="table-wrapper"><table><thead><tr><th>HTTP</th><th>Code</th><th>Producer</th></tr></thead><tbody> <tr><td>400</td><td><code>VALIDATION_ERROR</code></td><td>DTO validators (all endpoints); CastError on <code>:id</code> (<code>http-exception.filter.ts:47-48</code>)</td></tr> <tr><td>401</td><td><code>UNAUTHENTICATED</code></td><td><code>JwtAuthGuard</code> missing/expired token (<code>jwt-auth.guard.ts:36-59</code>)</td></tr> <tr><td>403</td><td><code>PERMISSION_DENIED</code></td><td><code>RbacGuard</code> where applied — <strong>not yet wired on these controllers</strong> (<code>staff.controller.ts:21</code>; OQ-1)</td></tr> <tr><td>404</td><td><code>RESOURCE_NOT_FOUND</code></td><td>missing staff/department/designation (<code>staff.service.ts:60,81,95</code>; <code>department.service.ts:34,58,64</code>; <code>designation.service.ts:37,61,67</code>)</td></tr> <tr><td>409</td><td><code>DUPLICATE_RESOURCE</code></td><td>dup employeeNumber / dept name / designation name (<code>staff.service.ts:32-36</code>; <code>department.service.ts:23-25</code>; <code>designation.service.ts:25-28</code>); unique indexes (<code>staff.schema.ts:57-58</code>, <code>department.schema.ts:24</code>, <code>designation.schema.ts:24</code>)</td></tr> <tr><td>429</td><td><code>RATE_LIMITED</code></td><td>platform rate limiter (auth 10/min, api 100/min, admin 500/min — <code>00-shared/07 §4</code>)</td></tr> <tr><td>5xx</td><td><code>INTERNAL_SERVER_ERROR</code></td><td>unexpected (<code>http-exception.filter.ts:50-55,60-65</code>)</td></tr> </tbody></table> </div> <p>422 <code>BUSINESS_RULE_VIOLATION</code> is defined by the filter (<code>http-exception.filter.ts:33</code>) but <strong>no staff-module code path produces it</strong>.</p> <h2 id="5-envelope-examples"><a class="header" href="#5-envelope-examples">5. Envelope examples</a></h2> <p>Success (paginated E2):</p> <pre><code class="language-json">{ "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": "…" } </code></pre> <p>Error (duplicate employee number E1):</p> <pre><code class="language-json">{ "success": false, "message": "Employee number \"STF001\" already exists.", "error": { "code": "DUPLICATE_RESOURCE" }, "timestamp": "…", "requestId": "…" } </code></pre> <p>Source of 409 message: <code>staff.service.ts:34-36</code> (<code>ConflictException</code> string); error envelope: <code>http-exception.filter.ts:73-81</code>; <code>DUPLICATE_RESOURCE</code> mapping: <code>http-exception.filter.ts:32</code>.</p> <h2 id="6-cross-module-endpoints-the-client-needs-joins-proposed"><a class="header" href="#6-cross-module-endpoints-the-client-needs-joins-proposed">6. Cross-module endpoints the client needs (joins <code>(proposed)</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Purpose</th><th>Endpoint</th><th>Source</th></tr></thead><tbody> <tr><td>User picker + name join (S3/S2)</td><td><code>GET /users</code>, <code>GET /users/:id</code></td><td>Users module</td></tr> <tr><td>Head display (S7)</td><td><code>GET /staff/:id</code> on <code>headId</code></td><td><code>department.schema.ts:15-16</code></td></tr> <tr><td>Dept/designation name resolution (S2)</td><td><code>GET /departments/:id</code>, <code>GET /designations/:id</code></td><td>refs <code>staff.schema.ts:29-33</code></td></tr> <tr><td>Member counts (S7/S10)</td><td><code>GET /staff</code> filtered client-side</td><td>no server-side filter (<code>staff.service.ts:64-76</code>)</td></tr> </tbody></table> </div> <h2 id="7-query-param-reality-check"><a class="header" href="#7-query-param-reality-check">7. Query-param reality check</a></h2> <ul> <li><code>sort</code> and <code>q</code> are accepted by <code>PaginationQueryDto</code> (<code>pagination-query.dto.ts:21-29</code>) but <strong>unused</strong> by all three list services (<code>staff.service.ts:64-76</code>, <code>department.service.ts:38-50</code>, <code>designation.service.ts:41-50</code>) — server-side search/sort is <code>(planned)</code> (OQ-2). No filter params exist for <code>status</code>, <code>employmentType</code>, <code>departmentId</code>, or <code>designationId</code>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-staff-module"><a class="header" href="#13--state-management-staff-module">13 — State Management (Staff Module)</a></h1> <ul> <li><a href="staff/13_State_Management.html#1-cubits">1. Cubits</a></li> <li><a href="staff/13_State_Management.html#2-async-state-machine-all-cubits">2. Async state machine (all cubits)</a></li> <li><a href="staff/13_State_Management.html#3-pagination-mixin-paginatedlistmixint">3. Pagination (mixin <code>PaginatedListMixin<T></code>)</a></li> <li><a href="staff/13_State_Management.html#4-caching--staleness">4. Caching & staleness</a></li> <li><a href="staff/13_State_Management.html#5-optimistic-updates">5. Optimistic updates</a></li> <li><a href="staff/13_State_Management.html#6-cross-screen-sync">6. Cross-screen sync</a></li> <li><a href="staff/13_State_Management.html#7-error-handling-module-specifics">7. Error handling (module specifics)</a></li> <li><a href="staff/13_State_Management.html#8-testing-hooks">8. Testing hooks</a></li> </ul> <blockquote> <p>Per-screen state on top of the shared architecture (<a href="staff/../00-shared/06_State_Management.html">00-shared/06_State_Management.md</a>). Stack: <code>flutter_bloc</code> Cubits, <code>get_it</code> DI, repository layer with <code>dio</code>.</p> </blockquote> <h2 id="1-cubits-5"><a class="header" href="#1-cubits-5">1. Cubits</a></h2> <div class="table-wrapper"><table><thead><tr><th>Cubit</th><th>Screen</th><th>Events</th><th>State</th></tr></thead><tbody> <tr><td><code>StaffListCubit</code></td><td>S1</td><td><code>Load</code>, <code>Refresh</code>, <code>LoadMore</code>, <code>Retry</code>, <code>ChangeStatusFilter</code>, <code>ChangeTypeFilter</code>, <code>Search(q)</code></td><td><code>{loadState, items, page, hasNext, isLoadingMore, statusFilter, typeFilter, query, meta}</code></td></tr> <tr><td><code>StaffDetailCubit</code></td><td>S2</td><td><code>Load(id)</code>, <code>Retry</code></td><td><code>{loadState, staff?, department?, designation?, user?}</code></td></tr> <tr><td><code>StaffFormCubit</code></td><td>S3/S4</td><td><code>LoadRefs</code>, <code>Load(initial)</code>, <code>Submit(delta)</code>, <code>Clear</code></td><td><code>{loadState, submitState, fields, users, departments, designations, fieldErrors}</code></td></tr> <tr><td><code>DepartmentListCubit</code></td><td>S6</td><td><code>Load</code>, <code>Refresh</code>, <code>LoadMore</code>, <code>Retry</code></td><td>paginated + <code>membersCountByDept</code> <code>(proposed)</code></td></tr> <tr><td><code>DepartmentFormCubit</code></td><td>S8</td><td><code>Load(initial)</code>, <code>Submit(delta)</code></td><td>form state</td></tr> <tr><td><code>DesignationListCubit</code></td><td>S9</td><td><code>Load</code>, <code>Refresh</code>, <code>LoadMore</code>, <code>Retry</code></td><td>paginated</td></tr> <tr><td><code>DesignationFormCubit</code></td><td>S11</td><td><code>Load(initial)</code>, <code>Submit(delta)</code></td><td>form state</td></tr> <tr><td><code>DeactivateCubit</code></td><td>S5</td><td><code>Confirm(id)</code>, <code>Dismiss</code></td><td><code>{idle, submitting, success, error}</code></td></tr> </tbody></table> </div> <p>Deactivate lives in its own cubit (not the list cubit) so the dialog state is independent and testable.</p> <h2 id="2-async-state-machine-all-cubits"><a class="header" href="#2-async-state-machine-all-cubits">2. Async state machine (all cubits)</a></h2> <p><code>Initial → Loading → Success | Error(ApiException)</code> (<a href="staff/../00-shared/06_State_Management.html">00-shared/06 §3.1</a>); UI maps: Loading → <code>AppSkeleton</code>, Error → <code>AppErrorState(code, retry)</code>, Success+empty → <code>AppEmptyState</code>, Success → content. Form cubits add <code>submitState</code> with <code>fieldErrors</code> mapped from <code>ApiException.details</code> (400 → <code>error.details[]</code>, <code>http-exception.filter.ts:103-107</code>).</p> <h2 id="3-pagination-mixin-paginatedlistmixint"><a class="header" href="#3-pagination-mixin-paginatedlistmixint">3. Pagination (mixin <code>PaginatedListMixin<T></code>)</a></h2> <ul> <li>Contract: <code>page</code>/<code>limit</code>(20)/<code>sort</code>/<code>q</code> (<a href="staff/../00-shared/06_State_Management.html">00-shared/06 §3.2</a>).</li> <li><code>loadMore</code> fires when <code>meta.hasNext</code> (<code>pagination-query.dto.ts:52</code>) and <code>isLoadingMore == false</code>; appends, dedupes by <code>_id</code>.</li> <li><code>Refresh</code> bypasses cache and resets to page 1.</li> <li>Cache key: <code>sl:{tenant}:staff:list:{page}</code> per module cache rules (<a href="staff/../00-shared/06_State_Management.html">00-shared/06 §3.3</a>).</li> </ul> <h2 id="4-caching--staleness-1"><a class="header" href="#4-caching--staleness-1">4. Caching & staleness</a></h2> <div class="table-wrapper"><table><thead><tr><th>Data</th><th>TTL (proposed)</th><th>Notes</th></tr></thead><tbody> <tr><td>Staff list pages</td><td>5 min</td><td>volatile (status changes)</td></tr> <tr><td>Department list</td><td>24 h</td><td>reference catalog (<a href="staff/../00-shared/06_State_Management.html">00-shared/06 §3.3</a>)</td></tr> <tr><td>Designation list</td><td>24 h</td><td>reference catalog</td></tr> <tr><td>Staff detail</td><td>no client cache</td><td>refetch per visit; server caches</td></tr> <tr><td>Users (picker)</td><td>24 h</td><td>cross-module reference</td></tr> </tbody></table> </div> <p>Stale-while-revalidate: render cache instantly, refresh in background (<a href="staff/../00-shared/06_State_Management.html">00-shared/06 §3.3</a>).</p> <h2 id="5-optimistic-updates"><a class="header" href="#5-optimistic-updates">5. Optimistic updates</a></h2> <p><strong>None.</strong> All mutations (create/update/deactivate) wait for the server envelope — status changes and deletes have downstream audit events (<code>event-queue-map.ts:34-36</code>) and no rollback path (<a href="staff/../00-shared/06_State_Management.html">00-shared/06 §3.5</a>).</p> <h2 id="6-cross-screen-sync"><a class="header" href="#6-cross-screen-sync">6. Cross-screen sync</a></h2> <ul> <li><code>StaffDetailCubit</code> refreshes after a successful edit; <code>StaffListCubit</code> refreshes after create/delete (or removes the row locally on <code>DELETE</code> success — <code>(proposed)</code>).</li> <li>Catalog changes (department/designation) invalidate the 24 h catalog cache so pickers show fresh options (<a href="staff/../00-shared/06_State_Management.html">00-shared/06 §3.3</a>).</li> <li>No WebSocket topics exist for staff (<a href="staff/../00-shared/07_API_Conventions.html">00-shared/07 §8</a> lists none) — no realtime updates; lists refresh on pull or refetch.</li> </ul> <h2 id="7-error-handling-module-specifics"><a class="header" href="#7-error-handling-module-specifics">7. Error handling (module specifics)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Code</th><th>Behavior</th></tr></thead><tbody> <tr><td>400</td><td>field errors onto <code>fieldErrors</code>; focus first invalid field</td></tr> <tr><td>401</td><td>interceptor refresh once; fail → re-login (<a href="staff/../00-shared/06_State_Management.html">00-shared/06 §3.6</a>)</td></tr> <tr><td>403</td><td>hide actions (no <code>staff.create</code> → no FAB) + 403 screen if routed</td></tr> <tr><td>404</td><td>detail → not-found empty-state; deactivate → snackbar "Already deactivated"</td></tr> <tr><td>409</td><td>inline duplicate message on the field (<a href="staff/../00-shared/06_State_Management.html">00-shared/06 §5</a>)</td></tr> <tr><td>429</td><td>backoff copy; no auto-retry</td></tr> <tr><td>5xx</td><td>generic + requestId (<a href="staff/../00-shared/06_State_Management.html">00-shared/06 §5</a>)</td></tr> </tbody></table> </div> <h2 id="8-testing-hooks"><a class="header" href="#8-testing-hooks">8. Testing hooks</a></h2> <ul> <li>Cubits are pure Dart with mocked repositories (<a href="staff/../00-shared/06_State_Management.html">00-shared/06 §6</a>); each list cubit has widget-test pairs for skeleton/error/empty/success permutations; deactivate cubit tested for 404 and success paths.</li> <li>Repository mappers (envelope → model) unit-tested for all status/type enum values and missing refs.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-staff-module"><a class="header" href="#14--qa-checklist-staff-module">14 — QA Checklist (Staff Module)</a></h1> <ul> <li><a href="staff/14_QA_Checklist.html#1-uniqueness--duplicates">1. Uniqueness & duplicates</a></li> <li><a href="staff/14_QA_Checklist.html#2-department--designation-delete-with-members">2. Department / designation delete with members</a></li> <li><a href="staff/14_QA_Checklist.html#3-deactivate-rules">3. Deactivate rules</a></li> <li><a href="staff/14_QA_Checklist.html#4-status--enum-fidelity">4. Status & enum fidelity</a></li> <li><a href="staff/14_QA_Checklist.html#5-pagination">5. Pagination</a></li> <li><a href="staff/14_QA_Checklist.html#6-envelope--errors">6. Envelope & errors</a></li> <li><a href="staff/14_QA_Checklist.html#7-cross-module-joins">7. Cross-module joins</a></li> <li><a href="staff/14_QA_Checklist.html#8-analytics-proposed">8. Analytics <code>(proposed)</code></a></li> <li><a href="staff/14_QA_Checklist.html#9-accessibility-shared-baseline-applied">9. Accessibility (shared baseline applied)</a></li> <li><a href="staff/14_QA_Checklist.html#10-performance--device-matrix-shared-budgets">10. Performance & device matrix (shared budgets)</a></li> </ul> <blockquote> <p>Module-specific QA items on top of <a href="staff/../00-shared/10_QA_Baseline.html">00-shared/10_QA_Baseline.md</a>. Every item is derived from backend behavior; <code>(proposed)</code> marks client-side choices.</p> </blockquote> <h2 id="1-uniqueness--duplicates"><a class="header" href="#1-uniqueness--duplicates">1. Uniqueness & duplicates</a></h2> <ul> <li><input disabled="" type="checkbox"/> Creating two staff with the same <code>employeeNumber</code> in one tenant → second gets 409 <code>DUPLICATE_RESOURCE</code> with the exact message (<code>staff.service.ts:31-36</code>); inline error shown on the field; first record intact.</li> <li><input disabled="" type="checkbox"/> Same <code>employeeNumber</code> in <strong>different tenants</strong> succeeds (index is <code>{tenantId, employeeNumber}</code> — <code>staff.schema.ts:57</code>); cross-tenant isolation verified (E2E pattern: <code>p1-school.e2e-spec.ts:445-459</code> uses unique stamps).</li> <li><input disabled="" type="checkbox"/> Same <code>userId</code> twice in one tenant → 409 via unique index (<code>staff.schema.ts:58</code>) — a user can have only one staff profile per tenant.</li> <li><input disabled="" type="checkbox"/> Duplicate department <code>name</code> → 409 (<code>department.service.ts:23-25</code>); duplicate designation <code>name</code> → 409 (<code>designation.service.ts:25-28</code>).</li> <li><input disabled="" type="checkbox"/> Re-using a deleted (soft-deleted) <code>employeeNumber</code> → <strong>still 409</strong> (index includes soft-deleted rows — <code>base.repository.ts:20-30</code> filters reads, not the unique index) — document in deactivate confirm copy.</li> </ul> <h2 id="2-department--designation-delete-with-members"><a class="header" href="#2-department--designation-delete-with-members">2. Department / designation delete with members</a></h2> <ul> <li><input disabled="" type="checkbox"/> Deleting a department that has staff referencing it <strong>succeeds</strong> server-side (no guard in <code>department.service.ts:62-65</code>) — members' <code>departmentId</code> now dangles. Confirm dialog must warn (OQ-4).</li> <li><input disabled="" type="checkbox"/> Same for designations (<code>designation.service.ts:65-68</code>) and for department→designation links (<code>designation.schema.ts:9-10</code>).</li> <li><input disabled="" type="checkbox"/> Detail screens render dangling refs as "—" (resolve via <code>GET /departments/:id</code> → 404 → <code>(proposed)</code> null render).</li> <li><input disabled="" type="checkbox"/> Deleting a department that is another entity's <code>headId</code> (staff as head) is not blocked — head ref dangles (<code>department.schema.ts:15-16</code>).</li> </ul> <h2 id="3-deactivate-rules"><a class="header" href="#3-deactivate-rules">3. Deactivate rules</a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>DELETE /staff/:id</code> on an existing record → 200, row gone from list and detail 404 afterwards (<code>base.repository.ts:68-74</code>, <code>staff.service.ts:93-104</code>).</li> <li><input disabled="" type="checkbox"/> Second <code>DELETE</code> on same id → 404 (<code>staff.service.ts:94-95</code>); UI shows "already deactivated" without crash.</li> <li><input disabled="" type="checkbox"/> Soft-deleted staff excluded from list <strong>and</strong> count (<code>base.repository.ts:20-30,53-55</code>) → <code>meta.totalItems</code> shrinks.</li> <li><input disabled="" type="checkbox"/> Deactivate requires <code>staff.delete</code>; without it the action is hidden (<code>permissions.constants.ts:22</code>; client gate — server RBAC pending OQ-1).</li> <li><input disabled="" type="checkbox"/> No restore endpoint exists — QA must not spec an undo in UI (<code>base.repository.ts:68-74</code>; OQ-8).</li> </ul> <h2 id="4-status--enum-fidelity"><a class="header" href="#4-status--enum-fidelity">4. Status & enum fidelity</a></h2> <ul> <li><input disabled="" type="checkbox"/> Status badge shows exactly the 4 enum values (<code>staff.schema.ts:7-12</code>); employment type exactly 4 (<code>staff.schema.ts:14-19</code>).</li> <li><input disabled="" type="checkbox"/> Create never sends <code>status</code> (not in <code>create-staff.dto.ts</code>); server responds <code>active</code> (<code>staff.service.ts:39</code>).</li> <li><input disabled="" type="checkbox"/> Empty <code>employmentType</code> on create → response <code>full_time</code> (<code>staff.service.ts:40-41</code>).</li> <li><input disabled="" type="checkbox"/> <code>status</code> free-string risk: DTO validates only <code>@IsString</code> (<code>update-staff.dto.ts:35-38</code>) — a bogus status is <strong>persisted</strong>; client must restrict dropdown to enum values (OQ-9).</li> <li><input disabled="" type="checkbox"/> <code>level</code> non-integer (e.g. <code>"2.5"</code>) → 400 <code>VALIDATION_ERROR</code> with details (<code>designation.dto.ts:14-17</code>; <code>http-exception.filter.ts:103-107</code>).</li> </ul> <h2 id="5-pagination"><a class="header" href="#5-pagination">5. Pagination</a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>page</code> < 1 → 400; <code>limit</code> > 100 → 400 (<code>pagination-query.dto.ts:6-19</code>).</li> <li><input disabled="" type="checkbox"/> Empty <code>totalItems</code> → <code>meta</code> = <code>{page:1, limit:20, totalItems:0, totalPages:1, hasNext:false, hasPrevious:false}</code> (<code>pagination-query.dto.ts:46-54</code>).</li> <li><input disabled="" type="checkbox"/> Page controls/infinite scroll respect <code>meta.hasNext/hasPrevious</code>; no duplicate requests for the same page (perf budget <a href="staff/../00-shared/10_QA_Baseline.html">00-shared/10 §1</a>).</li> <li><input disabled="" type="checkbox"/> <code>q</code> and <code>sort</code> sent but ignored — QA labels filters "filtering loaded results" (OQ-2); no crash on any <code>sort</code> value.</li> <li><input disabled="" type="checkbox"/> Cross-check: staff list query params match <code>p1-school.e2e-spec.ts:461-470</code>.</li> </ul> <h2 id="6-envelope--errors-1"><a class="header" href="#6-envelope--errors-1">6. Envelope & errors</a></h2> <ul> <li><input disabled="" type="checkbox"/> Every success payload: <code>success:true, message:"OK", data, meta?</code>, <code>timestamp</code>, <code>requestId</code> (<code>response-envelope.interceptor.ts:47-60</code>).</li> <li><input disabled="" type="checkbox"/> 404 on staff/department/designation returns <code>RESOURCE_NOT_FOUND</code> (<code>http-exception.filter.ts:31</code>), never exposes existence of cross-tenant ids.</li> <li><input disabled="" type="checkbox"/> Invalid ObjectId in <code>:id</code> → 400 (<code>http-exception.filter.ts:47-48,91-92</code>).</li> <li><input disabled="" type="checkbox"/> 401 without Bearer token (<code>jwt-auth.guard.ts:36-38</code>) → client refresh flow.</li> </ul> <h2 id="7-cross-module-joins"><a class="header" href="#7-cross-module-joins">7. Cross-module joins</a></h2> <ul> <li><input disabled="" type="checkbox"/> S2 resolves department/designation names; a soft-deleted catalog entry renders "—" (404 on resolve).</li> <li><input disabled="" type="checkbox"/> S3 user picker requires a <code>userId</code> (<code>create-staff.dto.ts:5-7</code>) — form cannot submit without one.</li> <li><input disabled="" type="checkbox"/> Teachers module shares the catalogs (<code>teacher.schema.ts:22-26</code>) — renaming a department here updates teacher displays on next fetch (no server denormalization).</li> </ul> <h2 id="8-analytics-proposed-1"><a class="header" href="#8-analytics-proposed-1">8. Analytics <code>(proposed)</code></a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>staff.list.view/search/filter</code>, <code>staff.create.submit|success|duplicate</code>, <code>staff.update.submit|success</code>, <code>staff.deactivate.confirm|success</code>, <code>department.create.success|duplicate</code>, <code>designation.create.success|duplicate</code> fire once per action (<a href="staff/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a> naming).</li> </ul> <h2 id="9-accessibility-shared-baseline-applied"><a class="header" href="#9-accessibility-shared-baseline-applied">9. Accessibility (shared baseline applied)</a></h2> <ul> <li><input disabled="" type="checkbox"/> Status never color-only (icon + label) (<a href="staff/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §9</a>).</li> <li><input disabled="" type="checkbox"/> TalkBack/VoiceOver walkthrough: create → deactivate journey without sight (<a href="staff/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §12</a>).</li> <li><input disabled="" type="checkbox"/> Keyboard-only: chips, menus, dialogs operable; focus ring visible; ESC closes (<a href="staff/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §6</a>).</li> <li><input disabled="" type="checkbox"/> Text scale 2×: employee-number rows and form grids reflow, no clipping (<a href="staff/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §4</a>).</li> <li><input disabled="" type="checkbox"/> Dialog focus trap; focus returns to the deactivate trigger on cancel (<a href="staff/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §6</a>).</li> </ul> <h2 id="10-performance--device-matrix-shared-budgets"><a class="header" href="#10-performance--device-matrix-shared-budgets">10. Performance & device matrix (shared budgets)</a></h2> <ul> <li><input disabled="" type="checkbox"/> Staff list first frame ≤ 300 ms cached / ≤ 2 s network (<a href="staff/../00-shared/10_QA_Baseline.html">00-shared/10 §1</a>).</li> <li><input disabled="" type="checkbox"/> 0 dropped frames scrolling 500-row roster (profiled); <code>ListView.builder</code> only.</li> <li><input disabled="" type="checkbox"/> Verified on device matrix P0 (<a href="staff/../00-shared/10_QA_Baseline.html">00-shared/10 §4</a>): phone/tablet/desktop, light+dark, goldens at 3 sizes.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-staff-module"><a class="header" href="#15--flutter-implementation-guide-staff-module">15 — Flutter Implementation Guide (Staff Module)</a></h1> <ul> <li><a href="staff/15_Flutter_Implementation_Guide.html#1-folder-structure">1. Folder structure</a></li> <li><a href="staff/15_Flutter_Implementation_Guide.html#2-models">2. Models</a></li> <li><a href="staff/15_Flutter_Implementation_Guide.html#3-repositories">3. Repositories</a></li> <li><a href="staff/15_Flutter_Implementation_Guide.html#4-routing-go_router">4. Routing (go_router)</a></li> <li><a href="staff/15_Flutter_Implementation_Guide.html#5-key-widgets">5. Key widgets</a></li> <li><a href="staff/15_Flutter_Implementation_Guide.html#6-state-wiring">6. State wiring</a></li> <li><a href="staff/15_Flutter_Implementation_Guide.html#7-testing">7. Testing</a></li> <li><a href="staff/15_Flutter_Implementation_Guide.html#8-performance">8. Performance</a></li> <li><a href="staff/15_Flutter_Implementation_Guide.html#9-i18n">9. i18n</a></li> </ul> <blockquote> <p>Extends <a href="staff/../00-shared/11_Flutter_App_Architecture.html">00-shared/11_Flutter_App_Architecture.md</a> with the Staff module structure. Forward-looking spec (no client repo exists yet); all routes/guards mirror the backend surface.</p> </blockquote> <h2 id="1-folder-structure-11"><a class="header" href="#1-folder-structure-11">1. Folder structure</a></h2> <pre><code>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 </code></pre> <h2 id="2-models"><a class="header" href="#2-models">2. Models</a></h2> <pre><code class="language-dart">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; } </code></pre> <ul> <li>Parse unknown enum strings defensively (server accepts any string for <code>employmentType</code>/<code>status</code> — no <code>@IsEnum</code>, <code>create-staff.dto.ts:23-28</code>, <code>update-staff.dto.ts:35-38</code>): fall back to a <code>StaffStatus.unknown</code>/raw display instead of throwing (OQ-9).</li> <li><code>DateTime.tryParse</code> for <code>joiningDate</code> (<code>staff.service.ts:42</code> stores <code>Date</code>).</li> <li>JSON mapping via <code>json_serializable</code> (codegen preferred per <code>00-shared/11 §4</code>).</li> </ul> <h2 id="3-repositories-2"><a class="header" href="#3-repositories-2">3. Repositories</a></h2> <ul> <li><code>StaffRepository</code>: <ul> <li><code>list({page, limit})</code> → <code>Paginated<Staff></code> (<code>GET /staff</code>, E2) — do <strong>not</strong> send <code>sort</code>/<code>q</code> semantics (server ignores them; <code>staff.service.ts:64-76</code>).</li> <li><code>get(id)</code> → <code>Staff</code> (E3); <code>create(dto)</code> (E1); <code>update(id, Map<String,dynamic> delta)</code> (E4 — send only changed keys, <code>staff.service.ts:88</code>); <code>deactivate(id)</code> (E5).</li> </ul> </li> <li><code>DepartmentRepository</code>: <code>list</code>, <code>get</code>, <code>create</code>, <code>update</code> (E6–E9), <code>deactivate</code> (E10).</li> <li><code>DesignationRepository</code>: <code>list</code>, <code>get</code>, <code>create</code>, <code>update</code> (E11–E14), <code>deactivate</code> (E15).</li> <li>All mutations non-optimistic; throw typed <code>ApiException(code, status, details)</code> (<code>00-shared/06 §2,3.5</code>).</li> <li>Cross-module joins live in <strong>client-side composition</strong> (UsersRepository for names, catalogs for refs) — never block list rendering on join fetches; render placeholders and fill in.</li> </ul> <h2 id="4-routing-go_router"><a class="header" href="#4-routing-go_router">4. Routing (go_router)</a></h2> <pre><code class="language-dart">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 </code></pre> <ul> <li>Guards: <code>permissionGuard('staff.read' | 'staff.create' | 'staff.update' | 'staff.delete' | 'department.manage' | 'designation.manage')</code> — client mirror of <code>permissions.constants.ts:19-24</code>; server RBAC pending (OQ-1) so guards are mandatory, not decorative.</li> <li>Routes registered inside the Staff shell branch; master-detail via <code>StatefulShellRoute</code> at ≥ 840 dp (<code>00-shared/05 §3</code>).</li> <li>Deep links: <code>studylyon://staff/:id</code>, <code>/departments/:id</code>, <code>/designations/:id</code> (<code>(proposed)</code>).</li> </ul> <h2 id="5-key-widgets"><a class="header" href="#5-key-widgets">5. Key widgets</a></h2> <div class="table-wrapper"><table><thead><tr><th>Widget</th><th>Impl notes</th></tr></thead><tbody> <tr><td><code>StaffStatusBadge</code></td><td><code>AppBadge</code> wrapper; 4-value map (<code>staff.schema.ts:7-12</code>); <code>Semantics('Status: …')</code></td></tr> <tr><td><code>EmployeeNumberText</code></td><td><code>SelectableText</code>, mono font, <code>tabularFigures</code> (<code>00-shared/02 §2</code>)</td></tr> <tr><td><code>RefChip</code></td><td>resolves ref via repository; null/404 → "—"</td></tr> <tr><td><code>CatalogPickerSheet</code></td><td><code>showModalBottomSheet</code> + <code>SearchBar</code> + <code>ListView.builder</code>; paginated catalogs; create affordance in empty state</td></tr> <tr><td><code>MetadataEditor</code></td><td>key/value rows → <code>Map<String, dynamic></code></td></tr> <tr><td><code>DeactivateDialog</code></td><td><code>showDialog</code> + <code>AlertDialog</code>; destructive action; <code>Navigator.pop(result)</code> into <code>DeactivateCubit.confirm</code></td></tr> </tbody></table> </div> <h2 id="6-state-wiring"><a class="header" href="#6-state-wiring">6. State wiring</a></h2> <ul> <li><code>StaffListPage</code> → <code>BlocProvider(StaffListCubit)</code>: <code>Load</code> on init; <code>LoadMore</code> on scroll end; <code>RefreshIndicator.onRefresh</code> → <code>Refresh</code>; chip callbacks → <code>ChangeStatusFilter/ChangeTypeFilter</code> (client-side over loaded pages, OQ-2).</li> <li><code>StaffDetailPage</code> → <code>StaffDetailCubit</code> (parallel fetches: staff + dept + desig + user via <code>Future.wait</code>; each failure degrades independently).</li> <li><code>StaffFormPage(mode)</code> → <code>StaffFormCubit</code>: create submits <code>CreateStaffDto</code>-shaped map; edit submits only changed keys; <code>ApiException.code == 'DUPLICATE_RESOURCE'</code> → field error on <code>employeeNumber</code>.</li> <li>Deactivate: dialog-owned <code>DeactivateCubit</code>; on success pop dialog + refresh list.</li> </ul> <h2 id="7-testing"><a class="header" href="#7-testing">7. Testing</a></h2> <ul> <li><strong>Unit:</strong> model enum parsing (including bogus server strings); repository envelope mapping (paginated + error); cubits with mocked repos — pagination edge (<code>hasNext</code> false), 409 field mapping, 404 on detail.</li> <li><strong>Widget:</strong> 3-state tests per page (<code>00-shared/10 §9</code>); golden tests for <code>StaffStatusBadge</code> (4 states), pickers, forms.</li> <li><strong>Integration:</strong> journey test "create staff → 409 on duplicate → success → detail → deactivate → gone from list" using <code>integration_test</code> + mocked or test-tenant API (<code>p1-school.e2e-spec.ts:437-478</code> mirrors the same journey).</li> <li><strong>A11y:</strong> TalkBack/VoiceOver walkthrough for create + deactivate (<code>00-shared/09 §12</code>).</li> </ul> <h2 id="8-performance-1"><a class="header" href="#8-performance-1">8. Performance</a></h2> <ul> <li><code>ListView.builder</code> everywhere; <code>RepaintBoundary</code> per avatar row in lists; <code>const</code> constructors; catalog pickers cache 24 h (<code>00-shared/06 §3.3</code>).</li> <li>Defer user-name joins to a background fill after first frame (<code>00-shared/11 §13</code>).</li> </ul> <h2 id="9-i18n"><a class="header" href="#9-i18n">9. i18n</a></h2> <p>Keys under <code>staff.*</code>: <code>staff.list.title</code>, <code>staff.form.employeeNumber</code> (+ helper "Created as active", <code>staff.service.ts:39</code>), <code>staff.status.active|inactive| onLeave|terminated</code>, <code>staff.deactivate.title|body|confirm</code>, <code>department.form.name</code>, <code>designation.form.level</code>, etc. Server 409 message shown via business-4xx fallback (<code>00-shared/11 §9</code>).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-students-module"><a class="header" href="#01--product-overview-students-module">01 — Product Overview (Students Module)</a></h1> <ul> <li><a href="students/01_Product_Overview.html#1-purpose">1. Purpose</a></li> <li><a href="students/01_Product_Overview.html#2-the-student-lifecycle-backend-state-machine">2. The student lifecycle (backend state machine)</a></li> <li><a href="students/01_Product_Overview.html#3-business-goals">3. Business goals</a></li> <li><a href="students/01_Product_Overview.html#4-user-goals">4. User goals</a></li> <li><a href="students/01_Product_Overview.html#5-stakeholders">5. Stakeholders</a></li> <li><a href="students/01_Product_Overview.html#6-dependencies">6. Dependencies</a></li> <li><a href="students/01_Product_Overview.html#7-success-metrics">7. Success metrics</a></li> <li><a href="students/01_Product_Overview.html#8-edge-cases-server-truth">8. Edge cases (server truth)</a></li> <li><a href="students/01_Product_Overview.html#9-module-notes">9. Module notes</a></li> <li><a href="students/01_Product_Overview.html#10-forward-looking--prd-notes">10. Forward-looking & PRD notes</a></li> <li><a href="students/01_Product_Overview.html#11-assumptions-module">11. Assumptions (module)</a></li> <li><a href="students/01_Product_Overview.html#12-open-questions-module-level-global-ledger-in-00-shared12">12. Open questions (module-level; global ledger in <code>00-shared/12</code>)</a></li> <li><a href="students/01_Product_Overview.html#13-glossary-this-module">13. Glossary (this module)</a></li> </ul> <blockquote> <p>StudyLyon — multi-tenant ERP / School Management API. This package designs the <strong>Students module client</strong> (Flutter, forward-looking spec) against the implemented NestJS backend. Every endpoint, DTO field, schema, enum, event, and wire contract is derived from <code>src/modules/students/**</code>, <code>src/modules/parents/**</code>, <code>src/modules/academics/**</code>, <code>src/modules/bulk/**</code>, <code>src/modules/users/**</code>, <code>src/modules/rbac/permissions.constants.ts</code>, <code>src/infrastructure/**</code>, and <code>studylyon-blueprint/04-Modules/Students.md</code>. Nothing is invented; gaps are flagged <code>(planned)</code>, <code>(forward-looking)</code>, <code>(proposed)</code> or listed in <strong>Assumptions & Open Questions</strong>.</p> </blockquote> <hr /> <h2 id="1-purpose-24"><a class="header" href="#1-purpose-24">1. Purpose</a></h2> <p>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.</p> <div class="table-wrapper"><table><thead><tr><th>Responsibility</th><th>Source</th></tr></thead><tbody> <tr><td>Profile creation with automatic first enrollment</td><td><code>student.service.ts:55-93</code></td></tr> <tr><td>Enrollment into a class for an academic year (history kept, never overwritten)</td><td><code>student.service.ts:120-140</code>, <code>class-enrollment.schema.ts:7-44</code></td></tr> <tr><td>Transfer between classes (guard: only <code>active</code>)</td><td><code>student.service.ts:170-203</code></td></tr> <tr><td>Graduate / archive / restore lifecycle</td><td><code>student.service.ts:205-248</code></td></tr> <tr><td>Document upload + listing per student</td><td><code>student.service.ts:250-279</code>, <code>student-document.schema.ts</code></td></tr> <tr><td>Academic history (all enrollments, newest first)</td><td><code>student.service.ts:281-287</code></td></tr> <tr><td>Bulk CSV import / export with per-row report</td><td><code>bulk/bulk-import.service.ts:22-71</code>, <code>bulk/adapters/students-import.adapter.ts</code></td></tr> <tr><td>Parent/guardian linking (many-to-many, relationship typed)</td><td><code>parents/services/student-parent-link.service.ts</code>, <code>parents/schemas/student-parent-link.schema.ts</code></td></tr> <tr><td>Domain events → BullMQ (in-app + audit)</td><td><code>infrastructure/bullmq/event-queue-map.ts:28-39</code></td></tr> <tr><td>Soft-delete + optimistic-version scoping (tenant-safe)</td><td><code>database/base.repository.ts:20-74</code></td></tr> </tbody></table> </div> <h2 id="2-the-student-lifecycle-backend-state-machine"><a class="header" href="#2-the-student-lifecycle-backend-state-machine">2. The student lifecycle (backend state machine)</a></h2> <p>Status values are fixed by <code>StudentStatus</code> (<code>student.schema.ts:7-13</code>): <code>active | inactive | graduated | transferred | archived</code>.</p> <pre><code> ┌────────────────────────────────────────────┐ │ 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) </code></pre> <ul> <li><strong>Create</strong> forces <code>status: ACTIVE</code> and <code>admissionDate = today</code> when omitted (<code>student.service.ts:65-69</code>), then immediately creates an ACTIVE <code>class_enrollments</code> row for the given <code>classId</code>/<code>academicYearId</code> (<code>student.service.ts:71-78</code>). Admission number is unique per tenant — duplicate → 409 (<code>student.service.ts:56-62</code>, <code>student.schema.ts:68</code>).</li> <li><strong>Enroll</strong> deactivates <strong>all</strong> currently-active enrollments (<code>status: transferred</code>, <code>leftAt: now</code>) and creates a fresh ACTIVE one (<code>student.service.ts:125-140</code>). History is append-only — previous years are never overwritten (blueprint <code>03-Database/COLLECTIONS.md:1674-1677</code>).</li> <li><strong>Transfer</strong> is <code>enroll</code> + profile sync: requires current status <code>active</code> (else 409), then updates <code>classId</code>, <code>academicYearId</code>, optional <code>gradeId</code>/<code>sectionId</code> on the student doc (<code>student.service.ts:170-203</code>).</li> <li><strong>Graduate</strong> sets status <code>graduated</code>; idempotency guard → 409 when already graduated (<code>student.service.ts:205-223</code>). Note: graduation does <strong>not</strong> close the active enrollment — flagged in OQ-5.</li> <li><strong>Archive / restore</strong> toggles <code>archived</code> ↔ <code>active</code> with a same-state 409 guard (<code>student.service.ts:225-248</code>).</li> <li><code>inactive</code> / <code>transferred</code> statuses exist in the enum but <strong>no service method writes them</strong> — only legacy data or future use (OQ-6).</li> <li><strong>DELETE</strong> soft-deletes the student record only — documents, enrollments and parent links are <strong>not cascaded</strong> (OQ-7, <code>student.service.ts:157-168</code>).</li> </ul> <h2 id="3-business-goals-1"><a class="header" href="#3-business-goals-1">3. Business goals</a></h2> <div class="table-wrapper"><table><thead><tr><th>Goal</th><th>Measure</th></tr></thead><tbody> <tr><td>Zero duplicate admission numbers per tenant</td><td>unique compound index <code>{tenantId, admissionNumber}</code> (<code>student.schema.ts:68</code>) + pre-check 409</td></tr> <tr><td>Enrollment history is a never-overwritten audit trail</td><td>deactivate-then-create pattern (<code>student.service.ts:125-140</code>)</td></tr> <tr><td>Every status change is observable</td><td><code>StudentUpdated</code> → <code>audit-write</code> queue (<code>event-queue-map.ts:29</code>)</td></tr> <tr><td>Safe transfers</td><td>only <code>active</code> students transfer; 409 otherwise (<code>student.service.ts:175-179</code>)</td></tr> <tr><td>Tenant isolation structural</td><td><code>BaseRepository.scopedFilter</code> injects <code>tenantId</code> + <code>isDeleted:false</code> on every query (<code>base.repository.ts:20-30</code>)</td></tr> <tr><td>Admissions in bulk</td><td>CSV import with per-row error report (1000-row class in <code>14_QA_Checklist.md</code>)</td></tr> </tbody></table> </div> <h2 id="4-user-goals-1"><a class="header" href="#4-user-goals-1">4. User goals</a></h2> <ul> <li><strong>Admission staff:</strong> create a student (identity via an existing user), set the class, upload documents, link parents — all from the student profile.</li> <li><strong>Admin:</strong> keep the roster accurate (transfer, graduate, archive), import the September batch from a spreadsheet, spot duplicates.</li> <li><strong>Teacher:</strong> find a student quickly (search/filter), view profile, documents, academic history.</li> <li><strong>Parent:</strong> see linked children via <code>GET /parents/:id/students</code> (<code>parent.controller.ts:38-40</code>) — read-only; profile changes are staff-side.</li> <li><strong>Student (self):</strong> read own profile, academic history, documents <code>(planned)</code> — the backend has no <code>student-self</code> endpoints today (OQ-1).</li> </ul> <h2 id="5-stakeholders"><a class="header" href="#5-stakeholders">5. Stakeholders</a></h2> <p>School admins, admission/reception staff, teachers, parents/guardians, students, accounts (enrollment status feeds fee invoicing), transport/hostel coordinators (<code>transportRequired</code>/<code>hostelRequired</code> flags), platform operator (tenant data).</p> <h2 id="6-dependencies-11"><a class="header" href="#6-dependencies-11">6. Dependencies</a></h2> <div class="table-wrapper"><table><thead><tr><th>Dependency</th><th>Role</th><th>Source</th></tr></thead><tbody> <tr><td>Users module</td><td>identity (name/email/phone/avatar) — <code>students</code> stores academic data only</td><td><code>user.schema.ts:15-79</code>; blueprint <code>COLLECTIONS.md:1283-1291</code></td></tr> <tr><td>Academics module</td><td><code>academicYearId</code>, <code>gradeId</code>, <code>sectionId</code>, <code>classId</code> refs + dropdown source endpoints</td><td><code>academics/controllers/*.ts</code>, schemas in <code>academics/schemas/</code></td></tr> <tr><td>Parents module</td><td>guardian links (<code>student_parent_links</code> M2M)</td><td><code>student-parent-link.schema.ts</code></td></tr> <tr><td>Bulk module</td><td>CSV import/export (<code>students</code> adapter)</td><td><code>bulk/bulk-import.service.ts</code>, <code>students-import.adapter.ts</code></td></tr> <tr><td>Storage provider</td><td>document + avatar binaries</td><td><code>shared/storage/storage-provider.ts</code>, <code>storage/local-storage.provider.ts:23-51</code></td></tr> <tr><td>EventBus → BullMQ</td><td><code>StudentCreated</code>/<code>StudentUpdated</code>/<code>StudentDeleted</code> → in-app + audit jobs</td><td><code>event-queue-map.ts:28-30</code></td></tr> <tr><td>RBAC constants</td><td><code>student.read/.create/.update/.delete</code> (note: not yet enforced — OQ-4)</td><td><code>permissions.constants.ts:25-28</code></td></tr> <tr><td>Collections</td><td><code>students</code>, <code>class_enrollments</code>, <code>student_documents</code>, <code>student_parent_links</code>, <code>parents</code></td><td><code>blueprint 03-Database/COLLECTIONS.md</code></td></tr> </tbody></table> </div> <h2 id="7-success-metrics-15"><a class="header" href="#7-success-metrics-15">7. Success metrics</a></h2> <ul> <li>Enrollment round-trip (create + auto-enroll) acknowledged in < 2 s p95.</li> <li>Zero duplicates: 409 on admission number must be prevented client-side by inline pre-check + server 409 fallback.</li> <li>Transfer flow error rate < 1% (guard 409s are user-preventable).</li> <li>CSV import of 1000 rows completes with a per-row report; 0 silent skips.</li> <li>Document uploads land with correct <code>fileId</code> mapping; previews resolvable.</li> <li>Parent links never orphaned (student existence checked at link time — <code>student-parent-link.service.ts:27</code>).</li> </ul> <h2 id="8-edge-cases-server-truth"><a class="header" href="#8-edge-cases-server-truth">8. Edge cases (server truth)</a></h2> <ul> <li>Duplicate <code>admissionNumber</code> → 409 <code>ConflictException</code> (<code>student.service.ts:56-62</code>).</li> <li><code>findById</code>/<code>enroll</code>/<code>transfer</code>/<code>update</code>/<code>remove</code>/<code>graduate</code>/<code>archive</code>/<code>restore</code>/ <code>uploadDocument</code> on unknown id → 404 "Student not found." (<code>student.service.ts:95-99,124,143-145,159,174,206,226,255</code>).</li> <li>Transfer/graduate/archive on wrong status → 409 with the current status in the message (<code>student.service.ts:175-179,207-209,228-230</code>).</li> <li>Enroll while another enrollment is active → previous becomes <code>transferred</code> (no error; expected workflow) (<code>student.service.ts:125-131</code>).</li> <li>Pagination: <code>sort</code> and <code>q</code> query params are <strong>accepted but ignored</strong> by <code>StudentService.find</code> (<code>student.service.ts:104-108</code>) — list search/sort is <code>(planned)</code> server-side (OQ-8).</li> <li>CSV malformed/empty → 400; unknown entity → 404; per-row failures reported, never abort the batch (<code>bulk-import.service.ts:31-63</code>).</li> <li>Duplicate email inside one CSV → <code>UsersService.create</code> fails → adapter falls back to the existing user (re-link) (<code>students-import.adapter.ts:66-75</code>).</li> </ul> <h2 id="9-module-notes"><a class="header" href="#9-module-notes">9. Module notes</a></h2> <ul> <li><code>POST /students</code> requires an existing <code>userId</code> (<code>create-student.dto.ts:6-7</code>) — the UI must create the user (or reuse an existing one) <strong>before</strong> creating the student. There is no "create user + student" composite endpoint.</li> <li>Document upload has <strong>no server-side size/mime validation</strong> — only the multipart field must exist; limits are client + proxy level today (OQ-9, <code>student.service.ts:250-271</code>).</li> <li><code>StudentCreated</code> payload carries <code>studentId</code>, <code>admissionNumber</code>, <code>classId</code> (<code>student.service.ts:85-90</code>); it routes to the <code>in-app</code> queue only — no email (email worker handles only <code>UserRegistered</code>/<code>PasswordResetRequested</code>, <code>email.worker.ts:26-42</code>). PLAN 4.1's "StudentCreated → ParentCreated → email" chain is therefore <code>(planned)</code> (OQ-3).</li> <li>RBAC: <code>student.*</code> permissions exist as constants (<code>permissions.constants.ts:25-28</code>) but the students controller applies <code>JwtAuthGuard</code> <strong>only</strong> (<code>student.controller.ts:32-35</code>); <code>RbacGuard</code> is not wired on these routes — permission enforcement is <code>(planned)</code> (OQ-4). The client should still gate UI by <code>student.*</code> per <code>00-shared/05 §9</code>.</li> <li>No <code>parent.*</code> permissions exist in <code>permissions.constants.ts</code> at all — the parents surface has no permission vocabulary yet (OQ-10).</li> </ul> <h2 id="10-forward-looking--prd-notes"><a class="header" href="#10-forward-looking--prd-notes">10. Forward-looking & PRD notes</a></h2> <ul> <li><strong>PRD:</strong> native mobile apps are Phase 3 (read-only companion) — this package is the forward-looking full client spec (shared ledger <code>00-shared/12 A1</code>).</li> <li><code>(planned)</code> (server): <code>POST /students/bulk-import</code> under <code>/students</code> (<code>docs/IMPLEMENTATION_PLAN.md:195</code> — today import lives at <code>/bulk/import/students</code>); <code>promote</code> endpoint + <code>StudentPromoted</code> event (blueprint <code>04-Modules/Students.md:32,43</code>); student self-service (results/attendance read for the linked <code>userId</code>).</li> <li><code>(forward-looking)</code>: profile photo upload at <code>POST /users/:id/avatar</code> (<code>users.controller.ts:95-102</code>) exists server-side; QR admission cards, push of <code>StudentCreated</code> to parents, WS live roster updates.</li> <li><code>(proposed)</code>: analytics events (<code>students.list.search</code>, <code>students.import.done</code>…) per <code>00-shared/10 §8</code>.</li> </ul> <h2 id="11-assumptions-module"><a class="header" href="#11-assumptions-module">11. Assumptions (module)</a></h2> <ul> <li>The client treats <code>active</code> as the default roster filter; <code>archived</code> students are hidden from lists unless "include archived" is toggled (server <code>find()</code> returns everything not soft-deleted — filtering is client-side today, OQ-8).</li> <li>Class dropdown data comes from <code>GET /classes</code> (+ <code>by-year/:academicYearId</code>), grades from <code>GET /grades</code>, sections from <code>GET /sections/by-grade/:gradeId</code>, academic years from <code>GET /academic-years</code> (<code>academics/controllers/*.ts</code>).</li> <li>Identity edits (name/email/phone/avatar) are <strong>Users module</strong> screens; the Students UI shows them read-only from the linked user.</li> <li>Multipart uploads use field name <code>file</code> exactly (<code>student.controller.ts:83</code>, <code>bulk.controller.ts:38</code>, <code>users.controller.ts:96</code>).</li> </ul> <h2 id="12-open-questions-module-level-global-ledger-in-00-shared12"><a class="header" href="#12-open-questions-module-level-global-ledger-in-00-shared12">12. Open questions (module-level; global ledger in <code>00-shared/12</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Item</th><th>Impact</th></tr></thead><tbody> <tr><td>OQ-1</td><td>No self-service endpoints for students/parents (no "my profile", no <code>GET /students/me</code>). Parent view exists only via <code>GET /parents/:id/students</code>. When is student self-view added?</td><td>Student/Parent persona screens (03, 06)</td></tr> <tr><td>OQ-2</td><td><code>StudentService.find</code> accepts <code>sort</code>/<code>q</code> but ignores them — server-side search/sort/status filter planned?</td><td>List screen filter behaviour (05, 12, 13)</td></tr> <tr><td>OQ-3</td><td>PLAN 4.1 chain "StudentCreated → ParentCreated → email" — no auto-parent-creation or student/parent email in code (<code>email.worker.ts:26-42</code>). Intended?</td><td>Journey "link parent" UX</td></tr> <tr><td>OQ-4</td><td><code>student.*</code> perms defined but <code>RbacGuard</code> not applied on students/parents/bulk controllers — enforcement when?</td><td>Permission gating in UI (04, 05)</td></tr> <tr><td>OQ-5</td><td><code>graduate</code> sets status but leaves the ACTIVE enrollment open — close it (<code>leftAt</code>) as part of graduation?</td><td>Academic-history rendering</td></tr> <tr><td>OQ-6</td><td><code>inactive</code>/<code>transferred</code> student statuses unwritable by any service method — legacy or future workflow?</td><td>Status chip legend</td></tr> <tr><td>OQ-7</td><td><code>DELETE /students/:id</code> soft-deletes the student only — documents/links/enrollments stay. Cascade or keep history?</td><td>Deletion UX copy</td></tr> <tr><td>OQ-8</td><td>List has no status filter param — archiving hides nothing from <code>GET /students</code>. Client-side filtering or new query param?</td><td>Roster filtering</td></tr> <tr><td>OQ-9</td><td>Document upload: no server size/type limits; <code>student_documents</code> has no downloadable-file endpoint (only <code>fileId</code>) — file retrieval via <code>/api/v1/files/...</code> local path <code>(planned)</code></td><td>Documents tab + previews</td></tr> <tr><td>OQ-10</td><td>No <code>parent.*</code> permission constants — how are parent CRUD routes authorized beyond JWT?</td><td>Parent link UI gating</td></tr> </tbody></table> </div> <h2 id="13-glossary-this-module"><a class="header" href="#13-glossary-this-module">13. Glossary (this module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Term</th><th>Meaning</th></tr></thead><tbody> <tr><td>Student</td><td><code>students</code> doc — academic profile only; identity in linked <code>User</code></td></tr> <tr><td>Admission number</td><td>tenant-unique identifier (<code>ADM…</code>), <code>student.schema.ts:20-21,68</code></td></tr> <tr><td>Enrollment</td><td><code>class_enrollments</code> row: student ↔ class ↔ academic year, with <code>joinedAt</code>/<code>leftAt</code>/<code>status</code></td></tr> <tr><td>Class</td><td><code>classes</code> doc = grade + section + academic year (+capacity) — <code>class.schema.ts</code></td></tr> <tr><td>Transfer</td><td>enroll-into-new-class with old enrollments set <code>transferred</code></td></tr> <tr><td>Graduate/Archive</td><td>status transitions <code>active → graduated/archived</code> (mutually exclusive guards)</td></tr> <tr><td>Parent link</td><td><code>student_parent_links</code> M2M row with <code>relationship</code>, guardian/priority flags</td></tr> <tr><td>Import report</td><td><code>{entity,totalRows,imported,failed,errors:[{rowNumber,errors}]}</code> (<code>import-adapter.interface.ts:14-25</code>)</td></tr> <tr><td>Envelope</td><td><code>{success,message,data,meta?,timestamp,requestId}</code> (<code>00-shared/07 §2-3</code>)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-students-module"><a class="header" href="#02--user-personas-students-module">02 — User Personas (Students Module)</a></h1> <ul> <li><a href="students/02_User_Personas.html#1-organization-admin--ravi-erp-owner">1. Organization Admin — "Ravi, ERP owner"</a></li> <li><a href="students/02_User_Personas.html#2-admission-staff--receptionist--priya-front-office">2. Admission Staff / Receptionist — "Priya, front office"</a></li> <li><a href="students/02_User_Personas.html#3-teacher--class-teacher--ananya-grade-5-a-teacher">3. Teacher / Class Teacher — "Ananya, Grade 5-A teacher"</a></li> <li><a href="students/02_User_Personas.html#4-parent--guardian--farid-father-of-two">4. Parent / Guardian — "Farid, father of two"</a></li> <li><a href="students/02_User_Personas.html#5-student-self--meera-grade-6">5. Student Self — "Meera, Grade 6"</a></li> <li><a href="students/02_User_Personas.html#persona--capability-matrix-derived-from-permissions-constants-only">Persona → capability matrix (derived from permissions constants only)</a></li> </ul> <blockquote> <p>Five personas exercise the Students module. Role → permission vocabulary derives from <code>permissions.constants.ts:25-28</code> (<code>student.read/.create/.update/.delete</code>); note enforcement is <code>(planned)</code> (see <a href="students/01_Product_Overview.html">01 §12 OQ-4</a>). Persona facts marked <code>(forward-looking)</code> reflect module docs (attendance/fees/results tabs) that read other modules' APIs.</p> </blockquote> <hr /> <h2 id="1-organization-admin--ravi-erp-owner"><a class="header" href="#1-organization-admin--ravi-erp-owner">1. Organization Admin — "Ravi, ERP owner"</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Profile</strong></td><td>40s, principal/owner of a 1200-student school. Runs the ERP end to end; reviews numbers every evening.</td></tr> <tr><td><strong>Goals</strong></td><td>Roster integrity (no dupes, correct classes), clean academic-year rollover, sees statuses at a glance, imports the new batch in September without IT help.</td></tr> <tr><td><strong>Pain points</strong></td><td>Duplicate admission numbers (409s), stale <code>archived</code> students polluting the list, transfer mistakes (enrolling into wrong year).</td></tr> <tr><td><strong>Behaviours</strong></td><td>Uses desktop/web; bulk import wizard; reviews per-row CSV error report; audits academic history before approving transfers.</td></tr> <tr><td><strong>Permissions</strong></td><td><code>student.read, student.create, student.update, student.delete</code> + <code>user.*</code>, <code>academics</code> read (<code>permissions.constants.ts:25-28,6-11</code>).</td></tr> <tr><td><strong>Key screens</strong></td><td>Students list, bulk import wizard, student detail → academic-history tab, transfer form.</td></tr> </tbody></table> </div> <h2 id="2-admission-staff--receptionist--priya-front-office"><a class="header" href="#2-admission-staff--receptionist--priya-front-office">2. Admission Staff / Receptionist — "Priya, front office"</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Profile</strong></td><td>25s, first point of contact; admits 10–30 students/week during admission season.</td></tr> <tr><td><strong>Goals</strong></td><td>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.</td></tr> <tr><td><strong>Pain points</strong></td><td>Forgetting that the user record must exist before <code>POST /students</code>; typing admission numbers; re-finding a student's documents.</td></tr> <tr><td><strong>Behaviours</strong></td><td>Phone-first; uses create wizard with camera capture for documents (capture → <code>POST /students/:id/documents</code>); checks duplicate admission number inline before submit.</td></tr> <tr><td><strong>Permissions</strong></td><td><code>student.create, student.read</code> (+ <code>user.create</code> for the identity step).</td></tr> <tr><td><strong>Key screens</strong></td><td>Create wizard (steps: identity → academics → extras → confirm), document upload, parent link sheet.</td></tr> </tbody></table> </div> <h2 id="3-teacher--class-teacher--ananya-grade-5-a-teacher"><a class="header" href="#3-teacher--class-teacher--ananya-grade-5-a-teacher">3. Teacher / Class Teacher — "Ananya, Grade 5-A teacher"</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Profile</strong></td><td>32s, class teacher of 5-A; also marks attendance and homework.</td></tr> <tr><td><strong>Goals</strong></td><td>Find any child by name/admission number quickly; see profile photo + parent pickup flags; view academic history before a parent meeting.</td></tr> <tr><td><strong>Pain points</strong></td><td>Unfilterable rosters (<code>GET /students</code> returns everything — client-side filtering today, OQ-2), statuses not obvious in the list.</td></tr> <tr><td><strong>Behaviours</strong></td><td>Uses search + filter chips; opens student detail; rarely mutates (no create/transfer).</td></tr> <tr><td><strong>Permissions</strong></td><td><code>student.read</code> (+ <code>attendance.mark</code> etc. per other modules).</td></tr> <tr><td><strong>Key screens</strong></td><td>Students list (search), student detail → profile + documents tabs.</td></tr> </tbody></table> </div> <h2 id="4-parent--guardian--farid-father-of-two"><a class="header" href="#4-parent--guardian--farid-father-of-two">4. Parent / Guardian — "Farid, father of two"</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Profile</strong></td><td>40s, two children at the school; busy; uses the app on mobile.</td></tr> <tr><td><strong>Goals</strong></td><td>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.</td></tr> <tr><td><strong>Pain points</strong></td><td>None today server-side — parent has no self-service read of the student profile (<code>OQ-1</code>); current access is only <code>GET /parents/:id/students</code> which returns <strong>link rows</strong>, not student details (<code>parent.service.ts:69-72</code>).</td></tr> <tr><td><strong>Behaviours</strong></td><td>Reads only; receives in-app notifications (<code>ParentCreated</code> → <code>in-app</code>, <code>event-queue-map.ts:37</code>); contacts office for any change.</td></tr> <tr><td><strong>Permissions</strong></td><td>None defined for parents (<code>OQ-10</code>); UI shows read-only surfaces.</td></tr> <tr><td><strong>Key screens</strong></td><td><code>(forward-looking)</code> Children list → child overview.</td></tr> </tbody></table> </div> <h2 id="5-student-self--meera-grade-6"><a class="header" href="#5-student-self--meera-grade-6">5. Student Self — "Meera, Grade 6"</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Profile</strong></td><td>11s; uses the app for homework/results (other modules).</td></tr> <tr><td><strong>Goals</strong></td><td>See her own class, academic history (promotions), and documents; keep her avatar current.</td></tr> <tr><td><strong>Behaviours</strong></td><td>Read-only; avatar upload via <code>POST /users/:id/avatar</code> (<code>users.controller.ts:95-102</code>) <code>(forward-looking)</code>.</td></tr> <tr><td><strong>Permissions</strong></td><td>None in code for self-service students (<code>OQ-1</code>).</td></tr> <tr><td><strong>Key screens</strong></td><td><code>(forward-looking)</code> My Profile → academic history, documents.</td></tr> </tbody></table> </div> <hr /> <h2 id="persona--capability-matrix-derived-from-permissions-constants-only"><a class="header" href="#persona--capability-matrix-derived-from-permissions-constants-only">Persona → capability matrix (derived from permissions constants only)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Capability</th><th>Admin</th><th>Admission</th><th>Teacher</th><th>Parent</th><th>Student</th></tr></thead><tbody> <tr><td>List/search students (<code>student.read</code>)</td><td>✓</td><td>✓</td><td>✓</td><td>—</td><td>—</td></tr> <tr><td>Create student (<code>student.create</code>)</td><td>✓</td><td>✓</td><td>—</td><td>—</td><td>—</td></tr> <tr><td>Update profile (<code>student.update</code>)</td><td>✓</td><td>—</td><td>—</td><td>—</td><td>—</td></tr> <tr><td>Delete (soft) (<code>student.delete</code>)</td><td>✓</td><td>—</td><td>—</td><td>—</td><td>—</td></tr> <tr><td>Transfer / graduate / archive</td><td>via <code>student.update</code> semantics</td><td>—</td><td>—</td><td>—</td><td>—</td></tr> <tr><td>Upload documents</td><td>via <code>file.upload</code> + <code>student.update</code> (<code>permissions.constants.ts:87</code>)</td><td>✓</td><td>—</td><td>—</td><td>—</td></tr> <tr><td>Bulk import</td><td><code>(planned)</code> — no <code>student.import</code> constant exists (blueprint lists it, code doesn't; <code>permissions.constants.ts</code> has only the four <code>student.*</code>)</td><td>—</td><td>—</td><td>—</td><td>—</td></tr> <tr><td>Link parents</td><td>via parent routes (no <code>parent.*</code> perms — <code>OQ-10</code>)</td><td>✓</td><td>—</td><td>—</td><td>—</td></tr> <tr><td>Self view</td><td>—</td><td>—</td><td>—</td><td><code>(planned)</code> OQ-1</td><td><code>(planned)</code> OQ-1</td></tr> </tbody></table> </div> <p>Notes:</p> <ul> <li><code>student.promote</code> / <code>student.import</code> are in the blueprint (<code>04-Modules/Students.md:70-76</code>) but <strong>absent from</strong> <code>permissions.constants.ts</code> — flag if import UI needs a dedicated gate.</li> <li>The bulk controller (<code>bulk.controller.ts:28-31</code>) is JWT-only today — import is technically open to any authenticated user until <code>RbacGuard</code> lands (OQ-4).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journeys-students-module"><a class="header" href="#03--user-journeys-students-module">03 — User Journeys (Students Module)</a></h1> <ul> <li><a href="students/03_User_Journey.html#1-create--enroll-student-admission-desk">1. Create + enroll student (admission desk)</a></li> <li><a href="students/03_User_Journey.html#2-link-parent--guardian">2. Link parent / guardian</a></li> <li><a href="students/03_User_Journey.html#3-transfer-student-to-another-class">3. Transfer student to another class</a></li> <li><a href="students/03_User_Journey.html#4-upload-student-documents">4. Upload student documents</a></li> <li><a href="students/03_User_Journey.html#5-bulk-csv-import-admissions-batch">5. Bulk CSV import (admissions batch)</a></li> <li><a href="students/03_User_Journey.html#6-graduate--withdraw-student">6. Graduate / withdraw student</a></li> <li><a href="students/03_User_Journey.html#7-student-self-view-forward-looking">7. Student self-view <code>(forward-looking)</code></a></li> <li><a href="students/03_User_Journey.html#journey--endpoint-inventory-summary">Journey → endpoint inventory (summary)</a></li> </ul> <blockquote> <p>Happy-path + failure branch journeys with Mermaid. Every endpoint referenced is exact (<code>src/modules/students/controllers/student.controller.ts</code>, <code>src/modules/parents/ controllers/parent.controller.ts</code>, <code>src/modules/bulk/bulk.controller.ts</code>). All journeys assume an authenticated JWT; <code>tenantId</code> comes from the token only.</p> </blockquote> <hr /> <h2 id="1-create--enroll-student-admission-desk"><a class="header" href="#1-create--enroll-student-admission-desk">1. Create + enroll student (admission desk)</a></h2> <pre class="mermaid">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 </pre> <p>Failure branches: 400 validation (missing <code>userId</code>, malformed <code>admissionNumber</code>), 409 duplicate, 5xx. Identity-first ordering is a hard server constraint: <code>CreateStudentDto.userId</code> is required (<code>create-student.dto.ts:6-7</code>).</p> <h2 id="2-link-parent--guardian"><a class="header" href="#2-link-parent--guardian">2. Link parent / guardian</a></h2> <pre class="mermaid">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 </pre> <p>Relationship is <strong>required</strong> on the link DTO (<code>link-parent.dto.ts:9-20</code>); all four flags are optional booleans with schema defaults (<code>student-parent-link.schema.ts:27-37</code>).</p> <h2 id="3-transfer-student-to-another-class"><a class="header" href="#3-transfer-student-to-another-class">3. Transfer student to another class</a></h2> <pre class="mermaid">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] </pre> <p><code>gradeId</code>/<code>sectionId</code> are optional and default to the target class's own grade/section (<code>transfer-student.dto.ts:13-25</code>).</p> <h2 id="4-upload-student-documents"><a class="header" href="#4-upload-student-documents">4. Upload student documents</a></h2> <pre class="mermaid">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) </pre> <h2 id="5-bulk-csv-import-admissions-batch"><a class="header" href="#5-bulk-csv-import-admissions-batch">5. Bulk CSV import (admissions batch)</a></h2> <pre class="mermaid">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] </pre> <p>Import is <strong>synchronous</strong> 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.</p> <h2 id="6-graduate--withdraw-student"><a class="header" href="#6-graduate--withdraw-student">6. Graduate / withdraw student</a></h2> <pre class="mermaid">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) </pre> <h2 id="7-student-self-view-forward-looking"><a class="header" href="#7-student-self-view-forward-looking">7. Student self-view <code>(forward-looking)</code></a></h2> <pre class="mermaid">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. </pre> <hr /> <h2 id="journey--endpoint-inventory-summary"><a class="header" href="#journey--endpoint-inventory-summary">Journey → endpoint inventory (summary)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Journey</th><th>Primary endpoints</th><th>Source</th></tr></thead><tbody> <tr><td>Create + enroll</td><td><code>POST /users</code>, <code>POST /students</code></td><td><code>users.controller.ts:36-40</code>, <code>student.controller.ts:38-40</code></td></tr> <tr><td>Link parent</td><td><code>POST /parents</code>, <code>POST /parents/link/:studentId</code>, <code>DELETE /parents/link/:linkId</code></td><td><code>parent.controller.ts:29-31,47-60</code></td></tr> <tr><td>Transfer</td><td><code>POST /students/:id/transfer</code></td><td><code>student.controller.ts:62-65</code></td></tr> <tr><td>Documents</td><td><code>POST /students/:id/documents</code>, <code>GET /students/:id/documents</code></td><td><code>student.controller.ts:78-90</code></td></tr> <tr><td>Bulk import</td><td><code>POST /bulk/import/students</code>, <code>GET /bulk/export/students</code></td><td><code>bulk.controller.ts:35-60</code></td></tr> <tr><td>Graduate/archive</td><td><code>POST /students/:id/graduate|archive|restore</code></td><td><code>student.controller.ts:66-77</code></td></tr> <tr><td>Academic history</td><td><code>GET /students/:id/academic-history</code>, <code>GET /students/:id/enrollments</code></td><td><code>student.controller.ts:47-49,91-94</code></td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-students-module"><a class="header" href="#04--information-architecture-students-module">04 — Information Architecture (Students Module)</a></h1> <ul> <li><a href="students/04_Information_Architecture.html#1-placement-in-the-app-shell">1. Placement in the app shell</a></li> <li><a href="students/04_Information_Architecture.html#2-route-tree">2. Route tree</a></li> <li><a href="students/04_Information_Architecture.html#3-screen-relationships-master-detail">3. Screen relationships (master-detail)</a></li> <li><a href="students/04_Information_Architecture.html#4-navigation--actions-per-screen">4. Navigation & actions per screen</a></li> <li><a href="students/04_Information_Architecture.html#5-filter--search-model-list-screen">5. Filter & search model (list screen)</a></li> <li><a href="students/04_Information_Architecture.html#6-status-vocabulary-ui-badge-set">6. Status vocabulary (UI badge set)</a></li> <li><a href="students/04_Information_Architecture.html#7-cross-cutting-ownership">7. Cross-cutting ownership</a></li> </ul> <blockquote> <p>Where Students lives in the global shell (<a href="students/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a>) and how its screens nest. Routes are module-level; base shell routes in <a href="students/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §4</a>. Permission-gating per <a href="students/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §9</a> (client mirrors <code>permissions.constants.ts</code>; server enforcement <code>(planned)</code> — <a href="students/01_Product_Overview.html">01 §12 OQ-4</a>).</p> </blockquote> <hr /> <h2 id="1-placement-in-the-app-shell-2"><a class="header" href="#1-placement-in-the-app-shell-2">1. Placement in the app shell</a></h2> <pre><code>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) </code></pre> <ul> <li>Hidden for parent/student roles today (they have no <code>student.read</code>; self-view is <code>(planned)</code> OQ-1). If a parent/student opens a shared student link, the guard rejects → 403 screen (<code>00-shared/05 §8</code>).</li> </ul> <h2 id="2-route-tree"><a class="header" href="#2-route-tree">2. Route tree</a></h2> <pre><code>/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) </code></pre> <p>Deep links (per <code>00-shared/05 §4</code> convention): <code>studylyon://students/:id</code> → detail (documents tab); <code>studylyon://students/:id/documents/:docId</code> <code>(planned)</code> — requires file-serving endpoint (OQ-9).</p> <h2 id="3-screen-relationships-master-detail"><a class="header" href="#3-screen-relationships-master-detail">3. Screen relationships (master-detail)</a></h2> <pre><code>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) </code></pre> <ul> <li>Phone: push-on-top; tablet/desktop ≥ 840 dp: master-detail two-pane (<code>00-shared/05 §6</code>, <code>00-shared/04 §6</code>).</li> <li>Edit/transfer return to detail with refresh (never a stale cache).</li> <li>List refresh after: create (new student first page), import (count delta), restore (item reappears), delete (item leaves).</li> </ul> <h2 id="4-navigation--actions-per-screen"><a class="header" href="#4-navigation--actions-per-screen">4. Navigation & actions per screen</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>AppBar</th><th>FAB / primary CTA</th><th>Row actions (AppMenu)</th><th>Swipe (phone)</th></tr></thead><tbody> <tr><td>List</td><td>Title "Students", global search icon</td><td><code>+ Add student</code> (<code>student.create</code>)</td><td>View / Edit / Transfer / Archive / Restore / Delete</td><td>— (roster rows have menu instead)</td></tr> <tr><td>Import</td><td>Title, close</td><td>"Import CSV" (submit)</td><td>—</td><td>—</td></tr> <tr><td>Detail</td><td>Student name, back, overflow (Edit, Transfer, Graduate, Archive, Delete)</td><td>Contextual per tab</td><td>Tab-dependent</td><td>—</td></tr> <tr><td>Documents tab</td><td>—</td><td><code>+ Upload</code> (<code>file.upload</code>+<code>student.update</code>)</td><td>Preview <code>(planned)</code> / Remove <code>(planned)</code></td><td>delete <code>(planned)</code></td></tr> </tbody></table> </div> <p>Rule: destructive/irreversible actions need <code>AppDialog</code> confirm (<code>00-shared/05 §5</code>): Delete (typed confirm not needed — soft delete), Graduate, Archive. Transfer is reversible via re-transfer but confirm anyway (state change).</p> <h2 id="5-filter--search-model-list-screen"><a class="header" href="#5-filter--search-model-list-screen">5. Filter & search model (list screen)</a></h2> <ul> <li><strong>Search box</strong> (<code>AppSearchBar</code>): client filters loaded page(s) by name/ admission number/roll number today (<code>q</code> ignored server-side — OQ-2).</li> <li><strong>Filter chips</strong> (client-side until server filters land): <ul> <li>Status: all / active / graduated / archived (+ inactive/transferred when data exists — OQ-6)</li> <li>Class: pick class → filter</li> <li>Academic year</li> </ul> </li> <li><strong>Sort</strong> (client-side on loaded page): name, admission number, admission date.</li> <li>Empty states: no students ("Add your first student"), no results for filters ("No students match — clear filters").</li> </ul> <h2 id="6-status-vocabulary-ui-badge-set"><a class="header" href="#6-status-vocabulary-ui-badge-set">6. Status vocabulary (UI badge set)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Status (enum)</th><th>Badge</th><th>Meaning</th><th>Source</th></tr></thead><tbody> <tr><td><code>active</code></td><td>✓ success</td><td>Current enrollment ACTIVE</td><td><code>student.schema.ts:8</code></td></tr> <tr><td><code>inactive</code></td><td>neutral</td><td>Enum exists; no writer today (OQ-6)</td><td><code>student.schema.ts:9</code></td></tr> <tr><td><code>graduated</code></td><td>tertiary</td><td>Completed; guarded 409</td><td><code>student.schema.ts:10</code></td></tr> <tr><td><code>transferred</code></td><td>neutral</td><td>Enum exists; enrollment rows use it, student status doesn't</td><td><code>student.schema.ts:11</code></td></tr> <tr><td><code>archived</code></td><td>error-ish (muted)</td><td>Withdrawn/left; restorable</td><td><code>student.schema.ts:12</code></td></tr> </tbody></table> </div> <p>Enrollment statuses (<code>class-enrollment.schema.ts:7-11</code>): <code>active</code>, <code>inactive</code>, <code>transferred</code> — shown inside history tab timeline, not as student badge.</p> <h2 id="7-cross-cutting-ownership"><a class="header" href="#7-cross-cutting-ownership">7. Cross-cutting ownership</a></h2> <div class="table-wrapper"><table><thead><tr><th>Concern</th><th>Owner</th><th>Note</th></tr></thead><tbody> <tr><td>Identity fields (name, email, phone, avatar)</td><td>Users module (<code>/users/:id</code>)</td><td>Students UI reads via linked <code>userId</code>; avatar edit → <code>POST /users/:id/avatar</code> (<code>users.controller.ts:95-102</code>)</td></tr> <tr><td>Attendance / fees / results tabs</td><td>Those modules</td><td>Forward-looking; read APIs from those modules</td></tr> <tr><td>Global search</td><td><code>/search</code> (<code>00-shared/05 §3</code>)</td><td><code>GET /api/v1/search?q=</code> <code>(planned)</code> (<code>docs/IMPLEMENTATION_PLAN.md:174</code>)</td></tr> <tr><td>Notifications</td><td><code>in-app</code> queue</td><td><code>StudentCreated</code>/<code>ParentCreated</code> → notification docs (<code>event-queue-map.ts:28,37</code>)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-students-module"><a class="header" href="#05--screen-inventory-students-module">05 — Screen Inventory (Students Module)</a></h1> <ul> <li><a href="students/05_Screen_Inventory.html#1-students-list-roster--students">1. Students List (Roster) — <code>/students</code></a></li> <li><a href="students/05_Screen_Inventory.html#2-student-detail--studentsid">2. Student Detail — <code>/students/:id</code></a></li> <li><a href="students/05_Screen_Inventory.html#3-create-student-wizard--studentsadd">3. Create Student Wizard — <code>/students/add</code></a></li> <li><a href="students/05_Screen_Inventory.html#4-bulk-import-wizard--studentsimport">4. Bulk Import Wizard — <code>/students/import</code></a></li> <li><a href="students/05_Screen_Inventory.html#5-transfer-form--studentsidtransfer">5. Transfer Form — <code>/students/:id/transfer</code></a></li> <li><a href="students/05_Screen_Inventory.html#6-enroll-form-sheet--from-detail">6. Enroll Form (sheet) — from detail</a></li> <li><a href="students/05_Screen_Inventory.html#7-documents-list--upload--detail-tab--sheet">7. Documents List + Upload — detail tab + sheet</a></li> <li><a href="students/05_Screen_Inventory.html#8-profile-photo-upload-identity--via-users-module">8. Profile Photo Upload (identity) — via Users module</a></li> <li><a href="students/05_Screen_Inventory.html#9-parents-tab-detail--link-sheet">9. Parents Tab (detail) + Link Sheet</a></li> <li><a href="students/05_Screen_Inventory.html#10-graduate--archive--restore--delete-dialogs">10. Graduate / Archive / Restore / Delete dialogs</a></li> <li><a href="students/05_Screen_Inventory.html#shared-components-used">Shared components used</a></li> <li><a href="students/05_Screen_Inventory.html#module-specific-components-07_component_librarymd">Module-specific components (07_Component_Library.md)</a></li> <li><a href="students/05_Screen_Inventory.html#analytics-events-proposed">Analytics events (proposed)</a></li> </ul> <blockquote> <p>Every screen of the Students module with intent, route, composition, states and permissions. Shared components come from <a href="students/../00-shared/03_Component_Library.html">00-shared/03</a>; module-specific components are defined in <a href="students/07_Component_Library.html">07_Component_Library.md</a>. States legend: idle / loading / success / empty / error / disabled / permission. Analytics events <code>(proposed)</code>.</p> </blockquote> <hr /> <h2 id="1-students-list-roster--students"><a class="header" href="#1-students-list-roster--students">1. Students List (Roster) — <code>/students</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Find, filter, open and batch-manage the roster</td></tr> <tr><td>Entry</td><td>Bottom nav "Students"; deep link; after import/create returns</td></tr> <tr><td>Source</td><td><code>GET /students</code> (paginated <code>data[]</code> + <code>meta</code>) — <code>student.controller.ts:41-43</code>; <code>find()</code> ignores <code>q</code>/<code>sort</code> (OQ-2)</td></tr> <tr><td>Composition</td><td><code>AppSearchBar</code>, status/class/year filter chips (client-side), list of <code>StudentListTile</code> (avatar from user, name, admission number, class label, status badge), infinite scroll (<code>meta.hasNext</code>)</td></tr> <tr><td>FAB</td><td><code>+ Add student</code> (visible iff <code>student.create</code>)</td></tr> <tr><td>Row menu</td><td>View, Edit, Transfer, Graduate, Archive, Restore, Delete (contextual by status: Restore only when archived; Graduate/Transfer only when active)</td></tr> <tr><td>States</td><td>loading → <code>AppSkeleton</code> list; empty → <code>AppEmptyState</code> ("No students yet — add or import"); error → <code>AppErrorState</code> with retry; offline → cached list + banner</td></tr> <tr><td>Keyboard (desktop)</td><td><code>Ctrl+F</code> focus search, <code>N</code> new, <code>Esc</code> clears filters</td></tr> <tr><td>Analytics</td><td><code>students.list.view</code>, <code>students.list.search</code>, <code>students.list.filter</code>, <code>students.list.open</code> <code>(proposed)</code></td></tr> <tr><td>a11y</td><td>Search results count live-region; "item X of Y" list announcements (<a href="students/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §5</a>)</td></tr> </tbody></table> </div> <h2 id="2-student-detail--studentsid"><a class="header" href="#2-student-detail--studentsid">2. Student Detail — <code>/students/:id</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>One student: identity + status + per-domain tabs</td></tr> <tr><td>Source</td><td><code>GET /students/:id</code> (<code>student.controller.ts:44-46</code>); tabs lazy-load their own endpoints</td></tr> <tr><td>Composition</td><td>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), <code>AppTabs</code>: Profile · Attendance · Fees · Results · Documents · History</td></tr> <tr><td>Tab sources</td><td>Profile = <code>GET /students/:id</code>; Documents = <code>GET /students/:id/documents</code> (createdAt desc); History = <code>GET /students/:id/academic-history</code> (joinedAt desc) + <code>GET /students/:id/enrollments</code> (active); Attendance/Fees/Results = other modules <code>(forward-looking)</code></td></tr> <tr><td>States</td><td>detail skeleton → content; tab-level skeletons; tab error isolated (tab shows <code>AppErrorState</code> compact, rest of screen intact)</td></tr> <tr><td>a11y</td><td>Tabs announced with badges; header actions labelled</td></tr> <tr><td>Analytics</td><td><code>students.detail.view</code>, <code>students.detail.tab.{profile,documents,history,…}</code> <code>(proposed)</code></td></tr> </tbody></table> </div> <h2 id="3-create-student-wizard--studentsadd"><a class="header" href="#3-create-student-wizard--studentsadd">3. Create Student Wizard — <code>/students/add</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Admission in one guided flow (identity → academics → extras → confirm)</td></tr> <tr><td>Source</td><td><code>POST /users</code> then <code>POST /students</code> (auto-enrolls — <code>student.service.ts:71-78</code>)</td></tr> <tr><td>Steps</td><td>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)</td></tr> <tr><td>CTA per step</td><td>Next / Back; final step "Create student"</td></tr> <tr><td>States</td><td>step validation errors inline; submit loading; 409 duplicate admissionNumber → jump to field; 400 → field mapping; offline → blocked</td></tr> <tr><td>Duplicate guard</td><td>admission number pre-check against loaded roster client-side; server 409 is authoritative</td></tr> <tr><td>Analytics</td><td><code>students.create.start</code>, <code>students.create.step_{n}</code>, <code>students.create.success</code>, <code>students.create.failure(code)</code> <code>(proposed)</code></td></tr> </tbody></table> </div> <h2 id="4-bulk-import-wizard--studentsimport"><a class="header" href="#4-bulk-import-wizard--studentsimport">4. Bulk Import Wizard — <code>/students/import</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>CSV admissions batch with template + report</td></tr> <tr><td>Source</td><td><code>GET /bulk/export/students</code> (template), <code>POST /bulk/import/students</code> (multipart <code>file</code>)</td></tr> <tr><td>Steps</td><td>1) Template: download CSV (columns: admissionNumber, rollNumber, status, admissionDate — <code>students-import.adapter.ts:87-98</code>); show required column list · 2) File pick: accept <code>.csv</code>; client pre-parse preview (isolate — <a href="students/../00-shared/11_Flutter_App_Architecture.html">00-shared/11 §13</a>) · 3) Upload: progress bar (request is synchronous server-side — UI simulates progress; OQ-12) · 4) Report: <code>{totalRows, imported, failed, errors[{rowNumber, errors}]}</code> — grouped failure list, "Export failures" not available server-side (client can re-download template), retry with fixed CSV</td></tr> <tr><td>Guards</td><td>missing file → 400; malformed/empty CSV → 400 with message</td></tr> <tr><td>States</td><td>step states; upload loading; report success/failure mixed is normal (partial success)</td></tr> <tr><td>Analytics</td><td><code>students.import.template</code>, <code>students.import.upload</code>, <code>students.import.report{imported,failed}</code> <code>(proposed)</code></td></tr> </tbody></table> </div> <h2 id="5-transfer-form--studentsidtransfer"><a class="header" href="#5-transfer-form--studentsidtransfer">5. Transfer Form — <code>/students/:id/transfer</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Move student to a new class (same or next academic year)</td></tr> <tr><td>Source</td><td><code>POST /students/:id/transfer</code> — guard: status must be <code>active</code> else 409 (<code>student.service.ts:175-179</code>)</td></tr> <tr><td>Composition</td><td>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"</td></tr> <tr><td>CTA</td><td>"Transfer student" (confirm dialog first)</td></tr> <tr><td>States</td><td>pre-fill from current class; submit loading; 409 → status banner + block; success → back to detail, history tab shows old enrollment <code>transferred</code></td></tr> <tr><td>Analytics</td><td><code>students.transfer.submit</code>, `students.transfer.success</td></tr> </tbody></table> </div> <h2 id="6-enroll-form-sheet--from-detail"><a class="header" href="#6-enroll-form-sheet--from-detail">6. Enroll Form (sheet) — from detail</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Enroll into a class for an academic year (history preserved)</td></tr> <tr><td>Source</td><td><code>POST /students/:id/enroll</code> — deactivates current actives, creates new ACTIVE (<code>student.service.ts:125-140</code>)</td></tr> <tr><td>Composition</td><td><code>AppBottomSheet</code>: classId (required), academicYearId (required), rollNumber (optional)</td></tr> <tr><td>CTA</td><td>"Enroll" — warning text: existing active enrollments become <code>transferred</code></td></tr> <tr><td>Note</td><td>Same backend as transfer minus profile fields; used for re-enrollment/next-year rollover</td></tr> </tbody></table> </div> <h2 id="7-documents-list--upload--detail-tab--sheet"><a class="header" href="#7-documents-list--upload--detail-tab--sheet">7. Documents List + Upload — detail tab + sheet</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Per-student files: birth certificate, reports, transfers</td></tr> <tr><td>Source</td><td><code>GET /students/:id/documents</code> (createdAt desc — <code>student.service.ts:273-279</code>); upload <code>POST /students/:id/documents</code> multipart <code>file</code> + optional <code>category</code></td></tr> <tr><td>Composition</td><td>List of <code>AppAttachmentTile</code> (fileName, size, mimeType, category chip, uploadedBy label, createdAt); FAB/sheet <code>+ Upload</code>: file picker + category <code>AppDropdown</code> (free values — no enum server-side; <code>upload-student-document.dto.ts:4-9</code>)</td></tr> <tr><td>States</td><td>empty → "No documents yet"; upload progress tile; error 400/5xx</td></tr> <tr><td>a11y</td><td>Attachment labels with type + size</td></tr> <tr><td>Notes</td><td>No download endpoint returns bytes today (only <code>fileId</code> metadata; local provider exposes <code>/api/v1/files/<tenant>/<uuid>--<name></code> path — OQ-9). Preview/Download actions are <code>(planned)</code>.</td></tr> </tbody></table> </div> <h2 id="8-profile-photo-upload-identity--via-users-module"><a class="header" href="#8-profile-photo-upload-identity--via-users-module">8. Profile Photo Upload (identity) — via Users module</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Student avatar (stored on the user record, not the student)</td></tr> <tr><td>Source</td><td><code>POST /users/:id/avatar</code> multipart <code>file</code> (<code>users.controller.ts:95-102</code>) — surfaced inside student detail header <code>(forward-looking)</code></td></tr> <tr><td>Composition</td><td>avatar tap → picker → upload tile → header refresh</td></tr> <tr><td>States</td><td>uploading spinner; error snackbar</td></tr> </tbody></table> </div> <h2 id="9-parents-tab-detail--link-sheet"><a class="header" href="#9-parents-tab-detail--link-sheet">9. Parents Tab (detail) + Link Sheet</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Manage guardians</td></tr> <tr><td>Source</td><td><code>GET /parents/link/student/:studentId</code> (list links — <code>parent.controller.ts:53-57</code>); create parent <code>POST /parents</code> (<code>parent.controller.ts:29-31</code>); link <code>POST /parents/link/:studentId</code> (<code>parent.controller.ts:47-52</code>); unlink <code>DELETE /parents/link/:linkId</code> (<code>parent.controller.ts:58-60</code>)</td></tr> <tr><td>Composition</td><td>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</td></tr> <tr><td>Link sheet</td><td>Pick existing parent (search) or create new (user + parent profile: occupation, company, annualIncome, relationshipNotes, pickupAuthorization — <code>create-parent.dto.ts</code>); required relationship selector; optional toggles</td></tr> <tr><td>Guards</td><td>409 duplicate parent-per-user; 404 unknown student; parent existence not checked at link time (OQ-11)</td></tr> </tbody></table> </div> <h2 id="10-graduate--archive--restore--delete-dialogs"><a class="header" href="#10-graduate--archive--restore--delete-dialogs">10. Graduate / Archive / Restore / Delete dialogs</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Graduate</td><td>Confirm → <code>POST /students/:id/graduate</code>; 409 if already graduated; result: status badge <code>graduated</code></td></tr> <tr><td>Archive</td><td>Confirm ("hidden from roster, can restore later") → <code>POST /students/:id/archive</code>; 409 if already archived</td></tr> <tr><td>Restore</td><td><code>POST /students/:id/restore</code> → status <code>active</code>; item reappears in list</td></tr> <tr><td>Delete</td><td>Confirm ("removes from roster; academic records retained") → <code>DELETE /students/:id</code> soft delete (<code>student.service.ts:157-168</code>); 404 if already gone</td></tr> <tr><td>a11y</td><td>Dialogs trap focus; destructive actions use <code>error</code> colors</td></tr> </tbody></table> </div> <hr /> <h2 id="shared-components-used-22"><a class="header" href="#shared-components-used-22">Shared components used</a></h2> <p><code>AppSearchBar</code>, <code>AppChips</code>, <code>AppListTile</code>, <code>AppAvatar</code>, <code>AppBadge</code>, <code>AppTabs</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppErrorState</code>, <code>AppOfflineBanner</code>, <code>AppFAB</code>, <code>AppButton</code>, <code>AppDialog</code>, <code>AppBottomSheet</code>, <code>AppMenu</code>, <code>AppTextField</code>, <code>AppDropdown</code>, <code>AppDatePicker</code>, <code>AppSwitch</code>, <code>AppAttachmentTile</code>, <code>AppStepper</code>, <code>AppSnackbar</code>, <code>AppInfoRow</code>, <code>AppCard</code>.</p> <h2 id="module-specific-components-07_component_librarymd"><a class="header" href="#module-specific-components-07_component_librarymd">Module-specific components (<a href="students/07_Component_Library.html">07_Component_Library.md</a>)</a></h2> <p><code>StudentListTile</code>, <code>StatusBadge</code>, <code>StudentHeader</code>, <code>AcademicHistoryTimeline</code>, <code>ImportReportCard</code>, <code>RelationshipChip</code>, <code>GuardianLinkCard</code>.</p> <h2 id="analytics-events-proposed-19"><a class="header" href="#analytics-events-proposed-19">Analytics events (proposed)</a></h2> <p><code>students.list.{view,search,filter,open}</code>, <code>students.create.{start,step,success,failure}</code>, <code>students.detail.{view,tab}</code>, <code>students.transfer.{submit,success,failure}</code>, <code>students.enroll.{submit,success,failure}</code>, <code>students.document.{upload,open}</code>, <code>students.import.{template,upload,report}</code>, <code>students.graduate/archive/restore/delete.{confirm,success}</code>.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-students-module"><a class="header" href="#06--screen-specifications-students-module">06 — Screen Specifications (Students Module)</a></h1> <ul> <li><a href="students/06_Screen_Specifications.html#1-students-list-roster">1. Students List (Roster)</a> <ul> <li><a href="students/06_Screen_Specifications.html#layout-phone">Layout (phone)</a></li> <li><a href="students/06_Screen_Specifications.html#behaviour-rules">Behaviour rules</a></li> <li><a href="students/06_Screen_Specifications.html#states-matrix">States matrix</a></li> <li><a href="students/06_Screen_Specifications.html#accessibility">Accessibility</a></li> </ul> </li> <li><a href="students/06_Screen_Specifications.html#2-student-detail">2. Student Detail</a> <ul> <li><a href="students/06_Screen_Specifications.html#layout-phone-tabbed">Layout (phone, tabbed)</a></li> <li><a href="students/06_Screen_Specifications.html#tab-profile">Tab: Profile</a></li> <li><a href="students/06_Screen_Specifications.html#tab-documents">Tab: Documents</a></li> <li><a href="students/06_Screen_Specifications.html#tab-history">Tab: History</a></li> <li><a href="students/06_Screen_Specifications.html#tab-attendance--fees--results-forward-looking">Tab: Attendance / Fees / Results <code>(forward-looking)</code></a></li> <li><a href="students/06_Screen_Specifications.html#behaviour-rules-1">Behaviour rules</a></li> </ul> </li> <li><a href="students/06_Screen_Specifications.html#3-create-student-wizard-studentsadd">3. Create Student Wizard (<code>/students/add</code>)</a> <ul> <li><a href="students/06_Screen_Specifications.html#step-model-appstepper--horizontal-phone-vertical-desktop">Step model (AppStepper — horizontal phone, vertical desktop)</a></li> <li><a href="students/06_Screen_Specifications.html#submission-sequence-exact">Submission sequence (exact)</a></li> <li><a href="students/06_Screen_Specifications.html#error-mapping">Error mapping</a></li> <li><a href="students/06_Screen_Specifications.html#states">States</a></li> </ul> </li> <li><a href="students/06_Screen_Specifications.html#4-transfer-form-studentsidtransfer">4. Transfer Form (<code>/students/:id/transfer</code>)</a></li> <li><a href="students/06_Screen_Specifications.html#5-enroll-sheet-detail--enroll">5. Enroll Sheet (detail → "Enroll")</a></li> <li><a href="students/06_Screen_Specifications.html#6-documents-upload-sheet">6. Documents Upload Sheet</a></li> <li><a href="students/06_Screen_Specifications.html#7-bulk-import-wizard-studentsimport">7. Bulk Import Wizard (<code>/students/import</code>)</a> <ul> <li><a href="students/06_Screen_Specifications.html#step-1--template">Step 1 — Template</a></li> <li><a href="students/06_Screen_Specifications.html#step-2--file">Step 2 — File</a></li> <li><a href="students/06_Screen_Specifications.html#step-3--progress-uploading">Step 3 — Progress (uploading)</a></li> <li><a href="students/06_Screen_Specifications.html#step-4--report">Step 4 — Report</a></li> </ul> </li> <li><a href="students/06_Screen_Specifications.html#8-link-parent-sheet-detail--parents-tab--">8. Link Parent Sheet (detail → Parents tab → +)</a></li> <li><a href="students/06_Screen_Specifications.html#9-graduate--archive--restore--delete-dialogs">9. Graduate / Archive / Restore / Delete dialogs</a></li> <li><a href="students/06_Screen_Specifications.html#shared-layout-rules-applied">Shared layout rules applied</a></li> </ul> <blockquote> <p>Production-grade layout specs for the core screens. Wire-accurate to the API (<a href="students/12_API_Mapping.html">12_API_Mapping.md</a>); tokens from <a href="students/../00-shared/02_Design_Tokens.html">00-shared/02</a>; components from <a href="students/../00-shared/03_Component_Library.html">00-shared/03</a> and <a href="students/07_Component_Library.html">07_Component_Library.md</a>. Grid: 4 dp. Breakpoints: phone < 600, tablet 600–839, landscape 840–1199, desktop ≥ 1200 (<a href="students/../00-shared/02_Design_Tokens.html">00-shared/02 §8</a>).</p> </blockquote> <hr /> <h2 id="1-students-list-roster"><a class="header" href="#1-students-list-roster">1. Students List (Roster)</a></h2> <h3 id="layout-phone-1"><a class="header" href="#layout-phone-1">Layout (phone)</a></h3> <pre><code>┌──────────────────────────────────────────────┐ │ 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)│ └──────────────────────────────────────────────┘ </code></pre> <ul> <li>Padding: horizontal 16 (phone) / 24 (tablet+). Row min height 56; avatar 40.</li> <li>Sticky header group: search + chips scroll away or stick? — <strong>stick</strong> search bar, chips scroll with content (desktop keeps all sticky, <code>e-2</code>).</li> <li>Tablet 600–839: 2-column list (cards, <code>surfaceContainerLow</code>, r-lg).</li> <li>Desktop ≥ 1200: master-detail — list left (320–400 dp), detail pane right; row tap selects (indicator <code>secondaryContainer</code>).</li> </ul> <h3 id="behaviour-rules"><a class="header" href="#behaviour-rules">Behaviour rules</a></h3> <ul> <li>Search: <code>AppSearchBar</code> debounce 300 ms → client filter over loaded pages (server <code>q</code> ignored — OQ-2). While searching, chips disabled except Status.</li> <li>Infinite scroll: load next page when last tile visible; append; <code>meta.hasNext</code> guard (<code>meta</code> shape from <code>pagination-query.dto.ts:32-39</code>).</li> <li>Pull-to-refresh: reload page 1 + reset.</li> <li>Row menu visibility by status: <ul> <li><code>active</code> → View, Edit, Transfer, Graduate, Archive, Delete</li> <li><code>graduated</code> → View, Edit, Restore* (no server un-graduate; Restore = archive→active only — <strong>omit</strong>; graduated only Edit, Delete), Archive, Delete</li> <li><code>archived</code> → View, Edit, Restore, Delete</li> <li><em>(see <code>01 §12 OQ-5/6</code> — graduated is terminal; no action maps to it)</em></li> </ul> </li> <li>Empty states: no data → <code>AppEmptyState</code> "No students yet — Add or Import"; filtered empty → "No students match — clear filters" with <code>Clear</code> action.</li> <li>Error: <code>AppErrorState</code> code+retry; offline: banner + cached rows.</li> </ul> <h3 id="states-matrix"><a class="header" href="#states-matrix">States matrix</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Render</th></tr></thead><tbody> <tr><td>initial/loading</td><td>6 × <code>AppSkeleton</code> list rows</td></tr> <tr><td>success+data</td><td>rows + meta count + infinite scroll</td></tr> <tr><td>success+empty</td><td><code>AppEmptyState</code></td></tr> <tr><td>error</td><td><code>AppErrorState(code, onRetry)</code></td></tr> <tr><td>loadingMore</td><td>bottom <code>LinearProgressIndicator</code> (indeterminate, <code>m-instant</code> fade)</td></tr> <tr><td>offline</td><td>rows from cache + <code>AppOfflineBanner</code>; FAB disabled? <strong>no</strong> — create blocked with snackbar</td></tr> </tbody></table> </div> <h3 id="accessibility-2"><a class="header" href="#accessibility-2">Accessibility</a></h3> <ul> <li>Live region: "24 students" / "0 results".</li> <li>Status never color-only: badge = icon + text (✓ Active etc. — <code>00-shared/09 §9</code>).</li> <li>Search field label "Search students".</li> </ul> <hr /> <h2 id="2-student-detail"><a class="header" href="#2-student-detail">2. Student Detail</a></h2> <h3 id="layout-phone-tabbed"><a class="header" href="#layout-phone-tabbed">Layout (phone, tabbed)</a></h3> <pre><code>┌──────────────────────────────────────────────┐ │ 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) │ └──────────────────────────────────────────────┘ </code></pre> <ul> <li>Tablet/desktop: header card + tabs in right pane of master-detail; two-column info grid.</li> <li>Overflow menu per status (same rules as list row menu).</li> </ul> <h3 id="tab-profile"><a class="header" href="#tab-profile">Tab: Profile</a></h3> <pre><code>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)`) </code></pre> <h3 id="tab-documents"><a class="header" href="#tab-documents">Tab: Documents</a></h3> <pre><code>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…" </code></pre> <h3 id="tab-history"><a class="header" href="#tab-history">Tab: History</a></h3> <pre><code>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) </code></pre> <h3 id="tab-attendance--fees--results-forward-looking"><a class="header" href="#tab-attendance--fees--results-forward-looking">Tab: Attendance / Fees / Results <code>(forward-looking)</code></a></h3> <p>Owned by their modules; here: embedded lazy tab shells that call those modules' read APIs (per <code>PLAN.md</code> 5.4/7.x/8.x flows). If module API not implemented → tab shows <code>(planned)</code> empty-state placeholder. Do not block rendering of other tabs.</p> <h3 id="behaviour-rules-1"><a class="header" href="#behaviour-rules-1">Behaviour rules</a></h3> <ul> <li>Tabs lazy-load on first activation; state kept alive (<code>TabBarView</code> + keep-alive).</li> <li>After any action (edit/transfer/enroll/upload/graduate/archive/restore) refresh profile + history + badges, snackbar confirmation.</li> <li>404 on load → <code>AppErrorState</code> "Student not found" + back; treat as deleted.</li> </ul> <hr /> <h2 id="3-create-student-wizard-studentsadd"><a class="header" href="#3-create-student-wizard-studentsadd">3. Create Student Wizard (<code>/students/add</code>)</a></h2> <h3 id="step-model-appstepper--horizontal-phone-vertical-desktop"><a class="header" href="#step-model-appstepper--horizontal-phone-vertical-desktop">Step model (AppStepper — horizontal phone, vertical desktop)</a></h3> <p><strong>Step 1 — Identity</strong></p> <pre><code>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) </code></pre> <p><strong>Step 2 — Academics</strong></p> <pre><code>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) </code></pre> <p><strong>Step 3 — Extras</strong></p> <pre><code>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) </code></pre> <p><strong>Step 4 — Review & submit</strong></p> <pre><code>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) </code></pre> <h3 id="submission-sequence-exact"><a class="header" href="#submission-sequence-exact">Submission sequence (exact)</a></h3> <ol> <li><code>POST /users {firstName,lastName,email,phone?}</code> → 200 <code>user._id</code> (or 409 → "email already registered" → switch to search-existing step).</li> <li><code>POST /students {userId, admissionNumber, rollNumber?, academicYearId, campusId?, gradeId, sectionId, classId, houseId?, admissionDate?, admissionType?, transportRequired?, hostelRequired?, medicalNotes?}</code> → 200 student (auto-enrolled).</li> <li>Navigate <code>/students/:id</code> + snackbar "Student created".</li> </ol> <h3 id="error-mapping"><a class="header" href="#error-mapping">Error mapping</a></h3> <div class="table-wrapper"><table><thead><tr><th>Error</th><th>UI</th></tr></thead><tbody> <tr><td>400 validation</td><td>field errors (first invalid field focused)</td></tr> <tr><td>409 duplicate admission</td><td>admission-number field inline "already exists" + suggest search</td></tr> <tr><td>409 duplicate email (users)</td><td>step 1 inline, offer reuse</td></tr> <tr><td>429</td><td>countdown banner, disable CTA</td></tr> <tr><td>offline</td><td>block submit, banner</td></tr> </tbody></table> </div> <h3 id="states-16"><a class="header" href="#states-16">States</a></h3> <p>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).</p> <hr /> <h2 id="4-transfer-form-studentsidtransfer"><a class="header" href="#4-transfer-form-studentsidtransfer">4. Transfer Form (<code>/students/:id/transfer</code>)</a></h2> <pre><code>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?") </code></pre> <ul> <li>Pre-submit validation client-side: target must differ from current (<code>classId</code>/<code>academicYearId</code> both equal current → block with inline message).</li> <li>Server guard: status ≠ <code>active</code> → 409 <code>Cannot transfer a student with status "x".</code> → screen switches to read-only + status banner (student.service.ts:175-179).</li> <li>Success → detail refresh; history tab shows old enrollment <code>transferred</code> + <code>leftAt</code> (<code>class-enrollment.schema.ts:30-31</code>).</li> </ul> <h2 id="5-enroll-sheet-detail--enroll"><a class="header" href="#5-enroll-sheet-detail--enroll">5. Enroll Sheet (detail → "Enroll")</a></h2> <pre><code>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 </code></pre> <h2 id="6-documents-upload-sheet"><a class="header" href="#6-documents-upload-sheet">6. Documents Upload Sheet</a></h2> <pre><code>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") </code></pre> <ul> <li>Client-side guards (server has none — OQ-9): size ≤ 10 MB <code>(proposed)</code>, mime allowlist pdf/jpg/png/webp <code>(proposed)</code>; violation → inline error before upload.</li> <li>Upload progress: determinate bar; on 200 → tile appears at top (createdAt desc); on network loss mid-upload → error + retry tile (<code>00-shared/10 §2</code>).</li> </ul> <h2 id="7-bulk-import-wizard-studentsimport"><a class="header" href="#7-bulk-import-wizard-studentsimport">7. Bulk Import Wizard (<code>/students/import</code>)</a></h2> <h3 id="step-1--template"><a class="header" href="#step-1--template">Step 1 — Template</a></h3> <pre><code>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] </code></pre> <h3 id="step-2--file"><a class="header" href="#step-2--file">Step 2 — File</a></h3> <pre><code>├─ 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] </code></pre> <h3 id="step-3--progress-uploading"><a class="header" href="#step-3--progress-uploading">Step 3 — Progress (uploading)</a></h3> <pre><code>Uploading… (indeterminate bar; request synchronous server-side — OQ-12) "Processing N rows — this may take a minute for large files" </code></pre> <h3 id="step-4--report"><a class="header" href="#step-4--report">Step 4 — Report</a></h3> <pre><code>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 </code></pre> <ul> <li>Partial success is the normal state — never show "failed" as an error page.</li> </ul> <h2 id="8-link-parent-sheet-detail--parents-tab--"><a class="header" href="#8-link-parent-sheet-detail--parents-tab--">8. Link Parent Sheet (detail → Parents tab → +)</a></h2> <pre><code>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 </code></pre> <ul> <li>New-parent branch sequence: <code>POST /users</code> → <code>POST /parents</code> (409 if parent profile exists for user) → link.</li> <li>Unlink: row menu → confirm → <code>DELETE /parents/link/:linkId</code> (404 → treat removed).</li> </ul> <h2 id="9-graduate--archive--restore--delete-dialogs"><a class="header" href="#9-graduate--archive--restore--delete-dialogs">9. Graduate / Archive / Restore / Delete dialogs</a></h2> <div class="table-wrapper"><table><thead><tr><th>Dialog</th><th>Copy (title / body)</th><th>Confirm label</th><th>Endpoint</th></tr></thead><tbody> <tr><td>Graduate</td><td>"Graduate Meera Sharma?" / "Marks the student as graduated. This cannot be undone by the UI (no un-graduate endpoint)."</td><td>Graduate (destructive)</td><td><code>POST /students/:id/graduate</code></td></tr> <tr><td>Archive</td><td>"Archive Meera Sharma?" / "Hidden from the roster. You can restore anytime."</td><td>Archive</td><td><code>POST /students/:id/archive</code></td></tr> <tr><td>Restore</td><td>"Restore Meera Sharma?" / "Returns the student to active status."</td><td>Restore</td><td><code>POST /students/:id/restore</code></td></tr> <tr><td>Delete</td><td>"Delete Meera Sharma?" / "Soft-deletes the record — excluded from all lists. Documents and history remain."</td><td>Delete (destructive)</td><td><code>DELETE /students/:id</code></td></tr> </tbody></table> </div> <p>409 handling: <code>graduate</code> twice / <code>archive</code> twice / <code>restore</code> twice → server 409 "Student is already graduated/archived/active." → snackbar, state refresh (<code>student.service.ts:207-209,228-230</code>).</p> <hr /> <h2 id="shared-layout-rules-applied"><a class="header" href="#shared-layout-rules-applied">Shared layout rules applied</a></h2> <ul> <li>One <code>headlineMedium</code> per screen title (<code>00-shared/09 §11</code>).</li> <li>All lists <code>ListView.builder</code>; infinite scroll with end-of-list footer.</li> <li>Keyboard: phone bottom-inset avoidance on all forms.</li> <li>Motion: screen enter <code>m-base</code> slide; sheet <code>m-base</code> scale+fade; success snackbar < 3 s; error ≥ 4 s (<code>00-shared/03 A</code>).</li> <li>Dark mode: every surface tokenized; badges keep icon+text (no color-only).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-students-module"><a class="header" href="#07--component-library-students-module">07 — Component Library (Students Module)</a></h1> <ul> <li><a href="students/07_Component_Library.html#1-studentlisttile">1. StudentListTile</a></li> <li><a href="students/07_Component_Library.html#2-statusbadge">2. StatusBadge</a></li> <li><a href="students/07_Component_Library.html#3-studentheader-detail">3. StudentHeader (detail)</a></li> <li><a href="students/07_Component_Library.html#4-academichistorytimeline">4. AcademicHistoryTimeline</a></li> <li><a href="students/07_Component_Library.html#5-importreportcard">5. ImportReportCard</a></li> <li><a href="students/07_Component_Library.html#6-relationshipchip">6. RelationshipChip</a></li> <li><a href="students/07_Component_Library.html#7-guardianlinkcard">7. GuardianLinkCard</a></li> <li><a href="students/07_Component_Library.html#8-classselectcascade-wizard-helper">8. ClassSelectCascade (wizard helper)</a></li> <li><a href="students/07_Component_Library.html#9-documentuploadtile">9. DocumentUploadTile</a></li> <li><a href="students/07_Component_Library.html#10-rosterfilterbar">10. RosterFilterBar</a></li> <li><a href="students/07_Component_Library.html#component-reuse-map">Component reuse map</a></li> <li><a href="students/07_Component_Library.html#golden-test-expectation">Golden-test expectation</a></li> </ul> <blockquote> <p>Module-specific widgets built on <a href="students/../00-shared/03_Component_Library.html">00-shared/03</a> components. Each entry: purpose, props, states, tokens, Flutter mapping. Analytics events follow the <code>(proposed)</code> convention from <a href="students/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a>.</p> </blockquote> <hr /> <h2 id="1-studentlisttile"><a class="header" href="#1-studentlisttile">1. StudentListTile</a></h2> <ul> <li><strong>Purpose:</strong> roster row (list screen + master-detail pane).</li> <li><strong>Props:</strong> <code>student</code> (id, admissionNumber, rollNumber, status, class/grade/section labels, academicYear label), <code>user</code> (name, avatarUrl), <code>onTap</code>, <code>menuItems</code>, <code>selected</code> (desktop master-detail).</li> <li><strong>States:</strong> default / selected (<code>secondaryContainer</code> indicator) / disabled (none needed — no student is un-openable).</li> <li><strong>Layout:</strong> avatar 40 (<code>AppAvatar</code>, fallback initials), title = display name (<code>titleMedium</code>), subtitle = <code>ADM-2026-0142 · Grade 6 · B</code> (<code>bodySmall</code> mono for the admission number), trailing = <code>StatusBadge</code> + chevron/menu (<code>AppMenu</code>).</li> <li><strong>Tokens:</strong> list row ≥ 56 (<a href="students/../00-shared/02_Design_Tokens.html">00-shared/02 §9</a>), <code>surface</code>, <code>e-0</code>.</li> <li><strong>Flutter:</strong> <code>ListTile</code> wrapped with <code>MenuAnchor</code>; <code>Hero</code> tag = <code>student-{id}</code> (avatar → detail header, <a href="students/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §4</a>).</li> <li><strong>A11y:</strong> combined label "Meera Sharma, active, Grade 6 B"; row tap semantics.</li> </ul> <h2 id="2-statusbadge"><a class="header" href="#2-statusbadge">2. StatusBadge</a></h2> <ul> <li><strong>Purpose:</strong> student + enrollment status chips; never color-only (<a href="students/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §9</a>).</li> <li><strong>Props:</strong> <code>status</code> (StudentStatus | EnrollmentStatus), <code>size</code> (sm 20 / md 24).</li> <li><strong>Mapping</strong> (from <code>student.schema.ts:7-13</code>, <code>class-enrollment.schema.ts:7-11</code>):</li> </ul> <div class="table-wrapper"><table><thead><tr><th>Status</th><th>Icon</th><th>Color role</th><th>Label</th></tr></thead><tbody> <tr><td><code>active</code></td><td>✓ check_circle (filled)</td><td><code>secondary</code>/success</td><td>Active</td></tr> <tr><td><code>inactive</code></td><td>remove_circle_outline</td><td><code>onSurfaceVariant</code></td><td>Inactive</td></tr> <tr><td><code>graduated</code></td><td>school (filled)</td><td><code>tertiary</code></td><td>Graduated</td></tr> <tr><td><code>transferred</code></td><td>swap_horiz (filled)</td><td><code>onSurfaceVariant</code></td><td>Transferred</td></tr> <tr><td><code>archived</code></td><td>archive (filled)</td><td><code>error</code>-muted / outline</td><td>Archived</td></tr> </tbody></table> </div> <ul> <li><strong>Flutter:</strong> <code>Badge</code>/custom <code>Container</code> + icon; <code>AppBadge</code> when icon+text too wide.</li> </ul> <h2 id="3-studentheader-detail"><a class="header" href="#3-studentheader-detail">3. StudentHeader (detail)</a></h2> <ul> <li><strong>Purpose:</strong> detail hero block (avatar, identity, class path, status, actions).</li> <li><strong>Props:</strong> <code>student</code>, <code>user</code>, <code>status</code>, <code>onEdit</code>, <code>onTransfer</code>, <code>onGraduate</code>, <code>onArchive</code>, <code>onRestore</code>, <code>onDelete</code>, <code>permissions</code> (hide actions without <code>student.update</code>/<code>student.delete</code>).</li> <li><strong>Layout:</strong> avatar 64 (<code>AppAvatar</code>), name <code>titleLarge</code>, admission number <code>mono</code>, <code>StatusBadge</code>, chips for class path (<code>Grade 6 · B · 2025-26</code>), campus/house when set; action row: <code>FilledButton.tonal</code> Edit/Transfer (≤ 2), rest in <code>AppMenu</code>.</li> <li><strong>Tokens:</strong> <code>surfaceContainerLow</code>, <code>e-1</code>, <code>r-lg</code>, padding 16.</li> <li><strong>Flutter:</strong> <code>Card.filled</code> + <code>Row</code>/<code>Wrap</code> (wraps on phone width).</li> </ul> <h2 id="4-academichistorytimeline"><a class="header" href="#4-academichistorytimeline">4. AcademicHistoryTimeline</a></h2> <ul> <li><strong>Purpose:</strong> enrollment history visualization (append-only trail, <code>COLLECTIONS.md:1674-1677</code>).</li> <li><strong>Props:</strong> <code>enrollments[]</code> (classId, academicYearId, rollNumber, joinedAt, leftAt, status) — already sorted <code>joinedAt desc</code> by <code>student.service.ts:281-287</code>.</li> <li><strong>Node:</strong> year + class label (<code>titleMedium</code>), <code>joinedAt → leftAt</code> dates (<code>bodySmall</code>, <code>Intl</code>-formatted), <code>EnrollmentStatus</code> badge; connector line <code>outlineVariant</code>; active node dot <code>primary</code> (pulsing <code>m-fast</code>), past nodes <code>onSurfaceVariant</code>.</li> <li><strong>States:</strong> empty → inline <code>AppEmptyState</code> compact ("No enrollment history yet — created students are auto-enrolled once").</li> <li><strong>A11y:</strong> timeline as list semantics; each node one row.</li> <li><strong>Flutter:</strong> custom <code>Column</code> of <code>Row</code>s with <code>Semantics</code>.</li> </ul> <h2 id="5-importreportcard"><a class="header" href="#5-importreportcard">5. ImportReportCard</a></h2> <ul> <li><strong>Purpose:</strong> bulk-import result summary (from <code>ImportReport</code> — <code>import-adapter.interface.ts:14-25</code>).</li> <li><strong>Props:</strong> <code>report {entity, totalRows, imported, failed, errors[]}</code>.</li> <li><strong>Layout:</strong> 3 <code>AppStatTile</code> (Imported / Failed / Total) + collapsible failed-rows list: "Row 42 — Missing required column "section"." (each error string verbatim from server).</li> <li><strong>States:</strong> all-imported (success tint), partial (warning tint), all-failed (error tint) — icons + text, not color alone.</li> <li><strong>A11y:</strong> live-region announces "997 of 1000 imported".</li> </ul> <h2 id="6-relationshipchip"><a class="header" href="#6-relationshipchip">6. RelationshipChip</a></h2> <ul> <li><strong>Purpose:</strong> parent-link relationship tag (enum values).</li> <li><strong>Props:</strong> <code>relationship</code> — mother | father | guardian | grandparent | relative | foster_parent (<code>student-parent-link.schema.ts:7-14</code>).</li> <li><strong>Rendering:</strong> <code>InputChip</code>-style with icon (mother = female icon, father = male, guardian = shield, grandparent = family, relative = link, foster_parent = home); text label capitalized.</li> <li><strong>Tokens:</strong> <code>surfaceVariant</code> fill, <code>labelMedium</code>.</li> </ul> <h2 id="7-guardianlinkcard"><a class="header" href="#7-guardianlinkcard">7. GuardianLinkCard</a></h2> <ul> <li><strong>Purpose:</strong> one linked parent (Parents tab).</li> <li><strong>Props:</strong> <code>link {parentId, relationship, isPrimaryGuardian, financialResponsibility, pickupAllowed, emergencyPriority}</code> + parent/user names + <code>onUnlink</code>, <code>onEditLink</code> <code>(planned)</code> — no link-update endpoint exists; edit = unlink + re-link.</li> <li><strong>Layout:</strong> <code>AppCard</code>: avatar + name (<code>titleMedium</code>), <code>RelationshipChip</code>, badge "Primary guardian" (filled <code>primaryContainer</code>) when <code>isPrimaryGuardian</code>, flag rows (financial responsibility ✓, pickup allowed ✓/✗, priority n) via <code>AppInfoRow</code>, trailing <code>AppMenu</code> (Unlink).</li> <li><strong>Empty:</strong> "No guardians linked — add a parent or guardian".</li> </ul> <h2 id="8-classselectcascade-wizard-helper"><a class="header" href="#8-classselectcascade-wizard-helper">8. ClassSelectCascade (wizard helper)</a></h2> <ul> <li><strong>Purpose:</strong> year → grade → section → class cascading pickers with capacity info.</li> <li><strong>Props:</strong> <code>onChanged(ClassSelection{academicYearId, gradeId, sectionId, classId})</code>, <code>initial?</code>, <code>loading</code>.</li> <li><strong>Sources</strong> (called through repository): <code>GET /academic-years</code>, <code>GET /grades</code>, <code>GET /sections/by-grade/:gradeId</code>, <code>GET /classes/by-year/:academicYearId</code> (and client-side grade+section filtering for class list when by-year is coarse — note <code>class.repository</code>/controller only index by year server-side; <code>academics/controllers/class.controller.ts:30-33</code>).</li> <li><strong>States:</strong> per-level loading skeleton (<code>AppSkeleton</code> line), empty level ("No classes yet for this year — set up Academics first").</li> <li><strong>Tokens:</strong> stacked <code>AppDropdown</code>s, gap 16.</li> </ul> <h2 id="9-documentuploadtile"><a class="header" href="#9-documentuploadtile">9. DocumentUploadTile</a></h2> <ul> <li><strong>Purpose:</strong> upload-in-progress/error attachment row.</li> <li><strong>Props:</strong> <code>fileName</code>, <code>size</code>, <code>progress?</code>, <code>error?</code>, <code>onRetry</code>, <code>onCancel</code>.</li> <li><strong>States:</strong> queued → uploading (determinate <code>LinearProgressIndicator</code>) → done (swap to <code>AppAttachmentTile</code>) → error (error text + Retry/Cancel).</li> <li><strong>A11y:</strong> progress announced via live region.</li> </ul> <h2 id="10-rosterfilterbar"><a class="header" href="#10-rosterfilterbar">10. RosterFilterBar</a></h2> <ul> <li><strong>Purpose:</strong> status/class/year filter chips + result count.</li> <li><strong>Props:</strong> <code>statusFilter</code>, <code>classFilter</code>, <code>yearFilter</code>, <code>onChanged</code>, <code>resultCount</code>, <code>loading</code>.</li> <li><strong>Behaviour:</strong> status chips fixed set (All/Active/Graduated/Archived); class/year are <code>AppDropdown</code> chips; count line <code>bodySmall</code>; <code>Clear</code> appears when any filter active. <strong>Client-side only</strong> until server filters land (OQ-8).</li> </ul> <hr /> <h2 id="component-reuse-map"><a class="header" href="#component-reuse-map">Component reuse map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Shared component (<a href="students/../00-shared/03_Component_Library.html">00-shared/03</a>)</th><th>Used by</th></tr></thead><tbody> <tr><td><code>AppStepper</code></td><td>Create wizard, import wizard</td></tr> <tr><td><code>AppAttachmentTile</code></td><td>Documents tab</td></tr> <tr><td><code>AppTabs</code></td><td>Student detail</td></tr> <tr><td><code>AppStatTile</code></td><td>Import report</td></tr> <tr><td><code>AppSlider</code></td><td>Emergency priority (link sheet)</td></tr> <tr><td><code>AppBottomSheet</code></td><td>Enroll, upload, link-parent</td></tr> <tr><td><code>AppDialog</code></td><td>Graduate/Archive/Restore/Delete/Transfer confirm</td></tr> <tr><td><code>AppMenu</code></td><td>Row + header overflow actions</td></tr> </tbody></table> </div> <h2 id="golden-test-expectation"><a class="header" href="#golden-test-expectation">Golden-test expectation</a></h2> <p>Each module component: phone/tablet/desktop + dark mode (<a href="students/../00-shared/03_Component_Library.html">00-shared/03 G</a>, <a href="students/../00-shared/10_QA_Baseline.html">00-shared/10 §9</a>).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-students-module"><a class="header" href="#08--form-specifications-students-module">08 — Form Specifications (Students Module)</a></h1> <ul> <li><a href="students/08_Form_Specifications.html#1-create-student-wizard-step-24--post-students">1. Create Student (wizard step 2–4 → <code>POST /students</code>)</a></li> <li><a href="students/08_Form_Specifications.html#2-enroll-post-studentsidenroll">2. Enroll (<code>POST /students/:id/enroll</code>)</a></li> <li><a href="students/08_Form_Specifications.html#3-transfer-post-studentsidtransfer">3. Transfer (<code>POST /students/:id/transfer</code>)</a></li> <li><a href="students/08_Form_Specifications.html#4-update-student-patch-studentsid">4. Update student (<code>PATCH /students/:id</code>)</a></li> <li><a href="students/08_Form_Specifications.html#5-upload-document-post-studentsiddocuments">5. Upload document (<code>POST /students/:id/documents</code>)</a></li> <li><a href="students/08_Form_Specifications.html#6-csv-bulk-import-post-bulkimportstudents">6. CSV bulk import (<code>POST /bulk/import/students</code>)</a></li> <li><a href="students/08_Form_Specifications.html#7-create-parent-post-parents">7. Create parent (<code>POST /parents</code>)</a></li> <li><a href="students/08_Form_Specifications.html#8-link-parent-post-parentslinkstudentid">8. Link parent (<code>POST /parents/link/:studentId</code>)</a></li> <li><a href="students/08_Form_Specifications.html#9-avatar-upload-post-usersidavatar">9. Avatar upload (<code>POST /users/:id/avatar</code>)</a></li> <li><a href="students/08_Form_Specifications.html#validation-ux-rules-all-forms">Validation UX rules (all forms)</a></li> </ul> <blockquote> <p>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. <code>*</code> = required by backend.</p> </blockquote> <hr /> <h2 id="1-create-student-wizard-step-24--post-students"><a class="header" href="#1-create-student-wizard-step-24--post-students">1. Create Student (wizard step 2–4 → <code>POST /students</code>)</a></h2> <p>Payload DTO: <code>create-student.dto.ts</code>. Identity step targets <code>POST /users</code> (<code>users.controller.ts:36-40</code>).</p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type / rules (server)</th><th>Source</th><th>Client input</th><th>Notes</th></tr></thead><tbody> <tr><td><code>userId</code>*</td><td>MongoId</td><td><code>create-student.dto.ts:6-7</code></td><td>hidden (from step 1 user)</td><td>must exist; user created/reused first</td></tr> <tr><td><code>admissionNumber</code>*</td><td>string</td><td><code>create-student.dto.ts:9-11</code></td><td><code>AppTextField</code> (uppercase, no spaces)</td><td>unique per tenant → 409 (<code>student.service.ts:56-62</code>); client pre-check against roster</td></tr> <tr><td><code>rollNumber?</code></td><td>string</td><td><code>create-student.dto.ts:13-16</code></td><td>text, mono</td><td></td></tr> <tr><td><code>academicYearId</code>*</td><td>MongoId</td><td><code>create-student.dto.ts:18-20</code></td><td><code>AppDropdown</code> (academic years)</td><td></td></tr> <tr><td><code>campusId?</code></td><td>MongoId</td><td><code>create-student.dto.ts:22-25</code></td><td><code>AppDropdown</code></td><td></td></tr> <tr><td><code>gradeId</code>*</td><td>MongoId</td><td><code>create-student.dto.ts:27-29</code></td><td><code>AppDropdown</code></td><td></td></tr> <tr><td><code>sectionId</code>*</td><td>MongoId</td><td><code>create-student.dto.ts:31-33</code></td><td><code>AppDropdown</code> (by grade)</td><td></td></tr> <tr><td><code>classId</code>*</td><td>MongoId</td><td><code>create-student.dto.ts:35-37</code></td><td><code>AppDropdown</code> (by grade+section+year)</td><td>auto-enrolled here (<code>student.service.ts:71-78</code>)</td></tr> <tr><td><code>houseId?</code></td><td>MongoId</td><td><code>create-student.dto.ts:39-42</code></td><td><code>AppDropdown</code></td><td></td></tr> <tr><td><code>admissionDate?</code></td><td>ISO date string</td><td><code>create-student.dto.ts:44-47</code></td><td><code>AppDatePicker</code> (≤ today)</td><td>omitted → today (<code>student.service.ts:66-68</code>)</td></tr> <tr><td><code>admissionType?</code></td><td>string enum <code>new|transfer|promoted</code></td><td><code>create-student.dto.ts:49-52</code>; <code>student.schema.ts:47</code></td><td>segmented buttons</td><td>free string accepted by DTO; UI constrains</td></tr> <tr><td><code>transportRequired?</code></td><td>boolean</td><td><code>create-student.dto.ts:54-56</code></td><td><code>AppSwitch</code></td><td>schema default false</td></tr> <tr><td><code>hostelRequired?</code></td><td>boolean</td><td><code>create-student.dto.ts:57-59</code></td><td><code>AppSwitch</code></td><td>schema default false</td></tr> <tr><td><code>medicalNotes?</code></td><td>string</td><td><code>create-student.dto.ts:61-65</code></td><td>multiline</td><td></td></tr> </tbody></table> </div> <p><strong>Identity step (<code>POST /users</code>)</strong> — user schema fields (<code>user.schema.ts:16-35</code>): <code>firstName</code>* (required), <code>middleName?</code>, <code>lastName</code>* (required), <code>displayName?</code>, <code>email</code>* (unique per tenant → 409; lowercase), <code>phone?</code>. UI submits only <code>firstName, lastName, email, phone?</code>.</p> <p>Server status on create: always <code>active</code> (<code>student.service.ts:65</code>), <code>admissionType</code> default <code>new</code>, enrollment <code>ACTIVE</code> with <code>joinedAt = now</code>.</p> <h2 id="2-enroll-post-studentsidenroll"><a class="header" href="#2-enroll-post-studentsidenroll">2. Enroll (<code>POST /students/:id/enroll</code>)</a></h2> <p>DTO: <code>enroll-student.dto.ts</code>.</p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type / rules</th><th>Source</th></tr></thead><tbody> <tr><td><code>classId</code>*</td><td>MongoId</td><td><code>enroll-student.dto.ts:5-7</code></td></tr> <tr><td><code>academicYearId</code>*</td><td>MongoId</td><td><code>enroll-student.dto.ts:9-11</code></td></tr> <tr><td><code>rollNumber?</code></td><td>string</td><td><code>enroll-student.dto.ts:13-16</code></td></tr> </tbody></table> </div> <p>Side effect banner: existing ACTIVE enrollments → <code>transferred</code> + <code>leftAt</code> (<code>student.service.ts:125-139</code>).</p> <h2 id="3-transfer-post-studentsidtransfer"><a class="header" href="#3-transfer-post-studentsidtransfer">3. Transfer (<code>POST /students/:id/transfer</code>)</a></h2> <p>DTO: <code>transfer-student.dto.ts</code>.</p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type / rules</th><th>Source</th><th>Client</th></tr></thead><tbody> <tr><td><code>classId</code>*</td><td>MongoId</td><td><code>transfer-student.dto.ts:5-7</code></td><td>dropdown</td></tr> <tr><td><code>academicYearId</code>*</td><td>MongoId</td><td><code>transfer-student.dto.ts:9-11</code></td><td>dropdown</td></tr> <tr><td><code>gradeId?</code></td><td>MongoId — "defaults to class grade"</td><td><code>transfer-student.dto.ts:13-19</code></td><td>dropdown, prefilled from class</td></tr> <tr><td><code>sectionId?</code></td><td>MongoId — "defaults to class section"</td><td><code>transfer-student.dto.ts:20-25</code></td><td>dropdown, prefilled from class</td></tr> <tr><td><code>rollNumber?</code></td><td>string</td><td><code>transfer-student.dto.ts:27-30</code></td><td>text</td></tr> </tbody></table> </div> <p>Guard: student status must be <code>active</code> (<code>student.service.ts:175-179</code>).</p> <h2 id="4-update-student-patch-studentsid"><a class="header" href="#4-update-student-patch-studentsid">4. Update student (<code>PATCH /students/:id</code>)</a></h2> <p>DTO: <code>update-student.dto.ts</code> — every field optional mirror of create plus: <code>status?</code> (plain string, <strong>no enum validation</strong> — <code>update-student.dto.ts:60-63</code>; UI must only send valid <code>StudentStatus</code> values), <code>metadata?</code> (object, <code>update-student.dto.ts:78-80</code>). Note: <code>PATCH</code> with <code>$set</code> writes only provided fields (<code>student.service.ts:144</code>). Client edit form = create fields minus class path (class changes go through transfer/enroll) plus <code>status</code> only via lifecycle actions, not the form.</p> <h2 id="5-upload-document-post-studentsiddocuments"><a class="header" href="#5-upload-document-post-studentsiddocuments">5. Upload document (<code>POST /students/:id/documents</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Part</th><th>Type</th><th>Source</th></tr></thead><tbody> <tr><td><code>file</code>*</td><td>multipart file — field name exactly <code>file</code></td><td><code>student.controller.ts:83</code>; <code>MulterFile{buffer,originalname,mimetype,size}</code></td></tr> <tr><td><code>category?</code></td><td>string (body, multipart text field)</td><td><code>upload-student-document.dto.ts:4-9</code></td></tr> </tbody></table> </div> <p>Server has <strong>no size/mime limits</strong> (OQ-9). Client <code>(proposed)</code> limits: ≤ 10 MB, mime ∈ {pdf, jpg, png, webp}. <code>uploadedBy</code> = actor userId (<code>student.service.ts:269</code>).</p> <h2 id="6-csv-bulk-import-post-bulkimportstudents"><a class="header" href="#6-csv-bulk-import-post-bulkimportstudents">6. CSV bulk import (<code>POST /bulk/import/students</code>)</a></h2> <p><strong>Adapter columns</strong> (<code>students-import.adapter.ts:17-26</code>):</p> <div class="table-wrapper"><table><thead><tr><th>CSV column</th><th>Required</th><th>Validation / resolution</th><th>Error string (exact)</th></tr></thead><tbody> <tr><td><code>firstName</code></td><td>✓</td><td>passed to user create</td><td>"Missing required column "firstName"."</td></tr> <tr><td><code>lastName</code></td><td>✓</td><td>passed to user create</td><td>"Missing required column "lastName"."</td></tr> <tr><td><code>email</code></td><td>✓</td><td>regex <code>^[^\s@]+@[^\s@]+\.[^\s@]+$</code> + uniqueness</td><td>"Invalid email format." / "Email "{email}" already registered."</td></tr> <tr><td><code>admissionNumber</code></td><td>✓</td><td>tenant-unique</td><td>"Missing required column "admissionNumber"." / "Admission number "{n}" already exists."</td></tr> <tr><td><code>grade</code></td><td>✓</td><td>matched by <code>name</code> <strong>or</strong> <code>code</code> (<code>students-import.adapter.ts:118-120</code>)</td><td>"Grade "{g}" not found."</td></tr> <tr><td><code>section</code></td><td>✓</td><td>matched by <code>name</code></td><td>"Section "{s}" not found."</td></tr> <tr><td><code>academicYear</code></td><td>✓</td><td>matched by <code>name</code></td><td>"Academic year "{y}" not found."</td></tr> <tr><td><code>rollNumber</code></td><td>—</td><td>passthrough</td><td>—</td></tr> </tbody></table> </div> <p>Derived resolution: class = first class matching <code>{gradeId, sectionId, academicYearId}</code> (<code>students-import.adapter.ts:131-141</code>) — error "No class found for grade "{g}" section "{s}".".</p> <p><strong>Parsing rules</strong> (<code>bulk-import.service.ts:26-35</code>): header row required (first row = column names), <code>skip_empty_lines</code>, <code>trim</code>, <code>columns: true</code>. Empty file → 400 "CSV must include a header row and data." Malformed → 400 "Malformed CSV: could not parse file." Row numbering: <code>index + 2</code> (header = row 1).</p> <p><strong>Export template</strong> (<code>GET /bulk/export/students</code>, <code>students-import.adapter.ts:87-98</code>): columns <code>admissionNumber, rollNumber, status, admissionDate</code> — sorted by <code>admissionNumber</code> asc. This is an <strong>export</strong> format, not an import template — flagged in UI (import columns differ).</p> <h2 id="7-create-parent-post-parents"><a class="header" href="#7-create-parent-post-parents">7. Create parent (<code>POST /parents</code>)</a></h2> <p>DTO: <code>create-parent.dto.ts</code>.</p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type / rules</th><th>Source</th></tr></thead><tbody> <tr><td><code>userId</code>*</td><td>MongoId</td><td><code>create-parent.dto.ts:5-7</code></td></tr> <tr><td><code>occupation?</code></td><td>string</td><td><code>create-parent.dto.ts:9-12</code></td></tr> <tr><td><code>company?</code></td><td>string</td><td><code>create-parent.dto.ts:13-16</code></td></tr> <tr><td><code>annualIncome?</code></td><td>number</td><td><code>create-parent.dto.ts:17-20</code></td></tr> <tr><td><code>relationshipNotes?</code></td><td>string</td><td><code>create-parent.dto.ts:21-24</code></td></tr> <tr><td><code>emergencyContactPriority?</code></td><td>number</td><td><code>create-parent.dto.ts:25-28</code></td></tr> <tr><td><code>pickupAuthorization?</code></td><td>boolean</td><td><code>create-parent.dto.ts:29-32</code></td></tr> </tbody></table> </div> <p>Guard: one parent profile per <code>userId</code> → 409 "Parent profile already exists for this user." (<code>parent.service.ts:30-34</code>).</p> <h2 id="8-link-parent-post-parentslinkstudentid"><a class="header" href="#8-link-parent-post-parentslinkstudentid">8. Link parent (<code>POST /parents/link/:studentId</code>)</a></h2> <p>DTO: <code>link-parent.dto.ts</code>.</p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type / rules</th><th>Source</th></tr></thead><tbody> <tr><td><code>parentId</code>*</td><td>MongoId</td><td><code>link-parent.dto.ts:5-7</code></td></tr> <tr><td><code>relationship</code>*</td><td>string enum: mother|father|guardian|grandparent|relative|foster_parent</td><td><code>link-parent.dto.ts:9-20</code>; <code>student-parent-link.schema.ts:7-14</code></td></tr> <tr><td><code>isPrimaryGuardian?</code></td><td>boolean (schema default false)</td><td><code>link-parent.dto.ts:22-24</code></td></tr> <tr><td><code>financialResponsibility?</code></td><td>boolean (default false)</td><td><code>link-parent.dto.ts:26-28</code></td></tr> <tr><td><code>pickupAllowed?</code></td><td>boolean (schema default <strong>true</strong>)</td><td><code>link-parent.dto.ts:30-32</code></td></tr> <tr><td><code>emergencyPriority?</code></td><td>number (schema default 0)</td><td><code>link-parent.dto.ts:34-36</code></td></tr> </tbody></table> </div> <p>Student existence checked (<code>student-parent-link.service.ts:27</code>); parent existence <strong>not</strong> checked (OQ-11).</p> <h2 id="9-avatar-upload-post-usersidavatar"><a class="header" href="#9-avatar-upload-post-usersidavatar">9. Avatar upload (<code>POST /users/:id/avatar</code>)</a></h2> <p>Multipart field <code>file</code> (<code>users.controller.ts:96</code>); updates <code>avatarFileId</code> on the user doc (<code>user.schema.ts:34-35</code>).</p> <hr /> <h2 id="validation-ux-rules-all-forms"><a class="header" href="#validation-ux-rules-all-forms">Validation UX rules (all forms)</a></h2> <ul> <li>Run <code>validator</code> on submit + after first error; clear on edit (<a href="students/../00-shared/03_Component_Library.html">00-shared/03 B</a>).</li> <li>400 <code>VALIDATION_ERROR</code> → map <code>details[].field</code> to fields (<a href="students/../00-shared/06_State_Management.html">00-shared/06 §5</a>); focus first invalid (<a href="students/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §10</a>).</li> <li>409 → inline conflict with suggested action (search existing / change admission number).</li> <li>Submit buttons: disabled while pending (anti-double-submit, <a href="students/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §6</a>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-students-module"><a class="header" href="#09--user-behaviour-students-module">09 — User Behaviour (Students Module)</a></h1> <ul> <li><a href="students/09_User_Behaviour.html#1-roster-list">1. Roster list</a></li> <li><a href="students/09_User_Behaviour.html#2-create-wizard">2. Create wizard</a></li> <li><a href="students/09_User_Behaviour.html#3-transfer--enroll">3. Transfer / Enroll</a></li> <li><a href="students/09_User_Behaviour.html#4-documents">4. Documents</a></li> <li><a href="students/09_User_Behaviour.html#5-bulk-import">5. Bulk import</a></li> <li><a href="students/09_User_Behaviour.html#6-parent-linking">6. Parent linking</a></li> <li><a href="students/09_User_Behaviour.html#7-lifecycle-actions-graduatearchiverestoredelete">7. Lifecycle actions (graduate/archive/restore/delete)</a></li> <li><a href="students/09_User_Behaviour.html#8-offline--connectivity">8. Offline & connectivity</a></li> <li><a href="students/09_User_Behaviour.html#9-permission-driven-behaviour-client">9. Permission-driven behaviour (client)</a></li> </ul> <blockquote> <p>Behavioural rules per screen: defaults, expectations, friction points and mitigations. All server behaviour cited is exact (<code>student.service.ts</code>, <code>bulk-import.service.ts</code>). Anything marked <code>(planned)</code>/<code>(proposed)</code> is flagged.</p> </blockquote> <hr /> <h2 id="1-roster-list"><a class="header" href="#1-roster-list">1. Roster list</a></h2> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Detail</th></tr></thead><tbody> <tr><td>Default view</td><td>page 1, limit 20 (<code>pagination-query.dto.ts:11-19</code>); client shows status "All"</td></tr> <tr><td>Ordering</td><td>server returns natural order (no sort applied — <code>student.service.ts:104-108</code>); client sorts loaded page by name/admission number when user picks a sort chip</td></tr> <tr><td>Search expectation</td><td>user expects server-wide search (<code>q</code>); today client filters loaded pages only → <strong>surface honesty</strong>: helper text "Searching current results — full search coming soon" <code>(planned)</code> (OQ-2)</td></tr> <tr><td>Refresh</td><td><code>RefreshIndicator</code> reloads page 1; infinite scroll appends via <code>meta.hasNext</code></td></tr> <tr><td>Returning from detail</td><td>list refreshes silently in background (stale-while-revalidate)</td></tr> <tr><td>Long session</td><td>statuses can change elsewhere (another admin) — pull-to-refresh is the contract; WS roster updates <code>(forward-looking)</code></td></tr> </tbody></table> </div> <h2 id="2-create-wizard"><a class="header" href="#2-create-wizard">2. Create wizard</a></h2> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Detail</th></tr></thead><tbody> <tr><td>Completion expectation</td><td>"created + enrolled in one action" — exact server semantics (<code>student.service.ts:55-93</code>); tell the user upfront on step 4</td></tr> <tr><td>Identity discovery</td><td>reuse-existing-user is the happy path for re-admissions; new-user only when not found. 409 email → auto-suggest search</td></tr> <tr><td>Admission number</td><td>suggest pattern <code>ADM{YYYY}{seq}</code> (example in DTO <code>create-student.dto.ts:9</code>); client caps length; uniqueness pre-checked + server-verified</td></tr> <tr><td>Abandonment</td><td>no draft persistence (YAGNI); warn on back with unsaved changes via system back-interception</td></tr> <tr><td>Double submit</td><td>CTA disabled while pending (<a href="students/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §6</a>)</td></tr> </tbody></table> </div> <h2 id="3-transfer--enroll"><a class="header" href="#3-transfer--enroll">3. Transfer / Enroll</a></h2> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Detail</th></tr></thead><tbody> <tr><td>Expectation</td><td>"history preserved, current enrollment closed" — banner before submit (<code>student.service.ts:125-131</code>)</td></tr> <tr><td>Same-class guard</td><td>client blocks identical <code>classId</code>+<code>academicYearId</code> (no server guard — server creates a duplicate enrollment; OQ-13)</td></tr> <tr><td>Non-active student</td><td>transfer form replaced by read-only status banner (server 409 mirror, <code>student.service.ts:175-179</code>)</td></tr> <tr><td>After success</td><td>navigate to history tab; new ACTIVE node on top, old node <code>transferred</code> with <code>leftAt</code></td></tr> </tbody></table> </div> <h2 id="4-documents"><a class="header" href="#4-documents">4. Documents</a></h2> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Detail</th></tr></thead><tbody> <tr><td>Expectation</td><td>uploaded file is instantly part of the student record — true (doc created synchronously)</td></tr> <tr><td>Category</td><td>free text (no server enum); suggest chips but allow custom</td></tr> <tr><td>Preview</td><td><strong>not implemented</strong> server-side (only <code>fileId</code> metadata; OQ-9) — do not show a dead preview button; show "Open file" only when a serving endpoint exists <code>(planned)</code></td></tr> <tr><td>Ordering</td><td>newest first (<code>student.service.ts:277-278</code>) — matches "just uploaded on top"</td></tr> </tbody></table> </div> <h2 id="5-bulk-import"><a class="header" href="#5-bulk-import">5. Bulk import</a></h2> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Detail</th></tr></thead><tbody> <tr><td>Expectation</td><td>"big batch, one report" — synchronous loop server-side (<code>bulk-import.service.ts:45-63</code>); 1000 rows can take a while (OQ-12) → progress step must not pretend real progress; use indeterminate + row-count readout</td></tr> <tr><td>Re-import</td><td>user re-uploads the same file to retry failed rows — dedup by admission number makes fixed rows import cleanly (server checks each row)</td></tr> <tr><td>Template mismatch</td><td>export CSV ≠ import CSV columns (see <a href="students/08_Form_Specifications.html">08 §6</a>) — the UI must present the <strong>import</strong> column list, not the export file</td></tr> <tr><td>Partial success</td><td>normal; report card shows imported/failed/total; never an error page</td></tr> </tbody></table> </div> <h2 id="6-parent-linking"><a class="header" href="#6-parent-linking">6. Parent linking</a></h2> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Detail</th></tr></thead><tbody> <tr><td>Expectation</td><td>"link the guardian who should get calls and fee messages" — flags map to <code>isPrimaryGuardian</code>, <code>financialResponsibility</code>, <code>pickupAllowed</code>, <code>emergencyPriority</code> (<code>link-parent.dto.ts</code>)</td></tr> <tr><td>Duplicate parent user</td><td>409 → reuse existing parent instead of creating</td></tr> <tr><td>Unlink</td><td>soft delete (<code>student-parent-link.service.ts:38-41</code>); history of links is not shown after unlink (deleted rows excluded by <code>scopedFilter</code>)</td></tr> <tr><td>Edit link</td><td><strong>no PATCH endpoint</strong> — edit = unlink + relink; tell the user ("changes require re-linking")</td></tr> </tbody></table> </div> <h2 id="7-lifecycle-actions-graduatearchiverestoredelete"><a class="header" href="#7-lifecycle-actions-graduatearchiverestoredelete">7. Lifecycle actions (graduate/archive/restore/delete)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Detail</th></tr></thead><tbody> <tr><td>Graduate</td><td>terminal in UI (no un-graduate endpoint; OQ-5) — copy must say so</td></tr> <tr><td>Archive</td><td>reversible; archived students hidden from default roster (client filter)</td></tr> <tr><td>Restore</td><td>returns to <code>active</code> (no restoration to <code>graduated</code>/<code>transferred</code>)</td></tr> <tr><td>Delete</td><td>soft delete — "removed from all lists, history retained"; <strong>no restore UI</strong> (no restore endpoint for soft-deleted; OQ-7)</td></tr> <tr><td>Idempotent taps</td><td>double-tap same action → server 409 (already graduated/archived/active) → snackbar + state refresh, no crash (<code>student.service.ts:207-209,228-230</code>)</td></tr> </tbody></table> </div> <h2 id="8-offline--connectivity-1"><a class="header" href="#8-offline--connectivity-1">8. Offline & connectivity</a></h2> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Detail</th></tr></thead><tbody> <tr><td>Reads</td><td>cached last-good roster + detail; <code>AppOfflineBanner</code> (<a href="students/../00-shared/06_State_Management.html">00-shared/06 §3.7</a>)</td></tr> <tr><td>Writes</td><td>all blocked (create/enroll/transfer/upload/import/link are server-first, no offline queue defined) — guidance snackbar</td></tr> <tr><td>Upload interrupted</td><td><code>DocumentUploadTile</code> error state with Retry (no chunked resume — <a href="students/../00-shared/12_Assumptions_&_Open_Questions.html">00-shared/12 B7</a>)</td></tr> <tr><td>Import offline</td><td>blocked before file pick</td></tr> </tbody></table> </div> <h2 id="9-permission-driven-behaviour-client"><a class="header" href="#9-permission-driven-behaviour-client">9. Permission-driven behaviour (client)</a></h2> <ul> <li>Actions hidden when permission missing (<a href="students/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §9</a>): create FAB needs <code>student.create</code>; edit/transfer/graduate/archive/restore need <code>student.update</code>; delete needs <code>student.delete</code>; upload needs <code>file.upload</code>; import/export <code>(planned)</code> gate — no <code>student.import</code> constant (OQ-4).</li> <li>Server currently authorizes by JWT only (<code>student.controller.ts:32-35</code>) — a 403 can still appear later; treat via standard error mapping (<a href="students/../00-shared/06_State_Management.html">00-shared/06 §5</a>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-students-module"><a class="header" href="#10--interaction-specification-students-module">10 — Interaction Specification (Students Module)</a></h1> <ul> <li><a href="students/10_Interaction_Specification.html#1-gestures--inputs-phonetablet">1. Gestures & inputs (phone/tablet)</a></li> <li><a href="students/10_Interaction_Specification.html#2-desktopkeyboard">2. Desktop/keyboard</a></li> <li><a href="students/10_Interaction_Specification.html#3-motion-choreography">3. Motion choreography</a></li> <li><a href="students/10_Interaction_Specification.html#4-micro-interactions">4. Micro-interactions</a></li> <li><a href="students/10_Interaction_Specification.html#5-frustration-controls">5. Frustration controls</a></li> <li><a href="students/10_Interaction_Specification.html#6-haptics-map-mobile-off-on-webdesktop-and-when-system-disabled">6. Haptics map (mobile; off on web/desktop and when system disabled)</a></li> <li><a href="students/10_Interaction_Specification.html#7-realtime-interplay">7. Realtime interplay</a></li> </ul> <blockquote> <p>Interaction details on top of the global conventions (<a href="students/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>). Motion tokens from <a href="students/../00-shared/02_Design_Tokens.html">00-shared/02 §6</a>; haptics per <a href="students/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §3</a>.</p> </blockquote> <hr /> <h2 id="1-gestures--inputs-phonetablet"><a class="header" href="#1-gestures--inputs-phonetablet">1. Gestures & inputs (phone/tablet)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Gesture</th><th>Behaviour</th><th>Screen</th></tr></thead><tbody> <tr><td>Tap row</td><td>open detail (Hero flight on avatar, <code>m-slow</code>)</td><td>Roster</td></tr> <tr><td>Long-press row</td><td><code>AppMenu</code> (same as trailing ⋮)</td><td>Roster</td></tr> <tr><td>Swipe left (phone)</td><td><strong>none</strong> — roster rows keep menu only (avoid accidental archive)</td><td>Roster</td></tr> <tr><td>Pull down</td><td>refresh page 1 + reset infinite scroll (<code>RefreshIndicator</code>, <code>lightImpact</code> on success)</td><td>Roster, Documents, History, Parents</td></tr> <tr><td>Scroll down/up</td><td>FAB hides on scroll-down, reappears scroll-up (<a href="students/../00-shared/03_Component_Library.html">00-shared/03 D</a>)</td><td>Roster</td></tr> <tr><td>Tab switch</td><td>indicator slide <code>m-base</code>; content cross-fade <code>m-slow</code></td><td>Detail</td></tr> <tr><td>Back on wizard</td><td>step back; at step 1 → leave prompt if dirty</td><td>Create/Import</td></tr> <tr><td>Sheet dismiss</td><td>drag handle + barrier; state preserved on dismiss</td><td>Enroll/Upload/Link</td></tr> </tbody></table> </div> <h2 id="2-desktopkeyboard"><a class="header" href="#2-desktopkeyboard">2. Desktop/keyboard</a></h2> <div class="table-wrapper"><table><thead><tr><th>Input</th><th>Behaviour</th></tr></thead><tbody> <tr><td><code>Ctrl+F</code></td><td>focus roster search</td></tr> <tr><td><code>N</code></td><td>open create wizard (roster)</td></tr> <tr><td><code>Esc</code></td><td>close sheets/menus/clear search; step back in wizards</td></tr> <tr><td><code>Enter</code></td><td>submit focused form field chain</td></tr> <tr><td>Right-click row</td><td>context menu = long-press menu</td></tr> <tr><td>Hover row</td><td>2% surface tint <code>m-fast</code>; tooltip on truncated names</td></tr> <tr><td>Arrows</td><td>navigate dropdown options; timeline focusable nodes</td></tr> </tbody></table> </div> <h2 id="3-motion-choreography"><a class="header" href="#3-motion-choreography">3. Motion choreography</a></h2> <div class="table-wrapper"><table><thead><tr><th>Moment</th><th>Motion</th></tr></thead><tbody> <tr><td>List → detail</td><td>Hero avatar <code>m-slow</code>; page slide <code>m-base</code></td></tr> <tr><td>Status badge change</td><td><code>AnimatedSwitcher</code> icon morph <code>m-fast</code> + scale pop on badge</td></tr> <tr><td>New document tile</td><td>entrance <code>m-entrance</code> (stagger 40 ms with list)</td></tr> <tr><td>Import report numbers</td><td>count-up animation <code>m-slow</code> (reduced-motion: static)</td></tr> <tr><td>Upload progress</td><td>determinate <code>LinearProgressIndicator</code> <code>m-instant</code> color shifts</td></tr> <tr><td>Snackbar confirmations</td><td>success < 3 s, error ≥ 4 s, slide <code>m-base</code> (<a href="students/../00-shared/03_Component_Library.html">00-shared/03 A</a>)</td></tr> <tr><td>Skeleton → content</td><td>cross-fade <code>m-slow</code>, never pop-in (<a href="students/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §4</a>)</td></tr> <tr><td>Reduced motion</td><td>all of the above → fades ≤ <code>m-instant</code>; no count-up, no stagger, no shimmer</td></tr> </tbody></table> </div> <h2 id="4-micro-interactions"><a class="header" href="#4-micro-interactions">4. Micro-interactions</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Detail</th></tr></thead><tbody> <tr><td>Filter chip toggle</td><td>selected = <code>secondaryContainer</code> fill, <code>m-fast</code></td></tr> <tr><td>Search</td><td>300 ms debounce; clear button restores; results count live-region</td></tr> <tr><td>Submit buttons</td><td>spinner replaces label; success → check + snackbar (<a href="students/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §5</a>)</td></tr> <tr><td>Row tap</td><td>ink ripple + 40 ms press feedback</td></tr> <tr><td>Timeline node</td><td>active node dot pulses <code>m-fast</code>; past nodes static</td></tr> <tr><td>Unlink guardian</td><td>destructive confirm <code>heavyImpact</code>, then row removal on server 200 only (not optimistic — server-first)</td></tr> <tr><td>Lifecycle actions</td><td>confirm dialog <code>m-base</code>; destructive label <code>error</code>; <code>heavyImpact</code> on confirm</td></tr> </tbody></table> </div> <h2 id="5-frustration-controls"><a class="header" href="#5-frustration-controls">5. Frustration controls</a></h2> <ul> <li>Every network action shows pending state (button spinner / skeleton) — never a silent wait (<a href="students/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §6</a>).</li> <li>Repeated taps disabled while pending everywhere (create/enroll/transfer/upload/ import/link).</li> <li>429 on any action → countdown + disabled CTA (<a href="students/../00-shared/06_State_Management.html">00-shared/06 §5</a>).</li> <li>Upload retry: error tile keeps file bytes in memory, retry re-posts; cancel discards.</li> </ul> <h2 id="6-haptics-map-mobile-off-on-webdesktop-and-when-system-disabled"><a class="header" href="#6-haptics-map-mobile-off-on-webdesktop-and-when-system-disabled">6. Haptics map (mobile; off on web/desktop and when system disabled)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Haptic</th></tr></thead><tbody> <tr><td>Create/enroll/transfer success</td><td><code>lightImpact</code></td></tr> <tr><td>Server 409/422 on lifecycle action</td><td><code>mediumImpact</code></td></tr> <tr><td>Long-press menu</td><td><code>selectionClick</code></td></tr> <tr><td>Destructive confirm (delete/graduate/archive/unlink)</td><td><code>heavyImpact</code></td></tr> <tr><td>Pull-to-refresh complete</td><td><code>lightImpact</code></td></tr> </tbody></table> </div> <h2 id="7-realtime-interplay"><a class="header" href="#7-realtime-interplay">7. Realtime interplay</a></h2> <ul> <li>No WS topics for students today (<a href="students/../00-shared/07_API_Conventions.html">00-shared/07 §8</a> topics list has none for students). Roster freshness = pull-to-refresh + re-fetch on screen focus. WS <code>roster.changed</code> topic <code>(forward-looking)</code>.</li> <li>In-app notifications (<code>StudentCreated</code> → <code>in-app</code> <code>student-enrolled</code>, <code>event-queue-map.ts:28</code>) arrive to the actor; tapping a student notification → deep link <code>/students/:id</code> (<code>studylyon://students/:id</code>) <code>(proposed)</code>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-students-module"><a class="header" href="#11--design-system-mapping-students-module">11 — Design System Mapping (Students Module)</a></h1> <ul> <li><a href="students/11_Design_System_Mapping.html#1-module-colour-usage">1. Module colour usage</a></li> <li><a href="students/11_Design_System_Mapping.html#2-typography-mapping-module">2. Typography mapping (module)</a></li> <li><a href="students/11_Design_System_Mapping.html#3-spacing--layout-tokens-module">3. Spacing & layout tokens (module)</a></li> <li><a href="students/11_Design_System_Mapping.html#4-radius--elevation">4. Radius & elevation</a></li> <li><a href="students/11_Design_System_Mapping.html#5-icons-material-symbols-outlined-status--filled">5. Icons (Material Symbols, outlined; status = filled)</a></li> <li><a href="students/11_Design_System_Mapping.html#6-motion-mapping-module">6. Motion mapping (module)</a></li> <li><a href="students/11_Design_System_Mapping.html#7-widget--token-additions-module">7. Widget → token additions (module)</a></li> <li><a href="students/11_Design_System_Mapping.html#8-dark-mode-notes-module">8. Dark-mode notes (module)</a></li> </ul> <blockquote> <p>Module-specific extensions to the shared mapping (<a href="students/../00-shared/04_Design_System_Mapping.html">00-shared/04</a>). Tokens from <a href="students/../00-shared/02_Design_Tokens.html">00-shared/02</a>; theming rules enforced (<a href="students/../00-shared/04_Design_System_Mapping.html">00-shared/04 §7</a>): no literal colors, spacing, or durations in widget code.</p> </blockquote> <hr /> <h2 id="1-module-colour-usage"><a class="header" href="#1-module-colour-usage">1. Module colour usage</a></h2> <div class="table-wrapper"><table><thead><tr><th>Use</th><th>Light token</th><th>Dark token</th><th>Component</th></tr></thead><tbody> <tr><td>Status <code>active</code> badge</td><td><code>secondary</code>/<code>secondaryContainer</code></td><td>same roles</td><td><code>StatusBadge</code></td></tr> <tr><td>Status <code>graduated</code> badge</td><td><code>tertiary</code>/<code>tertiaryContainer</code></td><td>same</td><td><code>StatusBadge</code></td></tr> <tr><td>Status <code>archived</code> badge</td><td><code>onSurfaceVariant</code> on <code>surfaceVariant</code> + <code>error</code> icon</td><td>same</td><td><code>StatusBadge</code></td></tr> <tr><td>Destructive actions (delete/graduate/unlink)</td><td><code>error</code></td><td><code>error</code></td><td><code>AppButton.destructive</code>, dialogs</td></tr> <tr><td>Primary guardian badge</td><td><code>primaryContainer</code>/<code>onPrimaryContainer</code></td><td>same</td><td><code>GuardianLinkCard</code></td></tr> <tr><td>Active timeline node</td><td><code>primary</code> (pulse)</td><td><code>primary</code></td><td><code>AcademicHistoryTimeline</code></td></tr> <tr><td>Import partial-failure banner</td><td><code>tertiaryContainer</code></td><td>same</td><td><code>ImportReportCard</code></td></tr> <tr><td>Filter chip selected</td><td><code>secondaryContainer</code></td><td>same</td><td><code>RosterFilterBar</code></td></tr> <tr><td>Admission number / roll number / IDs</td><td><code>mono</code> type token</td><td>same</td><td>list tiles, headers, forms</td></tr> <tr><td>Warning banner (enrollment deactivation)</td><td><code>tertiaryContainer</code></td><td>same</td><td>transfer/enroll forms</td></tr> </tbody></table> </div> <p>Status is <strong>never color-only</strong> — always icon + text (<a href="students/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §9</a>).</p> <h2 id="2-typography-mapping-module"><a class="header" href="#2-typography-mapping-module">2. Typography mapping (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Token</th><th>Where</th></tr></thead><tbody> <tr><td><code>headlineMedium</code></td><td>Roster screen title (AppBar <code>titleLarge</code> on detail), empty-state headlines</td></tr> <tr><td><code>titleLarge</code></td><td>Detail header student name, import report headline</td></tr> <tr><td><code>titleMedium</code></td><td>List tile names, timeline year/class, section headers</td></tr> <tr><td><code>bodyMedium</code></td><td>Form labels, info rows, banner bodies</td></tr> <tr><td><code>bodySmall</code></td><td>Timestamps, file sizes, result-count line, helper texts</td></tr> <tr><td><code>labelLarge</code></td><td>Tab labels, buttons, chips</td></tr> <tr><td><code>labelMedium</code></td><td>Badges, relationship chips</td></tr> <tr><td><code>mono</code></td><td><code>ADM-…</code>, roll numbers, metadata JSON</td></tr> </tbody></table> </div> <p>Numerals (priorities, counts, capacities) use <code>tabularFigures()</code> (<a href="students/../00-shared/02_Design_Tokens.html">00-shared/02 §2</a>).</p> <h2 id="3-spacing--layout-tokens-module"><a class="header" href="#3-spacing--layout-tokens-module">3. Spacing & layout tokens (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Token</th><th>Value</th><th>Use</th></tr></thead><tbody> <tr><td>Screen padding</td><td>16 / 24 (tablet+)</td><td>all module screens</td></tr> <tr><td>Card padding</td><td>16</td><td>header, guardian, document tiles</td></tr> <tr><td>Card gap</td><td>12</td><td>tab content stacks</td></tr> <tr><td>Section gap</td><td>24</td><td>profile tab sections</td></tr> <tr><td>List row height</td><td>≥ 56 (roster 72 with avatar+meta)</td><td>tiles</td></tr> <tr><td>Form field gap</td><td>16</td><td>all forms</td></tr> <tr><td>Wizard step gap</td><td>24</td><td><code>AppStepper</code></td></tr> <tr><td>Master-detail list width</td><td>320–400 dp</td><td>desktop ≥ 1200</td></tr> </tbody></table> </div> <h2 id="4-radius--elevation"><a class="header" href="#4-radius--elevation">4. Radius & elevation</a></h2> <div class="table-wrapper"><table><thead><tr><th>Token</th><th>Use</th></tr></thead><tbody> <tr><td><code>r-lg</code> 12</td><td>header card, guardian card, import report card</td></tr> <tr><td><code>r-md</code> 8</td><td>inputs, buttons, attachment tiles</td></tr> <tr><td><code>r-full</code></td><td>avatars, status dot</td></tr> <tr><td><code>e-0</code></td><td>roster rows (flat)</td></tr> <tr><td><code>e-1</code></td><td>header card, cards, sticky search bar</td></tr> <tr><td><code>e-2</code></td><td>sticky filter bar (desktop), FAB</td></tr> <tr><td><code>e-4</code></td><td>dialogs (graduate/archive/delete)</td></tr> <tr><td><code>e-5</code></td><td>sheets (enroll/upload/link)</td></tr> </tbody></table> </div> <h2 id="5-icons-material-symbols-outlined-status--filled"><a class="header" href="#5-icons-material-symbols-outlined-status--filled">5. Icons (Material Symbols, outlined; status = filled)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Icon</th><th>Use</th></tr></thead><tbody> <tr><td><code>group</code> / <code>person_add</code></td><td>roster empty state / create</td></tr> <tr><td><code>search</code></td><td>roster search</td></tr> <tr><td><code>filter_alt</code></td><td>filter bar</td></tr> <tr><td><code>upload_file</code> / <code>description</code> / <code>picture_as_pdf</code></td><td>documents</td></tr> <tr><td><code>swap_horiz</code></td><td>transfer</td></tr> <tr><td><code>school</code></td><td>graduate badge</td></tr> <tr><td><code>archive</code> / <code>unarchive</code></td><td>archive / restore</td></tr> <tr><td><code>delete</code></td><td>delete</td></tr> <tr><td><code>family_restroom</code> / <code>shield</code></td><td>parent link sheet</td></tr> <tr><td><code>check_circle</code> / <code>cancel</code></td><td>active / blocked states</td></tr> </tbody></table> </div> <h2 id="6-motion-mapping-module"><a class="header" href="#6-motion-mapping-module">6. Motion mapping (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Use</th><th>Token</th><th>Flutter</th></tr></thead><tbody> <tr><td>List → detail</td><td><code>m-slow</code></td><td><code>Hero</code> avatar + page transition</td></tr> <tr><td>Tab content</td><td><code>m-slow</code></td><td><code>AnimatedSwitcher</code>/TabBarView default</td></tr> <tr><td>Badge morph</td><td><code>m-fast</code></td><td><code>AnimatedSwitcher</code></td></tr> <tr><td>List entrance</td><td><code>m-entrance</code> 40 ms stagger</td><td><code>TweenAnimationBuilder</code></td></tr> <tr><td>Skeleton → content</td><td><code>m-slow</code></td><td><code>AnimatedOpacity</code> cross-fade</td></tr> <tr><td>Sheet/dialog</td><td><code>m-base</code></td><td>platform defaults</td></tr> </tbody></table> </div> <h2 id="7-widget--token-additions-module"><a class="header" href="#7-widget--token-additions-module">7. Widget → token additions (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Widget</th><th>Token mapping</th></tr></thead><tbody> <tr><td><code>StudentListTile</code></td><td><code>ListTile</code> + <code>surface</code> + <code>outlineVariant</code> divider (desktop)</td></tr> <tr><td><code>StatusBadge</code></td><td><code>Badge</code>/custom <code>Container</code> role fills + filled icon</td></tr> <tr><td><code>ClassSelectCascade</code></td><td><code>AppDropdown</code>s, focus ring <code>primary</code></td></tr> <tr><td><code>ImportReportCard</code></td><td><code>Card.filled</code> + <code>AppStatTile</code></td></tr> <tr><td><code>AcademicHistoryTimeline</code></td><td>custom, <code>outlineVariant</code> connectors</td></tr> <tr><td><code>DocumentUploadTile</code></td><td><code>surfaceContainerLow</code> + <code>LinearProgressIndicator</code> <code>primary</code></td></tr> </tbody></table> </div> <h2 id="8-dark-mode-notes-module"><a class="header" href="#8-dark-mode-notes-module">8. Dark-mode notes (module)</a></h2> <ul> <li>Roster rows: <code>surface</code>; dark elevation substitutes for borders (<a href="students/../00-shared/02_Design_Tokens.html">00-shared/02 §10</a>).</li> <li>Status badges re-read <code>ColorScheme</code> (no hardcoded green/red).</li> <li>Timeline connectors <code>outlineVariant</code> (light) / <code>#49454F</code> (dark token).</li> <li>Golden tests at light+dark for all module components (<a href="students/../00-shared/10_QA_Baseline.html">00-shared/10 §9</a>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-students-module"><a class="header" href="#12--api-mapping-students-module">12 — API Mapping (Students Module)</a></h1> <ul> <li><a href="students/12_API_Mapping.html#0-module-wide-contract">0. Module-wide contract</a></li> <li><a href="students/12_API_Mapping.html#1-list-students--roster">1. List students — Roster</a></li> <li><a href="students/12_API_Mapping.html#2-get-student--detail">2. Get student — Detail</a></li> <li><a href="students/12_API_Mapping.html#3-create-student--create-wizard">3. Create student — Create wizard</a></li> <li><a href="students/12_API_Mapping.html#4-update-student--edit-form">4. Update student — Edit form</a></li> <li><a href="students/12_API_Mapping.html#5-delete-student-soft--delete-dialog">5. Delete student (soft) — Delete dialog</a></li> <li><a href="students/12_API_Mapping.html#6-enroll--enroll-sheet">6. Enroll — Enroll sheet</a></li> <li><a href="students/12_API_Mapping.html#7-transfer--transfer-form">7. Transfer — Transfer form</a></li> <li><a href="students/12_API_Mapping.html#8-graduate--archive--restore--lifecycle-dialogs">8. Graduate / Archive / Restore — Lifecycle dialogs</a></li> <li><a href="students/12_API_Mapping.html#9-active-enrollments--detail--profilehistory">9. Active enrollments — Detail → Profile/History</a></li> <li><a href="students/12_API_Mapping.html#10-academic-history--detail--history-tab">10. Academic history — Detail → History tab</a></li> <li><a href="students/12_API_Mapping.html#11-documents">11. Documents</a></li> <li><a href="students/12_API_Mapping.html#12-bulk-import--export">12. Bulk import / export</a></li> <li><a href="students/12_API_Mapping.html#13-parent-linking-via-parents-module">13. Parent linking (via Parents module)</a></li> <li><a href="students/12_API_Mapping.html#14-supporting-endpoints-dropdownidentity-data">14. Supporting endpoints (dropdown/identity data)</a></li> <li><a href="students/12_API_Mapping.html#client-error-mapping-table-module">Client error mapping table (module)</a></li> <li><a href="students/12_API_Mapping.html#optimistic--undo-policy">Optimistic / undo policy</a></li> </ul> <blockquote> <p>Exact wire contract for every screen → endpoint. Base <code>/api/v1</code>. Envelope per <a href="students/../00-shared/07_API_Conventions.html">00-shared/07 §2-3</a>. Endpoints from <code>student.controller.ts</code>, <code>parent.controller.ts</code>, <code>bulk.controller.ts</code>, <code>users.controller.ts</code>; business rules from the services cited. All endpoints behind <code>JwtAuthGuard</code> (<code>student.controller.ts:32-35</code>); <code>tenantId</code> from JWT only — never in the body. RBAC enforcement <code>(planned)</code> (OQ-4); client still gates UI on <code>student.*</code>.</p> </blockquote> <hr /> <h2 id="0-module-wide-contract"><a class="header" href="#0-module-wide-contract">0. Module-wide contract</a></h2> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Contract</th></tr></thead><tbody> <tr><td>Base</td><td><code>https://api.<domain>/api/v1</code></td></tr> <tr><td>Headers</td><td><code>Authorization: Bearer <accessToken></code>; <code>x-request-id</code> (client UUID); <code>Content-Type: application/json</code> (multipart/form-data for uploads)</td></tr> <tr><td>Success</td><td><code>{success:true, message:"OK", data, meta?, timestamp, requestId}</code> — paginated: <code>data</code> array + <code>meta{page,limit,totalItems,totalPages,hasNext,hasPrevious}</code> (<code>pagination-query.dto.ts:32-39</code>)</td></tr> <tr><td>Error</td><td><code>{success:false, message, error:{code, details?}, timestamp, requestId}</code></td></tr> <tr><td>Codes</td><td>400 <code>VALIDATION_ERROR</code> · 401 <code>UNAUTHENTICATED</code> · 403 <code>PERMISSION_DENIED</code> · 404 <code>RESOURCE_NOT_FOUND</code> · 409 <code>DUPLICATE_RESOURCE</code> · 422 <code>BUSINESS_RULE_VIOLATION</code> · 429 <code>RATE_LIMITED</code> · 5xx <code>INTERNAL_SERVER_ERROR</code></td></tr> <tr><td>Pagination</td><td><code>page</code> ≥ 1 (default 1), <code>limit</code> 1–100 (default 20), <code>sort</code> (<code>-</code> = desc), <code>q</code> — <strong>students <code>find</code> ignores <code>sort</code>/<code>q</code></strong> (OQ-2)</td></tr> <tr><td>Caching</td><td>client last-good + stale-while-revalidate (<a href="students/../00-shared/06_State_Management.html">00-shared/06 §3.3</a>); TTLs: roster 5 min <code>(proposed)</code>, detail no client cache</td></tr> <tr><td>Offline</td><td>reads cached; <strong>all writes blocked</strong> (no offline queue defined for students)</td></tr> <tr><td>Retry</td><td>backoff on 5xx/network; no auto-retry on 429</td></tr> </tbody></table> </div> <hr /> <h2 id="1-list-students--roster"><a class="header" href="#1-list-students--roster">1. List students — Roster</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>GET /students</code> (<code>student.controller.ts:41-43</code>)</td></tr> <tr><td>Query</td><td><code>page</code>, <code>limit</code>, <code>sort?</code>, <code>q?</code> (parsed; only page/limit applied — <code>student.service.ts:104-108</code>)</td></tr> <tr><td>Response</td><td>200 <code>data:[StudentDoc…]</code>, <code>meta</code> (page/limit/totalItems/totalPages/hasNext/hasPrevious)</td></tr> <tr><td>StudentDoc shape</td><td><code>_id, tenantId, userId, admissionNumber, rollNumber?, academicYearId, campusId?, gradeId, sectionId, classId, houseId?, admissionDate?, admissionType?, status, transportRequired, hostelRequired, medicalNotes?, metadata?, createdAt, updatedAt, version, isDeleted:false</code> (<code>student.schema.ts:15-64</code> + <code>base.schema.ts</code>)</td></tr> <tr><td>Errors</td><td>401; 429; 5xx</td></tr> <tr><td>Client</td><td>infinite scroll on <code>meta.hasNext</code>; client-side filters/sort (server ignores q/sort — OQ-2)</td></tr> </tbody></table> </div> <h2 id="2-get-student--detail"><a class="header" href="#2-get-student--detail">2. Get student — Detail</a></h2> <p><code>GET /students/:id</code> (<code>student.controller.ts:44-46</code>) → 200 <code>data:StudentDoc</code>; 404 <code>RESOURCE_NOT_FOUND</code> "Student not found." (<code>student.service.ts:95-99</code>).</p> <h2 id="3-create-student--create-wizard"><a class="header" href="#3-create-student--create-wizard">3. Create student — Create wizard</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>POST /students</code> (<code>student.controller.ts:38-40</code>)</td></tr> <tr><td>Body</td><td><code>CreateStudentDto</code> — see <a href="students/08_Form_Specifications.html">08 §1</a> (userId*, admissionNumber*, academicYearId*, gradeId*, sectionId*, classId*; rest optional)</td></tr> <tr><td>Response</td><td>200 <code>data:StudentDoc</code> with <code>status:"active"</code> (<code>student.service.ts:65</code>) + auto-enrolled ACTIVE <code>class_enrollments</code> row (<code>student.service.ts:71-78</code>)</td></tr> <tr><td>Errors</td><td>400 validation; 409 <code>DUPLICATE_RESOURCE</code> "Admission number "x" already exists." (<code>student.service.ts:56-62</code>); 429; 5xx</td></tr> <tr><td>Events</td><td><code>StudentCreated {studentId, admissionNumber, classId}</code> → <code>in-app</code>/<code>student-enrolled</code> (<code>event-queue-map.ts:28</code>); audit via event bus</td></tr> </tbody></table> </div> <h2 id="4-update-student--edit-form"><a class="header" href="#4-update-student--edit-form">4. Update student — Edit form</a></h2> <p><code>PATCH /students/:id</code> (<code>student.controller.ts:56-58</code>) — body <code>UpdateStudentDto</code> (all optional; <code>status</code> is unvalidated string — UI sends enum values only). 200 <code>data:StudentDoc</code>; 404; 409 n/a; emits <code>StudentUpdated {changes:[field…]}</code> → <code>audit-write</code>/<code>log-student-updated</code> (<code>event-queue-map.ts:29</code>). Note <code>version</code> increments on every write (<code>base.repository.ts:57-66</code>).</p> <h2 id="5-delete-student-soft--delete-dialog"><a class="header" href="#5-delete-student-soft--delete-dialog">5. Delete student (soft) — Delete dialog</a></h2> <p><code>DELETE /students/:id</code> (<code>student.controller.ts:59-61</code>) → 200 (<code>data</code> empty); 404; emits <code>StudentDeleted</code> → <code>audit-write</code>/<code>log-student-deleted</code> (<code>event-queue-map.ts:30</code>). Soft delete: <code>isDeleted:true, deletedAt, deletedBy</code> (<code>base.repository.ts:68-74</code>) — excluded from all queries. No cascade to documents/enrollments/links (OQ-7). No restore endpoint for soft-deleted.</p> <h2 id="6-enroll--enroll-sheet"><a class="header" href="#6-enroll--enroll-sheet">6. Enroll — Enroll sheet</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>POST /students/:id/enroll</code> (<code>student.controller.ts:50-55</code>)</td></tr> <tr><td>Body</td><td><code>EnrollStudentDto {classId*, academicYearId*, rollNumber?}</code></td></tr> <tr><td>Behaviour</td><td>deactivates all ACTIVE enrollments (<code>status:"transferred"</code>, <code>leftAt:now</code>) then creates new ACTIVE (<code>student.service.ts:125-140</code>)</td></tr> <tr><td>Response</td><td>200 <code>data:ClassEnrollmentDoc {studentId, classId, academicYearId, rollNumber?, joinedAt, leftAt?, status:"active"}</code> (<code>class-enrollment.schema.ts:14-39</code>)</td></tr> <tr><td>Errors</td><td>404 (student); 400; 429; 5xx</td></tr> </tbody></table> </div> <h2 id="7-transfer--transfer-form"><a class="header" href="#7-transfer--transfer-form">7. Transfer — Transfer form</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td><strong>Endpoint</strong></td><td><code>POST /students/:id/transfer</code> (<code>student.controller.ts:62-65</code>)</td></tr> <tr><td>Body</td><td><code>TransferStudentDto {classId*, academicYearId*, gradeId?, sectionId?, rollNumber?}</code></td></tr> <tr><td>Behaviour</td><td>guard <code>status === "active"</code> else 409 <code>Cannot transfer a student with status "x".</code> (<code>student.service.ts:175-179</code>) → enroll (same deactivation) → <code>$set {classId, academicYearId, gradeId?, sectionId?}</code> (<code>student.service.ts:180-192</code>)</td></tr> <tr><td>Response</td><td>200 <code>data:StudentDoc</code> (updated)</td></tr> <tr><td>Errors</td><td>404; 409 (wrong status); 400; 429; 5xx</td></tr> <tr><td>Events</td><td><code>StudentUpdated {changes:["transfer"]}</code> → audit</td></tr> </tbody></table> </div> <h2 id="8-graduate--archive--restore--lifecycle-dialogs"><a class="header" href="#8-graduate--archive--restore--lifecycle-dialogs">8. Graduate / Archive / Restore — Lifecycle dialogs</a></h2> <div class="table-wrapper"><table><thead><tr><th>Action</th><th>Endpoint</th><th>Guard</th><th>Success → data</th><th>Source</th></tr></thead><tbody> <tr><td>Graduate</td><td><code>POST /students/:id/graduate</code></td><td>409 "Student is already graduated."</td><td><code>status:"graduated"</code></td><td><code>student.controller.ts:66-69</code>, <code>student.service.ts:205-223</code></td></tr> <tr><td>Archive</td><td><code>POST /students/:id/archive</code></td><td>409 "Student is already archived."</td><td><code>status:"archived"</code></td><td><code>student.controller.ts:70-73</code></td></tr> <tr><td>Restore</td><td><code>POST /students/:id/restore</code></td><td>409 "Student is already active."</td><td><code>status:"active"</code></td><td><code>student.controller.ts:74-77</code></td></tr> </tbody></table> </div> <p>All: 404; 200 <code>data:StudentDoc</code>; emit <code>StudentUpdated {changes:["graduate"|"archive"|"restore"]}</code>.</p> <h2 id="9-active-enrollments--detail--profilehistory"><a class="header" href="#9-active-enrollments--detail--profilehistory">9. Active enrollments — Detail → Profile/History</a></h2> <p><code>GET /students/:id/enrollments</code> (<code>student.controller.ts:47-49</code>) → 200 <code>data:[ClassEnrollmentDoc…]</code> <strong>active only</strong> (<code>findActiveByStudent</code> — <code>class-enrollment.repository.ts:20-24</code>); 404.</p> <h2 id="10-academic-history--detail--history-tab"><a class="header" href="#10-academic-history--detail--history-tab">10. Academic history — Detail → History tab</a></h2> <p><code>GET /students/:id/academic-history</code> (<code>student.controller.ts:91-94</code>) → 200 <code>data:[ClassEnrollmentDoc…]</code> <strong>all</strong> enrollments sorted <code>joinedAt desc</code> (<code>student.service.ts:281-287</code>); 404.</p> <h2 id="11-documents"><a class="header" href="#11-documents">11. Documents</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>List</td><td><code>GET /students/:id/documents</code> (<code>student.controller.ts:78-81</code>) → 200 <code>data:[StudentDocumentDoc…]</code> sorted <code>createdAt desc</code> (<code>student.service.ts:273-279</code>) — shape <code>{studentId, fileName, mimeType, size, fileId, category?, uploadedBy?, createdAt}</code> (<code>student-document.schema.ts:8-29</code>)</td></tr> <tr><td>Upload</td><td><code>POST /students/:id/documents</code> (<code>student.controller.ts:82-90</code>) — <code>multipart/form-data</code>, file field <strong><code>file</code></strong>, text field <code>category?</code>; 200 <code>data:StudentDocumentDoc</code>; no server size/type validation (OQ-9)</td></tr> <tr><td>Download</td><td><strong>none</strong> — only <code>fileId</code> metadata; local provider path <code>/api/v1/files/<tenantId>/<uuid>--<name></code> (<code>local-storage.provider.ts:47-51</code>) — preview/download <code>(planned)</code></td></tr> </tbody></table> </div> <h2 id="12-bulk-import--export"><a class="header" href="#12-bulk-import--export">12. Bulk import / export</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Import</td><td><code>POST /bulk/import/:entity</code> where <code>entity="students"</code> (<code>bulk.controller.ts:35-48</code>; only adapter — <code>bulk-import.service.ts:17-20</code>) — multipart field <strong><code>file</code></strong>; missing → 400 "CSV file is required (multipart field "file")."; 200 <code>data:ImportReport {entity, totalRows, imported, failed, errors:[{rowNumber, errors[]}]}</code> (<code>import-adapter.interface.ts:14-25</code>)</td></tr> <tr><td>Parse errors</td><td>400 "Malformed CSV: could not parse file." / "CSV must include a header row and data." (<code>bulk-import.service.ts:31-35</code>)</td></tr> <tr><td>Unknown entity</td><td>404 <code>No import adapter for entity "x".</code></td></tr> <tr><td>Export</td><td><code>GET /bulk/export/:entity</code> (<code>bulk.controller.ts:50-60</code>) → 200 <code>text/csv</code>, <code>Content-Disposition: attachment; filename="students.csv"</code> — columns <code>admissionNumber, rollNumber, status, admissionDate</code> sorted by admissionNumber (<code>students-import.adapter.ts:87-98</code>)</td></tr> </tbody></table> </div> <p>Per-row errors (exact strings) in <a href="students/08_Form_Specifications.html">08 §6</a>; row numbers are <code>index+2</code> (header = row 1) (<code>bulk-import.service.ts:45-46</code>). Synchronous; no job/progress endpoint (OQ-12).</p> <h2 id="13-parent-linking-via-parents-module"><a class="header" href="#13-parent-linking-via-parents-module">13. Parent linking (via Parents module)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>List links</td><td><code>GET /parents/link/student/:studentId</code> (<code>parent.controller.ts:53-57</code>) → 200 <code>data:[StudentParentLinkDoc…]</code> <code>{studentId, parentId, relationship, isPrimaryGuardian, financialResponsibility, pickupAllowed, emergencyPriority, metadata?}</code> (<code>student-parent-link.schema.ts:17-41</code>)</td></tr> <tr><td>Create parent</td><td><code>POST /parents</code> (<code>parent.controller.ts:29-31</code>) — <code>CreateParentDto</code>; 409 "Parent profile already exists for this user." (<code>parent.service.ts:30-34</code>)</td></tr> <tr><td>Link</td><td><code>POST /parents/link/:studentId</code> (<code>parent.controller.ts:47-52</code>) — <code>LinkParentDto</code> (relationship* required); 404 unknown student (<code>student-parent-link.service.ts:27</code>); parent existence not verified (OQ-11)</td></tr> <tr><td>Unlink</td><td><code>DELETE /parents/link/:linkId</code> (<code>parent.controller.ts:58-60</code>) — soft delete; 404 "Link not found." (<code>student-parent-link.service.ts:38-41</code>)</td></tr> <tr><td>Parent's children</td><td><code>GET /parents/:id/students</code> (<code>parent.controller.ts:38-40</code>) → link docs for that parent</td></tr> <tr><td>Update link</td><td><strong>none</strong> — edit = unlink + re-link</td></tr> </tbody></table> </div> <h2 id="14-supporting-endpoints-dropdownidentity-data"><a class="header" href="#14-supporting-endpoints-dropdownidentity-data">14. Supporting endpoints (dropdown/identity data)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Purpose</th><th>Endpoint</th><th>Source</th></tr></thead><tbody> <tr><td>Academic years</td><td><code>GET /academic-years</code> (page/limit)</td><td><code>academics/controllers/academic-year.controller.ts:27-29</code></td></tr> <tr><td>Grades</td><td><code>GET /grades</code></td><td><code>grade.controller.ts:27-29</code></td></tr> <tr><td>Sections by grade</td><td><code>GET /sections/by-grade/:gradeId</code></td><td><code>section.controller.ts:30-33</code></td></tr> <tr><td>Classes</td><td><code>GET /classes</code> ; <code>GET /classes/by-year/:academicYearId</code></td><td><code>class.controller.ts:27-33</code></td></tr> <tr><td>Create user (identity step)</td><td><code>POST /users</code></td><td><code>users.controller.ts:36-40</code></td></tr> <tr><td>Search users (reuse)</td><td><code>GET /users?q=</code> (server-side <code>q</code> handling per users module)</td><td><code>users.controller.ts:42-46</code></td></tr> <tr><td>Avatar</td><td><code>POST /users/:id/avatar</code> (multipart <code>file</code>)</td><td><code>users.controller.ts:95-102</code></td></tr> </tbody></table> </div> <hr /> <h2 id="client-error-mapping-table-module"><a class="header" href="#client-error-mapping-table-module">Client error mapping table (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Code</th><th>UI</th></tr></thead><tbody> <tr><td>any</td><td>401</td><td>silent refresh → <code>sessionExpired</code> (<a href="students/../00-shared/06_State_Management.html">00-shared/06 §3.6</a>)</td></tr> <tr><td>any action</td><td>403</td><td>hide action / 403 screen (server RBAC <code>(planned)</code>)</td></tr> <tr><td>create</td><td>409 admission</td><td>inline field "already exists"</td></tr> <tr><td>create (users)</td><td>409 email</td><td>step-1 inline + offer reuse</td></tr> <tr><td>transfer</td><td>409 status</td><td>read-only status banner</td></tr> <tr><td>graduate/archive/restore</td><td>409 same state</td><td>snackbar + refresh</td></tr> <tr><td>documents upload</td><td>400/5xx</td><td>tile error + retry</td></tr> <tr><td>import</td><td>400 parse</td><td>wizard error banner with message</td></tr> <tr><td>import</td><td>404 entity</td><td>generic (should not happen)</td></tr> <tr><td>list/detail</td><td>404</td><td>empty-state "not found"</td></tr> <tr><td>any</td><td>429</td><td>countdown, CTA disabled</td></tr> <tr><td>any</td><td>5xx</td><td>generic + requestId (<a href="students/../00-shared/06_State_Management.html">00-shared/06 §5</a>)</td></tr> </tbody></table> </div> <h2 id="optimistic--undo-policy"><a class="header" href="#optimistic--undo-policy">Optimistic / undo policy</a></h2> <ul> <li><strong>Never optimistic</strong> on: create, enroll, transfer, graduate, archive, restore, delete, link/unlink, document upload, import (all server-truth, side effects).</li> <li>Row removal (unlink, delete) reflects only after server 200.</li> <li>Undo: not offered (no restore endpoint for soft-delete; archive has explicit Restore action instead).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-students-module"><a class="header" href="#13--state-management-students-module">13 — State Management (Students Module)</a></h1> <ul> <li><a href="students/13_State_Management.html#1-module-data-model-client">1. Module data model (client)</a></li> <li><a href="students/13_State_Management.html#2-cubit-map">2. Cubit map</a></li> <li><a href="students/13_State_Management.html#3-roster-state-machine-pagination--filters">3. Roster state machine (pagination + filters)</a></li> <li><a href="students/13_State_Management.html#4-detail-tabs-lazy-load">4. Detail tabs lazy-load</a></li> <li><a href="students/13_State_Management.html#5-create-wizard-state">5. Create wizard state</a></li> <li><a href="students/13_State_Management.html#6-import-wizard-state-progress-contract">6. Import wizard state (progress contract)</a></li> <li><a href="students/13_State_Management.html#7-documents-upload-state">7. Documents upload state</a></li> <li><a href="students/13_State_Management.html#8-events--actions-map-ui--cubit--repository">8. Events & actions map (UI → Cubit → Repository)</a></li> <li><a href="students/13_State_Management.html#9-realtime--cross-screen">9. Realtime & cross-screen</a></li> <li><a href="students/13_State_Management.html#10-error-states-per-action">10. Error states per action</a></li> <li><a href="students/13_State_Management.html#11-testing-hooks-00-shared06-6">11. Testing hooks (<code>00-shared/06 §6</code>)</a></li> </ul> <blockquote> <p>Per-screen Cubits (Flutter/bloc; proposal — <a href="students/../00-shared/06_State_Management.html">00-shared/06</a>) + repositories for the endpoints in <a href="students/12_API_Mapping.html">12_API_Mapping.md</a>. Conventions: one Cubit per screen, <code>LoadState</code> machine (<a href="students/../00-shared/06_State_Management.html">00-shared/06 §3.1</a>), <code>PaginatedListMixin</code> for the roster (<a href="students/../00-shared/06_State_Management.html">00-shared/06 §3.2</a>).</p> </blockquote> <hr /> <h2 id="1-module-data-model-client"><a class="header" href="#1-module-data-model-client">1. Module data model (client)</a></h2> <pre><code class="language-dart">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[]} } </code></pre> <p>Enums map 1:1 to server (<code>student.schema.ts:7-13</code>, <code>class-enrollment.schema.ts:7-11</code>, <code>student-parent-link.schema.ts:7-14</code>). <code>user</code>/<code>classRef</code> hydration is client-side join (GET <code>/users/:id</code> + academics refs) — no server join exists.</p> <h2 id="2-cubit-map"><a class="header" href="#2-cubit-map">2. Cubit map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Cubit</th><th>Events → State</th></tr></thead><tbody> <tr><td>Roster</td><td><code>StudentsListCubit</code></td><td><code>Load(page1)</code>, <code>LoadMore</code>, <code>Refresh</code>, <code>ChangeFilters(status/class/year)</code>, <code>Search(q)</code>, <code>Retry</code> → <code>{load: LoadState, items[], meta, filters, query, searchMode}</code></td></tr> <tr><td>Detail</td><td><code>StudentDetailCubit</code></td><td><code>Load(id)</code>, <code>Refresh</code>, `RunAction(Action.graduate</td></tr> <tr><td>Create wizard</td><td><code>CreateStudentCubit</code></td><td>`IdentityStep(createUser</td></tr> <tr><td>Enroll sheet</td><td><code>EnrollCubit</code></td><td><code>Submit(classId, yearId, roll)</code> → <code>{idle, submitting, success, error}</code></td></tr> <tr><td>Transfer</td><td><code>TransferCubit</code></td><td><code>Load(current)</code>, <code>Submit(form)</code> → <code>{idle, loading, submitting, blocked(status), success, error}</code></td></tr> <tr><td>Documents</td><td><code>DocumentsCubit</code></td><td><code>Load</code>, <code>Upload(file, category)</code>, <code>RetryUpload</code> → <code>{load, docs[], uploading{tile}, error}</code></td></tr> <tr><td>History</td><td><code>HistoryCubit</code></td><td><code>Load</code> → <code>{load, enrollments[]}</code> (academic-history + active enrollments merged)</td></tr> <tr><td>Parents tab</td><td><code>ParentLinksCubit</code></td><td><code>Load</code>, <code>CreateParent(form)</code>, <code>Link(dto)</code>, <code>Unlink(id)</code> → <code>{load, links[], sheetState}</code></td></tr> <tr><td>Import wizard</td><td><code>ImportCubit</code></td><td><code>DownloadTemplate</code>, <code>PickFile(parsed)</code>, <code>Upload</code>, <code>Retry</code> → <code>{step, file, report?, submitting}</code></td></tr> </tbody></table> </div> <h2 id="3-roster-state-machine-pagination--filters"><a class="header" href="#3-roster-state-machine-pagination--filters">3. Roster state machine (pagination + filters)</a></h2> <pre class="mermaid">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 </pre> <ul> <li><code>PaginatedListMixin</code> contract: <code>page</code>, <code>limit(20)</code>, <code>hasNext</code>, <code>items</code>, <code>loadFirst/loadMore/pullToRefresh</code> (<code>00-shared/06 §3.2</code>).</li> <li>Server <code>q</code>/<code>sort</code> ignored (OQ-2) → filters/search run <strong>client-side</strong> over accumulated pages; when filter set, <code>LoadMore</code> keeps paging server-side and applies filter locally. <code>searchMode</code> flag toggles helper text (honesty rule <code>09 §1</code>).</li> <li>Cache: last-good list per key <code>sl:{tenant}:students:list:{page}</code> (Hive/prefs, TTL 5 min <code>(proposed)</code>) — served instantly offline, refreshed in background.</li> </ul> <h2 id="4-detail-tabs-lazy-load"><a class="header" href="#4-detail-tabs-lazy-load">4. Detail tabs lazy-load</a></h2> <pre class="mermaid">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] </pre> <ul> <li>Each tab owns its LoadState; failures isolated (tab shows compact <code>AppErrorState</code>, screen stays).</li> <li>After <code>RunAction</code> success → <code>Refresh()</code> profile + badge; history refresh on transfer/enroll; documents refresh after upload.</li> </ul> <h2 id="5-create-wizard-state"><a class="header" href="#5-create-wizard-state">5. Create wizard state</a></h2> <pre><code class="language-dart">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 } </code></pre> <ul> <li><code>Submit</code> sequence (exact): if new user → <code>usersRepo.create</code> → on 409 switch to <code>reuseExistingUser</code> prompt; then <code>studentsRepo.create</code> → <code>createdId</code> → navigate <code>/students/:id</code>.</li> <li>No draft persistence across app kills (YAGNI — <code>09 §2</code>).</li> <li>400 field errors mapped via <code>details[].field</code>; 409 admission → field error on admission number.</li> </ul> <h2 id="6-import-wizard-state-progress-contract"><a class="header" href="#6-import-wizard-state-progress-contract">6. Import wizard state (progress contract)</a></h2> <pre><code class="language-dart">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 } </code></pre> <ul> <li>Server import is <strong>synchronous</strong> (loop in request, <code>bulk-import.service.ts:45-63</code>) — no job id, no progress API (OQ-12). UI: indeterminate progress + elapsed rows readout (client estimate <code>totalRows</code>), disable back during upload.</li> <li>Report render from <code>ImportReport</code>; <code>rowNumber</code>-grouped errors; "Done" resets list cache (invalidate roster).</li> </ul> <h2 id="7-documents-upload-state"><a class="header" href="#7-documents-upload-state">7. Documents upload state</a></h2> <p><code>UploadingDoc {fileName, size, progress(0..1), phase: queued|uploading|done|error}</code> in <code>DocumentsCubit</code>; multipart via <code>dio</code> <code>FormData</code>; retry re-posts buffered bytes; no chunked resume (<code>00-shared/12 B7</code>).</p> <h2 id="8-events--actions-map-ui--cubit--repository"><a class="header" href="#8-events--actions-map-ui--cubit--repository">8. Events & actions map (UI → Cubit → Repository)</a></h2> <div class="table-wrapper"><table><thead><tr><th>UI event</th><th>Cubit method</th><th>Repository call</th></tr></thead><tbody> <tr><td>Roster open / refresh</td><td><code>load()</code> / <code>refresh()</code></td><td><code>studentsRepo.list(page, limit)</code></td></tr> <tr><td>Infinite scroll</td><td><code>loadMore()</code></td><td><code>studentsRepo.list(page+1, limit)</code></td></tr> <tr><td>Search / filters</td><td><code>search(q)</code> / <code>setFilters(f)</code></td><td>client-side (plus server page fetch)</td></tr> <tr><td>Row → View</td><td><code>select(id)</code></td><td>detail <code>load(id)</code></td></tr> <tr><td>Create submit</td><td><code>submit()</code></td><td><code>usersRepo.create</code> → <code>studentsRepo.create</code></td></tr> <tr><td>Enroll</td><td><code>submit(form)</code></td><td><code>studentsRepo.enroll(id, dto)</code></td></tr> <tr><td>Transfer</td><td><code>submit(form)</code></td><td><code>studentsRepo.transfer(id, dto)</code></td></tr> <tr><td>Graduate/Archive/Restore</td><td><code>runAction(a)</code></td><td>`studentsRepo.graduate</td></tr> <tr><td>Delete</td><td><code>runAction(delete)</code></td><td><code>studentsRepo.delete(id)</code></td></tr> <tr><td>Documents load/upload</td><td><code>load()</code> / <code>upload(file, cat)</code></td><td><code>documentsRepo.list(id)</code> / <code>documentsRepo.upload(id, file, cat)</code></td></tr> <tr><td>History load</td><td><code>load()</code></td><td><code>studentsRepo.academicHistory(id)</code> + <code>enrollments(id)</code></td></tr> <tr><td>Parents load</td><td><code>load()</code></td><td><code>parentsRepo.linksByStudent(id)</code></td></tr> <tr><td>Create parent</td><td><code>createParent(draft)</code></td><td><code>usersRepo.create</code> → <code>parentsRepo.create</code></td></tr> <tr><td>Link</td><td><code>link(dto)</code></td><td><code>parentsRepo.link(studentId, dto)</code></td></tr> <tr><td>Unlink</td><td><code>unlink(linkId)</code></td><td><code>parentsRepo.unlink(linkId)</code></td></tr> <tr><td>Import template</td><td><code>downloadTemplate()</code></td><td><code>bulkRepo.exportCsv('students')</code> → file save/share</td></tr> <tr><td>Import upload</td><td><code>upload(file)</code></td><td><code>bulkRepo.importCsv('students', file)</code></td></tr> </tbody></table> </div> <h2 id="9-realtime--cross-screen"><a class="header" href="#9-realtime--cross-screen">9. Realtime & cross-screen</a></h2> <ul> <li>No student WS topics (<code>00-shared/07 §8</code>); roster freshness via refresh + focus re-fetch; <code>roster.changed</code> <code>(forward-looking)</code>.</li> <li><code>StudentCreated</code> in-app notification tap → deep link <code>/students/:id</code> <code>(proposed)</code>.</li> <li>Academic-year switcher (<code>00-shared/06 §4</code> selectors) affects class cascade defaults in create/enroll/transfer forms.</li> </ul> <h2 id="10-error-states-per-action"><a class="header" href="#10-error-states-per-action">10. Error states per action</a></h2> <div class="table-wrapper"><table><thead><tr><th>Action</th><th>Error</th><th>State →</th></tr></thead><tbody> <tr><td>create</td><td>409 admission / email</td><td>field error; reuse-user prompt</td></tr> <tr><td>create</td><td>400</td><td>field map</td></tr> <tr><td>transfer</td><td>409 status</td><td><code>blocked(status)</code> → read-only banner</td></tr> <tr><td>lifecycle</td><td>409 same-state</td><td>snackbar + refresh</td></tr> <tr><td>unlink/delete</td><td>404</td><td>treat as removed</td></tr> <tr><td>upload</td><td>network loss</td><td>tile error + retry</td></tr> <tr><td>import</td><td>400 parse</td><td>wizard banner</td></tr> <tr><td>any</td><td>429</td><td>countdown</td></tr> <tr><td>any</td><td>5xx</td><td>generic + requestId</td></tr> </tbody></table> </div> <h2 id="11-testing-hooks-00-shared06-6"><a class="header" href="#11-testing-hooks-00-shared06-6">11. Testing hooks (<code>00-shared/06 §6</code>)</a></h2> <ul> <li>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).</li> <li>Widget tests per screen: 3 states (loading/error/success+empty) per <code>00-shared/10 §9</code>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-students-module"><a class="header" href="#14--qa-checklist-students-module">14 — QA Checklist (Students Module)</a></h1> <ul> <li><a href="students/14_QA_Checklist.html#1-data-integrity--business-rules">1. Data integrity & business rules</a></li> <li><a href="students/14_QA_Checklist.html#2-enrollment-conflict-scenarios">2. Enrollment conflict scenarios</a></li> <li><a href="students/14_QA_Checklist.html#3-document-limits">3. Document limits</a></li> <li><a href="students/14_QA_Checklist.html#4-bulk-import--1000-row-file-target-scenario-per-plan-153">4. Bulk import — 1000-row file (target scenario per PLAN 15.3)</a></li> <li><a href="students/14_QA_Checklist.html#5-multi-tenancy--security-baseline-10">5. Multi-tenancy & security (baseline §10)</a></li> <li><a href="students/14_QA_Checklist.html#6-performance-budgets-baseline-1-applied">6. Performance budgets (baseline §1 applied)</a></li> <li><a href="students/14_QA_Checklist.html#7-offline-matrix-baseline-2-applied">7. Offline matrix (baseline §2 applied)</a></li> <li><a href="students/14_QA_Checklist.html#8-a11y-spot-checks-baseline-9--09_accessibility_baselinemd">8. A11y spot checks (baseline §9 + 09_Accessibility_Baseline.md)</a></li> <li><a href="students/14_QA_Checklist.html#9-localization--format">9. Localization & format</a></li> <li><a href="students/14_QA_Checklist.html#10-regression-pack-server-verified-behaviours-worth-automating">10. Regression pack (server-verified behaviours worth automating)</a></li> <li><a href="students/14_QA_Checklist.html#11-analytics-events-proposed-to-smoke-test">11. Analytics events (proposed) to smoke-test</a></li> </ul> <blockquote> <p>Module-specific quality gates on top of <a href="students/../00-shared/10_QA_Baseline.html">00-shared/10</a> (baseline applies to every item here). Each checklist row is traceable to a server behaviour cited inline.</p> </blockquote> <hr /> <h2 id="1-data-integrity--business-rules"><a class="header" href="#1-data-integrity--business-rules">1. Data integrity & business rules</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expected</th><th>Server source</th></tr></thead><tbody> <tr><td>1.1</td><td>Duplicate admission number</td><td>Create → 409 "Admission number "x" already exists."; import row → per-row error</td><td><code>student.service.ts:56-62</code>; <code>students-import.adapter.ts:49-54</code></td></tr> <tr><td>1.2</td><td>Duplicate admission via race (two clients)</td><td>unique index <code>{tenantId, admissionNumber}</code> prevents both; one wins, other 409</td><td><code>student.schema.ts:68</code></td></tr> <tr><td>1.3</td><td>Create auto-enroll</td><td>response student + one ACTIVE enrollment with <code>joinedAt</code> ≈ now</td><td><code>student.service.ts:71-78</code></td></tr> <tr><td>1.4</td><td>Enroll closes previous</td><td>old enrollment <code>status:"transferred"</code> + <code>leftAt</code> set; new ACTIVE; history append-only</td><td><code>student.service.ts:125-139</code>; <code>COLLECTIONS.md:1674-1677</code></td></tr> <tr><td>1.5</td><td>Transfer guard</td><td>non-<code>active</code> status → 409 with exact status in message; no enrollment change happens</td><td><code>student.service.ts:175-179</code></td></tr> <tr><td>1.6</td><td>Graduate idempotency</td><td>second graduate → 409 "already graduated"; status stays <code>graduated</code></td><td><code>student.service.ts:207-209</code></td></tr> <tr><td>1.7</td><td>Archive/restore idempotency</td><td>archive twice → 409; restore twice → 409 "already active"</td><td><code>student.service.ts:228-230</code></td></tr> <tr><td>1.8</td><td>Soft delete isolation</td><td>after DELETE, student absent from list/detail/documents/history; <code>isDeleted:true</code></td><td><code>base.repository.ts:20-30,68-74</code></td></tr> <tr><td>1.9</td><td>No cascade on delete</td><td>documents/links/enrollments remain queryable by id</td><td><code>student.service.ts:157-168</code> (no cascade)</td></tr> <tr><td>1.10</td><td>Transfer writes profile</td><td>classId/academicYearId updated; gradeId/sectionId only when provided</td><td><code>student.service.ts:185-192</code></td></tr> </tbody></table> </div> <h2 id="2-enrollment-conflict-scenarios"><a class="header" href="#2-enrollment-conflict-scenarios">2. Enrollment conflict scenarios</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expected</th></tr></thead><tbody> <tr><td>2.1</td><td>Enroll same class twice in a row</td><td>second enroll deactivates first (transferred), creates new ACTIVE — allowed by server; UI warns (no server 409) — flagged OQ-13</td></tr> <tr><td>2.2</td><td>Enroll during active transfer UI</td><td>server processes sequentially; last write wins (<code>version</code> increments)</td></tr> <tr><td>2.3</td><td>Concurrent PATCH + transfer</td><td><code>findOneAndUpdate</code> + <code>$inc version</code> — no lost-update corruption (<code>base.repository.ts:57-66</code>); UI refreshes after both</td></tr> </tbody></table> </div> <h2 id="3-document-limits"><a class="header" href="#3-document-limits">3. Document limits</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expected</th><th>Source</th></tr></thead><tbody> <tr><td>3.1</td><td>No-file upload</td><td>request without <code>file</code> → 400 (multer)</td><td><code>student.controller.ts:82-90</code></td></tr> <tr><td>3.2</td><td>Oversized file</td><td><strong>no server limit today</strong> — must be enforced client + proxy <code>(proposed)</code> 10 MB</td><td>OQ-9; <code>student.service.ts:250-271</code></td></tr> <tr><td>3.3</td><td>Unsupported mime</td><td>no server allowlist — client <code>(proposed)</code> allowlist pdf/jpg/png/webp</td><td>OQ-9</td></tr> <tr><td>3.4</td><td>Empty category</td><td>allowed (optional string) — UI shows uncategorized chip</td><td><code>upload-student-document.dto.ts:4-9</code></td></tr> <tr><td>3.5</td><td>Uploaded metadata correctness</td><td>fileName/mimeType/size match actual file; <code>uploadedBy</code> = actor</td><td><code>student.service.ts:262-270</code></td></tr> <tr><td>3.6</td><td>List order</td><td>newest first (<code>createdAt desc</code>)</td><td><code>student.service.ts:273-279</code></td></tr> </tbody></table> </div> <h2 id="4-bulk-import--1000-row-file-target-scenario-per-plan-153"><a class="header" href="#4-bulk-import--1000-row-file-target-scenario-per-plan-153">4. Bulk import — 1000-row file (target scenario per PLAN 15.3)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expected</th></tr></thead><tbody> <tr><td>4.1</td><td>Valid 1000-row CSV</td><td>report <code>{totalRows:1000, imported:1000, failed:0, errors:[]}</code>; 1000 students + users created; no timeout → request budget: set client timeout ≥ 120 s for import (<a href="students/../00-shared/11_Flutter_App_Architecture.html">00-shared/11 §5</a>)</td></tr> <tr><td>4.2</td><td>Header row only (0 data rows)</td><td>400 "CSV must include a header row and data."</td></tr> <tr><td>4.3</td><td>Malformed CSV (unbalanced quotes)</td><td>400 "Malformed CSV: could not parse file."</td></tr> <tr><td>4.4</td><td>Missing required column</td><td>per-row error "Missing required column "section"."; row number = index+2</td></tr> <tr><td>4.5</td><td>Bad email format</td><td>"Invalid email format."</td></tr> <tr><td>4.6</td><td>Duplicate admission number in file (row 5 duplicates existing)</td><td>row 5 failed "already exists"; other rows import</td></tr> <tr><td>4.7</td><td>Duplicate email in file</td><td>first row creates user; second row fails "already registered" (validate pre-check) — or falls back to existing user inside <code>create</code> if validate missed it (race)</td></tr> <tr><td>4.8</td><td>Unknown academic year/grade/section</td><td>per-row "Academic year "x" not found." / "Grade … not found." / "Section … not found."</td></tr> <tr><td>4.9</td><td>Grade+section with no class</td><td>"No class found for grade "g" section "s"."</td></tr> <tr><td>4.10</td><td>Grade match by code</td><td>grade matched on <code>name</code> <strong>or</strong> <code>code</code></td></tr> <tr><td>4.11</td><td>Row-level failure isolation</td><td>one bad row never aborts the batch (try/catch per row)</td></tr> <tr><td>4.12</td><td>Import from non-authorized user</td><td>currently allowed (JWT-only) — verify when RBAC lands (OQ-4)</td></tr> </tbody></table> </div> <h2 id="5-multi-tenancy--security-baseline-10"><a class="header" href="#5-multi-tenancy--security-baseline-10">5. Multi-tenancy & security (baseline §10)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th></tr></thead><tbody> <tr><td>5.1</td><td>Cross-tenant <code>:id</code> on any endpoint → 404/403, no data leak (<code>base.repository.ts:20-30</code>)</td></tr> <tr><td>5.2</td><td><code>tenantId</code> never in request bodies from client</td></tr> <tr><td>5.3</td><td>Duplicate email/user cross-tenant never resolves (users repo is tenant-scoped too)</td></tr> <tr><td>5.4</td><td><code>uploadedBy</code>/<code>createdBy</code> set from JWT, never from body</td></tr> <tr><td>5.5</td><td>Soft-deleted students not returned by any scoped query</td></tr> <tr><td>5.6</td><td>Parent link of tenant A student with tenant B parent → structurally impossible (both scoped)</td></tr> </tbody></table> </div> <h2 id="6-performance-budgets-baseline-1-applied"><a class="header" href="#6-performance-budgets-baseline-1-applied">6. Performance budgets (baseline §1 applied)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Metric</th><th>Budget</th></tr></thead><tbody> <tr><td>Roster first frame (cached)</td><td>< 300 ms</td></tr> <tr><td>Roster network content</td><td>skeleton ≤ 200 ms, content ≤ 2 s (page 1, limit 20)</td></tr> <tr><td>1000-row import</td><td>completes; client timeout 120 s+; no UI freeze (parse on isolate)</td></tr> <tr><td>Detail load</td><td>profile immediate; tabs lazy (documents/history only on activation)</td></tr> <tr><td>Scroll</td><td>0 dropped frames; <code>ListView.builder</code> + const tiles</td></tr> </tbody></table> </div> <h2 id="7-offline-matrix-baseline-2-applied"><a class="header" href="#7-offline-matrix-baseline-2-applied">7. Offline matrix (baseline §2 applied)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Scenario</th><th>Expected</th></tr></thead><tbody> <tr><td>Offline → roster</td><td>cached rows + <code>AppOfflineBanner</code>; pull-refresh → Retry error state</td></tr> <tr><td>Offline → create/transfer/upload/import/link</td><td>blocked with guidance (no offline queue)</td></tr> <tr><td>Network loss mid-upload</td><td><code>DocumentUploadTile</code> error + Retry; no silent corruption</td></tr> <tr><td>Network loss mid-import</td><td>request fails → wizard error; re-import is idempotent per-row (admission/email dedup)</td></tr> </tbody></table> </div> <h2 id="8-a11y-spot-checks-baseline-9--09_accessibility_baselinemd"><a class="header" href="#8-a11y-spot-checks-baseline-9--09_accessibility_baselinemd">8. A11y spot checks (baseline §9 + <a href="students/09_User_Behaviour.html">09_Accessibility_Baseline.md</a>)</a></h2> <ul> <li>Status badges: icon + text, never color-only (<a href="students/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §9</a>).</li> <li>Search results count in live region; "item X of Y" list position.</li> <li>Wizard: focus first invalid field on Next; step index announced.</li> <li>Import report: live-region summary "997 of 1000 imported".</li> <li>Timeline: readable list semantics; badges announced.</li> <li>Text scale 2×: no clipping in header card, tiles, report cards.</li> <li>Dialogs trap focus; Esc/back closes; focus returns to trigger.</li> </ul> <h2 id="9-localization--format"><a class="header" href="#9-localization--format">9. Localization & format</a></h2> <ul> <li>Dates via <code>Intl</code> (admissionDate, joinedAt/leftAt, createdAt); money none.</li> <li><code>ADM</code> numbers in <code>mono</code>, not localized.</li> <li>RTL readiness (relationship chips, timelines mirror).</li> </ul> <h2 id="10-regression-pack-server-verified-behaviours-worth-automating"><a class="header" href="#10-regression-pack-server-verified-behaviours-worth-automating">10. Regression pack (server-verified behaviours worth automating)</a></h2> <ol> <li>create → auto-enroll → history shows 1 ACTIVE.</li> <li>enroll → old enrollment transferred + leftAt.</li> <li>transfer on archived student → 409.</li> <li>graduate → archive → restore sequence statuses.</li> <li>delete → list excludes; detail 404.</li> <li>import mixed file → report numbers + error strings.</li> <li>link parent → student missing → 404; parent missing → <strong>no error today</strong> (OQ-11 — decide and test when resolved).</li> <li>documents: upload → list order newest-first.</li> <li>pagination: page 2 offset math (<code>skip = (page-1)*limit</code>, <code>student.service.ts:104</code>).</li> <li>envelope: every response <code>{success,message,data,meta?,timestamp,requestId}</code>; every error carries <code>code</code>.</li> </ol> <h2 id="11-analytics-events-proposed-to-smoke-test"><a class="header" href="#11-analytics-events-proposed-to-smoke-test">11. Analytics events (proposed) to smoke-test</a></h2> <p><code>students.list.{view,search,filter,open}</code>, <code>students.create.{start,step,success,failure}</code>, <code>students.transfer.{submit,success,failure}</code>, <code>students.import.{template,upload,report}</code>, <code>students.document.{upload,open}</code>, <code>students.lifecycle.{graduate,archive,restore,delete}</code> — per <a href="students/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a>.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-students-module"><a class="header" href="#15--flutter-implementation-guide-students-module">15 — Flutter Implementation Guide (Students Module)</a></h1> <ul> <li><a href="students/15_Flutter_Implementation_Guide.html#1-feature-folder-structure">1. Feature folder structure</a></li> <li><a href="students/15_Flutter_Implementation_Guide.html#2-routing-go_router">2. Routing (go_router)</a></li> <li><a href="students/15_Flutter_Implementation_Guide.html#3-di-get_it">3. DI (get_it)</a></li> <li><a href="students/15_Flutter_Implementation_Guide.html#4-repository-contracts-dio">4. Repository contracts (dio)</a></li> <li><a href="students/15_Flutter_Implementation_Guide.html#5-key-implementation-notes">5. Key implementation notes</a></li> <li><a href="students/15_Flutter_Implementation_Guide.html#6-testing-strategy-module">6. Testing strategy (module)</a></li> <li><a href="students/15_Flutter_Implementation_Guide.html#7-dependencies-to-add-module-scoped-verify-licenses">7. Dependencies to add (module-scoped, verify licenses)</a></li> <li><a href="students/15_Flutter_Implementation_Guide.html#8-perf-notes">8. Perf notes</a></li> </ul> <blockquote> <p>Build guidance for the Students feature slice on top of <a href="students/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a> (project layout, DI, dio, router, theme, testing). Forward-looking spec — no client repo exists yet.</p> </blockquote> <hr /> <h2 id="1-feature-folder-structure"><a class="header" href="#1-feature-folder-structure">1. Feature folder structure</a></h2> <pre><code>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) </code></pre> <h2 id="2-routing-go_router"><a class="header" href="#2-routing-go_router">2. Routing (go_router)</a></h2> <pre><code class="language-dart">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', ...), ], ), ], ) </code></pre> <p>Guards: <code>permissionGuard('student.read')</code> on <code>/students</code>; <code>student.create</code> on <code>add</code>; <code>student.update</code> on <code>edit</code>/<code>transfer</code>; <code>file.upload</code> on documents actions (<code>00-shared/11 §6</code>). Deep link <code>studylyon://students/:id</code> → detail.</p> <h2 id="3-di-get_it"><a class="header" href="#3-di-get_it">3. DI (get_it)</a></h2> <pre><code class="language-dart">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) </code></pre> <p>Create-wizard dependency on UsersRepository via <code>getIt<UsersRepository>()</code> (module boundary: repository call, not service — preserves module boundaries in the client as server events do server-side).</p> <h2 id="4-repository-contracts-dio"><a class="header" href="#4-repository-contracts-dio">4. Repository contracts (dio)</a></h2> <pre><code class="language-dart">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 } </code></pre> <ul> <li>Envelope decoding + typed <code>ApiException(code,status,message,details)</code> via shared interceptor (<code>00-shared/11 §5</code>).</li> <li>Timeouts: default 15 s; <strong>import 120 s</strong>; upload 120 s.</li> <li>Multipart: <code>FormData.fromMap({'file': MultipartFile.fromBytes(...)})</code> — field name exactly <code>file</code> (<code>student.controller.ts:83</code>).</li> </ul> <h2 id="5-key-implementation-notes"><a class="header" href="#5-key-implementation-notes">5. Key implementation notes</a></h2> <div class="table-wrapper"><table><thead><tr><th>Topic</th><th>Guidance</th></tr></thead><tbody> <tr><td>Status enum</td><td>one Dart enum mirroring server; parse with fallback → unknown status renders neutral badge (never crash on new values)</td></tr> <tr><td>Client-side filters</td><td>roster filter chips operate on accumulated <code>items</code>; keep <code>serverPage</code> separate from <code>filteredList</code> (13 §3)</td></tr> <tr><td>Search honesty</td><td><code>searchMode</code> helper text per <code>09 §1</code> (server <code>q</code> ignored — OQ-2)</td></tr> <tr><td>Import CSV parse</td><td><code>csv</code> dart package on an isolate for > 500 rows (<code>00-shared/11 §13</code>); header/column check before upload (mirror <code>students-import.adapter.ts</code> rules client-side for early feedback, server report is authoritative)</td></tr> <tr><td>Import progress</td><td>indeterminate; no fake percentage (<code>09 §5</code>, OQ-12)</td></tr> <tr><td>Document preview</td><td>gate on file-serving endpoint existence (OQ-9); until then show metadata only</td></tr> <tr><td>Identity hydration</td><td>detail joins <code>GET /users/:id</code> for name/avatar; fallback <code>initials</code> avatar</td></tr> <tr><td>Class cascade</td><td>cache academics reference lists 24 h (<code>00-shared/06 §3.3</code>); <code>ClassSelectCascade</code> derives class by grade+section+year client-side where <code>by-year</code> list is coarse</td></tr> <tr><td>Hero</td><td>avatar hero tag <code>student-{id}</code> list→detail (<code>00-shared/08 §4</code>)</td></tr> <tr><td>Analytics</td><td><code>AnalyticsService</code> events per <code>14 §11</code> (proposed)</td></tr> </tbody></table> </div> <h2 id="6-testing-strategy-module"><a class="header" href="#6-testing-strategy-module">6. Testing strategy (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Layer</th><th>Tests</th></tr></thead><tbody> <tr><td>Unit — cubits</td><td><code>StudentsListCubitTest</code> (state machine incl. filters/loadMore/retry), <code>CreateStudentCubitTest</code> (user-409 → reuse path), <code>ImportCubitTest</code> (report render), <code>TransferCubitTest</code> (blocked status)</td></tr> <tr><td>Unit — mappers</td><td>DTO→model incl. unknown status fallback; envelope error mapping</td></tr> <tr><td>Widget</td><td>each screen 3 states (loading/error/success+empty); roster filter chips; import report card partial success; document upload tile states</td></tr> <tr><td>Golden</td><td>module components light/dark × 3 sizes (<code>00-shared/10 §9</code>)</td></tr> <tr><td>Integration</td><td>journey: create user → create student → detail shows ACTIVE → transfer → history shows transferred; import 50-row CSV happy+failed mix</td></tr> <tr><td>E2E (device cloud)</td><td>P0: roster → create → documents upload → link parent (<code>00-shared/10 §9</code>)</td></tr> </tbody></table> </div> <h2 id="7-dependencies-to-add-module-scoped-verify-licenses"><a class="header" href="#7-dependencies-to-add-module-scoped-verify-licenses">7. Dependencies to add (module-scoped, verify licenses)</a></h2> <ul> <li><code>csv</code> (parse client previews) — or hand-roll minimal splitter for header check (YAGNI: start with <code>csv</code>, it handles quotes correctly).</li> <li>No chart/markdown/QR deps needed in this module.</li> </ul> <h2 id="8-perf-notes"><a class="header" href="#8-perf-notes">8. Perf notes</a></h2> <ul> <li><code>ListView.builder</code> + <code>const</code> tiles; <code>RepaintBoundary</code> on timeline; avatar images cached/resized (<code>cached_network_image</code>).</li> <li>Tab keep-alive with lazy first-build (documents/history fetch on first activation only).</li> <li>Import parse off main isolate for ≥ 500 rows; UI never blocks.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-teachers-module"><a class="header" href="#01--product-overview-teachers-module">01 — Product Overview (Teachers Module)</a></h1> <ul> <li><a href="teachers/01_Product_Overview.html#1-purpose">1. Purpose</a></li> <li><a href="teachers/01_Product_Overview.html#2-module-boundary-teachers-vs-staff">2. Module boundary: Teachers vs Staff</a></li> <li><a href="teachers/01_Product_Overview.html#3-teacher-lifecycle-end-to-end">3. Teacher lifecycle (end-to-end)</a></li> <li><a href="teachers/01_Product_Overview.html#4-subject--class-assignment--two-mechanisms-important">4. Subject & class assignment — two mechanisms (important)</a></li> <li><a href="teachers/01_Product_Overview.html#5-permissions-reality-read-carefully">5. Permissions reality (read carefully)</a></li> <li><a href="teachers/01_Product_Overview.html#6-events--side-effects">6. Events & side-effects</a></li> <li><a href="teachers/01_Product_Overview.html#7-dependencies">7. Dependencies</a></li> <li><a href="teachers/01_Product_Overview.html#8-business-goals">8. Business goals</a></li> <li><a href="teachers/01_Product_Overview.html#9-success-metrics">9. Success metrics</a></li> <li><a href="teachers/01_Product_Overview.html#10-edge-cases-derived">10. Edge cases (derived)</a></li> <li><a href="teachers/01_Product_Overview.html#11-assumptions-module">11. Assumptions (module)</a></li> <li><a href="teachers/01_Product_Overview.html#12-open-questions-module-level">12. Open questions (module-level)</a></li> </ul> <blockquote> <p>StudyLyon — multi-tenant ERP / School Management API. This package designs the <strong>Teachers module client</strong> (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, events, and business rules are derived directly from <code>src/modules/teachers/**</code>, <code>src/modules/academics/**</code> (subject assignments), <code>src/modules/staff/**</code>, <code>src/modules/rbac/**</code>, and <code>src/infrastructure/**</code>. No feature is invented; gaps are flagged in <strong>Assumptions & Open Questions</strong> and marked <code>(planned)</code> / <code>(proposed)</code>.</p> </blockquote> <hr /> <h2 id="1-purpose-25"><a class="header" href="#1-purpose-25">1. Purpose</a></h2> <p>Teachers is the <strong>teaching-profile domain</strong>. It answers "who teaches here" for an institution: the employment record of every instructor (linked to a <code>User</code> identity), their department/designation, employment status, qualifications, the <strong>subjects they teach</strong> and the <strong>classes they are class-teacher for</strong>, plus — via the <code>subject_assignments</code> collection owned by the Academics module — the per-academic-year matrix of <em>which teacher teaches which subject in which class</em>.</p> <div class="table-wrapper"><table><thead><tr><th>Responsibility</th><th>Source</th></tr></thead><tbody> <tr><td>Teacher profile CRUD (create/read/update/deactivate)</td><td><code>teacher.controller.ts:24-38</code></td></tr> <tr><td>Duplicate guards (userId, employeeNumber) on create</td><td><code>teacher.service.ts:27-38</code></td></tr> <tr><td>Soft-delete (deactivate) with <code>isDeleted</code> flag</td><td><code>base.repository.ts:68-74</code>, <code>teacher.service.ts:95-106</code></td></tr> <tr><td>Employment status enum (<code>active/inactive/on_leave/terminated</code>)</td><td><code>teacher.schema.ts:7-12</code></td></tr> <tr><td>Subject ↔ class ↔ academic-year teaching matrix</td><td><code>subject-assignment.controller.ts:21-38</code>, <code>subject-assignment.schema.ts:9-19</code></td></tr> <tr><td>Denormalized subject/class-teacher references on profile</td><td><code>teacher.schema.ts:44-48</code></td></tr> <tr><td>Domain events → in-app notification + audit + search index</td><td><code>teacher.service.ts:40-50,84-91,98-105</code>; <code>event-queue-map.ts:31-33</code>; <code>search-indexer.service.ts:10,17,24</code></td></tr> <tr><td>Teacher count KPI for dashboard</td><td><code>dashboard.service.ts:32,52</code></td></tr> </tbody></table> </div> <h2 id="2-module-boundary-teachers-vs-staff"><a class="header" href="#2-module-boundary-teachers-vs-staff">2. Module boundary: Teachers vs Staff</a></h2> <p>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:</p> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Teacher (<code>teachers</code>)</th><th>Staff (<code>staff</code>)</th></tr></thead><tbody> <tr><td>Blueprint identity</td><td>"Teaching profile." (<code>COLLECTIONS.md:1418-1422</code>)</td><td>"Non-teaching employees." (<code>blueprint 04-Modules/Staff.md:3</code>)</td></tr> <tr><td>Employment fields</td><td><code>employmentStatus</code>, <code>qualification</code>, <code>experienceYears</code> (<code>teacher.schema.ts:28-42</code>)</td><td><code>employmentType</code>, <code>salaryGrade</code> (<code>staff.schema.ts:35-47</code>)</td></tr> <tr><td>Academic linkage</td><td><code>subjects[]</code>, <code>classTeacherFor[]</code> (<code>teacher.schema.ts:44-48</code>)</td><td>none</td></tr> <tr><td>Unique constraints</td><td><code>tenantId+userId</code>, <code>tenantId+employeeNumber</code> (<code>teacher.schema.ts:56-57</code>)</td><td><code>tenantId+userId</code>, <code>tenantId+employeeNumber</code> (<code>staff.schema.ts:57-58</code>)</td></tr> <tr><td>CRUD surface</td><td><code>teacher.controller.ts:24-38</code></td><td><code>staff.controller.ts:24-38</code></td></tr> <tr><td>Events</td><td><code>TeacherCreated/Updated/Deleted</code></td><td><code>StaffCreated/Updated/Deleted</code></td></tr> <tr><td>Reference catalogs</td><td>departments/designations <strong>owned by Staff module</strong> (<code>Staff.md:59</code>)</td><td>departments/designations owned here</td></tr> <tr><td>Status field name</td><td><code>employmentStatus</code> (enum <code>EmploymentStatus</code>)</td><td><code>status</code> (enum <code>StaffStatus</code>) — same 4 values</td></tr> </tbody></table> </div> <p>A single <code>users</code> record may own a teacher profile <em>and</em> a staff profile (e.g. a teacher who also works the front office): <code>RELATIONSHIPS.md:32-33</code> ("A single <code>users</code> record may own multiple profiles"). Both can even coexist with the same <code>userId</code> because they are separate collections with separate uniqueness scopes. The client must treat them as <strong>two different records</strong> — the Teachers screen never renders Staff records, and vice versa.</p> <h2 id="3-teacher-lifecycle-end-to-end"><a class="header" href="#3-teacher-lifecycle-end-to-end">3. Teacher lifecycle (end-to-end)</a></h2> <pre><code>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) </code></pre> <p>Deactivation is <strong>permanent from the client's perspective</strong> — there is no re-activation endpoint. <code>DELETE</code> sets <code>isDeleted: true</code> + <code>deletedAt</code> + <code>deletedBy</code> (<code>base.repository.ts:68-74</code>); every subsequent query excludes the record (<code>base.repository.ts:20-30</code>).</p> <h2 id="4-subject--class-assignment--two-mechanisms-important"><a class="header" href="#4-subject--class-assignment--two-mechanisms-important">4. Subject & class assignment — two mechanisms (important)</a></h2> <p>The backend has <strong>two parallel, non-synchronized</strong> ways to link a teacher to subjects/classes:</p> <ol> <li><strong>Profile arrays</strong> — <code>teacher.subjects[]</code> (ref <code>Subject</code>) and <code>teacher.classTeacherFor[]</code> (ref <code>Class</code>), set via <code>POST /teachers</code> or <code>PATCH /teachers/:id</code> (<code>create-teacher.dto.ts:44-52</code>, <code>teacher.schema.ts:44-48</code>). These are <strong>denormalized profile data</strong> — there is no server logic that reads them for scheduling.</li> <li><strong><code>subject_assignments</code> collection</strong> — one document per (teacher, subject, class, academicYear) triple: <code>subject-assignment.schema.ts:9-19</code>, managed by <code>POST /subject-assignments</code>, <code>GET /subject-assignments/by-class/:classId</code>, <code>GET /subject-assignments/by-teacher/:teacherId</code>, <code>DELETE /subject-assignments/:id</code> (<code>subject-assignment.controller.ts:21-38</code>). This is the <strong>authoritative teaching matrix</strong> per academic year (<code>RELATIONSHIPS.md:82-92</code>).</li> </ol> <p>Additionally <code>classes.classTeacherId</code> exists (<code>class.schema.ts:28</code>) as a <strong>third</strong> class-teacher reference. The three stores are <strong>not kept in sync by any code</strong> — the client must decide which is canonical for which surface (see OQ-4).</p> <h2 id="5-permissions-reality-read-carefully"><a class="header" href="#5-permissions-reality-read-carefully">5. Permissions reality (read carefully)</a></h2> <ul> <li><code>permissions.constants.ts</code> contains <strong>no <code>teacher.*</code> permissions</strong> — the teachers endpoints carry none of <code>staff.*</code>, <code>department.manage</code>, <code>designation.manage</code> either (<code>permissions.constants.ts:19-24</code>).</li> <li><code>TeacherController</code> is guarded <strong>only</strong> by <code>@UseGuards(JwtAuthGuard)</code> (<code>teacher.controller.ts:18-21</code>). The global <code>RbacGuard</code> (<code>app.module.ts:131</code>) passes any request that carries <strong>no <code>@Roles</code>/<code>@Permissions</code> metadata</strong> (<code>rbac.guard.ts:29</code>).</li> <li>The default <code>teacher</code> role ships with <code>['student.read','attendance.mark','attendance.edit']</code> (<code>role.schema.ts:26-32</code>) — <strong>nothing that lets a teacher read their own profile via <code>GET /teachers/:id</code> is gated server-side</strong>; the client must enforce "teacher sees own record only" in UI/routing (OQ-1).</li> <li><code>docs/user-flows/END_TO_END_USER_FLOWS.md:751</code> anticipates <code>teacher.*</code> permissions ("✅ Admin, Read own — Teacher") — this is a <strong>doc-only contract, not implemented</strong>.</li> </ul> <h2 id="6-events--side-effects"><a class="header" href="#6-events--side-effects">6. Events & side-effects</a></h2> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Emitted at</th><th>Queue route</th><th>Side-effect</th></tr></thead><tbody> <tr><td><code>TeacherCreated</code></td><td><code>teacher.service.ts:40-50</code> (payload <code>{teacherId, employeeNumber}</code>)</td><td><code>in-app</code> / job <code>teacher-created</code> (<code>event-queue-map.ts:31</code>)</td><td>In-app notification (<code>inapp.worker.ts:46-53</code>); search index <code>Teacher</code> (<code>search-indexer.service.ts:10</code>)</td></tr> <tr><td><code>TeacherUpdated</code></td><td><code>teacher.service.ts:84-91</code> (payload <code>{teacherId}</code>)</td><td><code>audit-write</code> / <code>log-teacher-updated</code> (<code>event-queue-map.ts:32</code>)</td><td>Audit log; search re-index</td></tr> <tr><td><code>TeacherDeleted</code></td><td><code>teacher.service.ts:98-105</code> (payload <code>{teacherId}</code>)</td><td><code>audit-write</code> / <code>log-teacher-deleted</code> (<code>event-queue-map.ts:33</code>)</td><td>Audit log; search index removal (<code>search-indexer.service.ts:52-59</code>)</td></tr> </tbody></table> </div> <p>Two derivable gaps (see 14-QA): <code>PLAN.md:36</code> promises "→ email queue → welcome email" but the actual mapping sends <code>TeacherCreated</code> to the <strong><code>in-app</code></strong> queue, not <code>emails</code>; and <code>inapp.worker.ts:47</code> stores <code>type: 'TeacherCreated'</code> into a notification whose <code>NotificationType</code> enum only admits <code>email_verified|password_reset|verification_resent|welcome</code> (<code>notification.schema.ts:7-12</code>) — an enum-mismatch risk.</p> <h2 id="7-dependencies"><a class="header" href="#7-dependencies">7. Dependencies</a></h2> <div class="table-wrapper"><table><thead><tr><th>Dependency</th><th>Role</th><th>Source</th></tr></thead><tbody> <tr><td>Users module</td><td>identity link (<code>userId</code> → firstName/lastName/email)</td><td><code>user.schema.ts:17-29</code>; <code>RELATIONSHIPS.md:22-30</code></td></tr> <tr><td>Staff module (Academics-owned)</td><td><code>departments</code>, <code>designations</code> catalogs</td><td><code>staff.controller.ts</code> (departments/designations controllers), <code>department.schema.ts:9-19</code></td></tr> <tr><td>Academics module</td><td><code>subjects</code>, <code>classes</code>, <code>academic_years</code>, <code>subject_assignments</code></td><td><code>subject.schema.ts:9-34</code>, <code>class.schema.ts:9-34</code>, <code>academic-year.schema.ts:14-32</code></td></tr> <tr><td>Academics: class teacher</td><td><code>classes.classTeacherId</code></td><td><code>class.schema.ts:28</code></td></tr> <tr><td>Timetable module</td><td>teacher schedule (<code>GET /timetable?teacherId=</code>)</td><td><code>timetable.controller.ts:21-27</code>, <code>timetable.service.ts:55-60</code></td></tr> <tr><td>Dashboard module</td><td>teacher count KPI</td><td><code>dashboard.service.ts:32,52</code></td></tr> <tr><td>Leave module</td><td>substitute-teacher flows reference <code>teacherRepo</code></td><td><code>leave.service.ts:232-277</code></td></tr> <tr><td>BullMQ <code>in-app</code> / <code>audit-write</code></td><td>event side-effects</td><td><code>queue.constants.ts:5,11</code>, <code>event-queue-map.ts:31-33</code></td></tr> <tr><td>Mongo collections</td><td><code>teachers</code>, <code>subject_assignments</code>, <code>subjects</code>, <code>classes</code>, <code>academic_years</code>, <code>departments</code>, <code>designations</code></td><td><code>COLLECTIONS.md:1418,1644</code></td></tr> </tbody></table> </div> <h2 id="8-business-goals"><a class="header" href="#8-business-goals">8. Business goals</a></h2> <div class="table-wrapper"><table><thead><tr><th>Goal</th><th>Measure</th></tr></thead><tbody> <tr><td>Create a teacher in < 1 min of form time</td><td>2 server calls max (user exists → create profile)</td></tr> <tr><td>No duplicate teachers</td><td>409 on duplicate <code>userId</code> / <code>employeeNumber</code> (<code>teacher.service.ts:27-38</code>)</td></tr> <tr><td>Assignment matrix accurate per academic year</td><td><code>subject_assignments</code> scoped by <code>academicYearId</code> in every query (<code>subject-assignment.service.ts:19-31</code>)</td></tr> <tr><td>Deactivation is safe & reversible by ops</td><td>soft-delete only; nothing is physically removed</td></tr> <tr><td>Tenant isolation structural</td><td>every repo call injects <code>tenantId</code> + <code>isDeleted:false</code> (<code>base.repository.ts:20-30</code>)</td></tr> </tbody></table> </div> <h2 id="9-success-metrics"><a class="header" href="#9-success-metrics">9. Success metrics</a></h2> <ul> <li>Teacher creation → visible in list + dashboard count ≤ 3 s after submit.</li> <li>0 duplicate employeeNumber errors for > 99% of form flows (client validates against existing list + server 409 handles races).</li> <li>Assignment editor: no silent conflicts — server has <strong>no conflict check</strong> on <code>POST /subject-assignments</code> (<code>subject-assignment.service.ts:13-17</code>) so the client must surface duplicates itself (OQ-2).</li> <li>Deactivate flow: confirmation dialog → success in 1 call, teacher disappears from all lists (soft-delete) but audit/search retention honored.</li> </ul> <h2 id="10-edge-cases-derived"><a class="header" href="#10-edge-cases-derived">10. Edge cases (derived)</a></h2> <ul> <li>Duplicate <code>userId</code> → 409 "Teacher profile already exists for this user." (<code>teacher.service.ts:29-31</code>).</li> <li>Duplicate <code>employeeNumber</code> → 409 "Employee number "X" already exists." (<code>teacher.service.ts:35-38</code>).</li> <li><code>GET /teachers/:id</code> unknown id → 404 "Teacher not found." (<code>teacher.service.ts:56</code>).</li> <li><code>DELETE</code> unknown id → 404 (softDelete returns false → throw) (<code>teacher.service.ts:96-97</code>).</li> <li><code>PATCH</code> unknown id → 404 via <code>findById</code> pre-check (<code>teacher.service.ts:81</code>).</li> <li><code>GET /subject-assignments/by-teacher/:teacherId</code> <strong>without</strong> <code>academicYearId</code> → filter is <code>{teacherId, academicYearId: undefined}</code> → empty result (query param is required in practice though not validated) (<code>subject-assignment.service.ts:26-31</code>).</li> <li><code>DELETE /subject-assignments/:id</code> unknown id → 404 "Assignment not found." (<code>subject-assignment.service.ts:34-35</code>).</li> <li><code>PATCH /teachers/:id</code> cannot change <code>userId</code> (not in <code>UpdateTeacherDto</code>) — identity link is immutable via this API.</li> <li><code>sort</code> and <code>q</code> params are accepted by <code>PaginationQueryDto</code> but <strong>not applied</strong> in <code>TeacherService.find</code> (<code>teacher.service.ts:66-78</code>) — client-side search/sort needed or backend enhancement (OQ-3).</li> </ul> <h2 id="11-assumptions-module-1"><a class="header" href="#11-assumptions-module-1">11. Assumptions (module)</a></h2> <ul> <li>Client is <strong>forward-looking</strong>: backend is complete for this module; these docs are the UI-side spec, consistent with shared ledger A1 (<code>00-shared/12</code>) — PRD lists native mobile as a later-phase, read-only companion; this package specs a full client per user instruction.</li> <li><code>subjects[]</code>/<code>classTeacherFor[]</code> arrays and <code>subject_assignments</code> are 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).</li> <li>No bulk-import surface exists for teachers (<code>BulkModule</code> exists at <code>app.module.ts:59</code> but teacher import is not implemented) → bulk import is <code>(planned)</code>, not specced as v1.</li> <li>Teacher photo/avatar: no photo field exists on <code>Teacher</code> or <code>User</code> in scope — avatar rendering uses initials fallback (<code>AppAvatar</code>, <code>03_Component_Library.md</code>).</li> </ul> <h2 id="12-open-questions-module-level"><a class="header" href="#12-open-questions-module-level">12. Open questions (module-level)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Item</th><th>Impact</th></tr></thead><tbody> <tr><td>OQ-1</td><td>No <code>teacher.*</code> permissions exist and no endpoint carries RBAC metadata — who may view/edit which teacher? Is a self-service "my profile" endpoint (<code>GET /teachers/me</code>) planned?</td><td>Screen access rules, permission UI</td></tr> <tr><td>OQ-2</td><td><code>POST /subject-assignments</code> has no duplicate/conflict guard (<code>subject-assignment.service.ts:13-17</code>) — duplicate (teacher,subject,class,year) is storable. Client-side dedup only, or backend unique index?</td><td>Assignment editor UX</td></tr> <tr><td>OQ-3</td><td><code>sort</code>/<code>q</code> ignored by <code>TeacherService.find</code> (<code>teacher.service.ts:66-78</code>) — implement client-side filter/sort or extend backend?</td><td>List screen</td></tr> <tr><td>OQ-4</td><td>Three class-teacher sources (<code>teacher.classTeacherFor[]</code>, <code>classes.classTeacherId</code>, none in assignments) — which is canonical for "class teacher" display?</td><td>Detail tabs</td></tr> <tr><td>OQ-5</td><td>Deactivation doesn't check active assignments/timetable/substitutions — deactivating a teacher with a live schedule is allowed. Guard added where?</td><td>Deactivate dialog copy</td></tr> <tr><td>OQ-6</td><td><code>TeacherCreated</code> in-app notification writes a <code>type</code> outside the <code>NotificationType</code> enum (<code>notification.schema.ts:7-12</code> vs <code>inapp.worker.ts:47</code>) — likely DB validation failure; needs backend fix before notifications render.</td><td>Notification UX</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-teachers-module"><a class="header" href="#02--user-personas-teachers-module">02 — User Personas (Teachers Module)</a></h1> <ul> <li><a href="teachers/02_User_Personas.html#1-organization-admin">1. Organization Admin</a></li> <li><a href="teachers/02_User_Personas.html#2-hr-admin-staffpeople-operations">2. HR Admin (staff/people operations)</a></li> <li><a href="teachers/02_User_Personas.html#3-teacher-self">3. Teacher (self)</a></li> <li><a href="teachers/02_User_Personas.html#4-academic-coordinator-teaching-pattern-oversight">4. Academic Coordinator (teaching-pattern oversight)</a></li> <li><a href="teachers/02_User_Personas.html#5-persona--surface-map">5. Persona → surface map</a></li> </ul> <blockquote> <p>Derived from <code>blueprint/01-Product/USER_PERSONAS.md</code> (tenant-level actors) and the implemented RBAC role seed (<code>src/modules/rbac/schemas/role.schema.ts:8-65</code>). The blueprint defines <strong>Organization Admin</strong>, <strong>Teacher</strong>, and <strong>Staff (Non-Teaching)</strong> personas; <strong>HR Admin</strong> and <strong>Academic Coordinator</strong> are role-level specializations of Staff/Teacher inferred from blueprint descriptions and module access patterns — flagged as derived, not literal blueprint personas.</p> </blockquote> <hr /> <h2 id="1-organization-admin"><a class="header" href="#1-organization-admin">1. Organization Admin</a></h2> <ul> <li><strong>Blueprint anchor:</strong> "Manages a single institution (tenant). Goals: Configure branches, invite staff, oversee operations. Pain: Wants one dashboard for everything. Access: Full tenant admin, configurable permissions." (<code>USER_PERSONAS.md:20-25</code>)</li> <li><strong>System role:</strong> <code>org_admin</code> — <code>permissions: ALL_PERMISSIONS</code> (<code>role.schema.ts:17-24</code>). <strong>Note:</strong> there are no <code>teacher.*</code> perms in the system (<code>permissions.constants.ts:1-97</code>) — the admin's access to teachers is inherited as <em>everything</em>, not by explicit permission.</li> <li><strong>Goals:</strong> onboard teachers fast (create → assign → ready to teach), keep the staff roster accurate, deactivate leavers safely, see teacher counts on dashboard (<code>dashboard.service.ts:32,52</code>).</li> <li><strong>Pain points:</strong> duplicate employee numbers cause 409 on create (<code>teacher.service.ts:32-38</code>); no server-side assignment conflict check (<code>subject-assignment.service.ts:13-17</code>) means errors surface late; <code>sort</code>/<code>q</code> query params are ignored (<code>teacher.service.ts:66-78</code>) so large rosters are hard to scan.</li> <li><strong>Context:</strong> desktop-first, but reviews notifications on phone (in-app <code>teacher-created</code> notifications, <code>event-queue-map.ts:31</code>).</li> <li><strong>Success:</strong> roster complete within one session; every new teacher has subjects + classes before their first class.</li> </ul> <h2 id="2-hr-admin-staffpeople-operations"><a class="header" href="#2-hr-admin-staffpeople-operations">2. HR Admin (staff/people operations)</a></h2> <ul> <li><strong>Blueprint anchor:</strong> Staff persona — "Operations, reception, coordinators. Goals: Manage profiles, support admins. Pain: Needs read access across modules. Access: Role-scoped, often read-heavy." (<code>USER_PERSONAS.md:34-39</code>) — HR is the profile-lifecycle operator within that persona. <em>(derived)</em></li> <li><strong>System role:</strong> custom <code>staff</code> role — <code>permissions: ['student.read']</code> (<code>role.schema.ts:34-36</code>); or a custom role with <code>staff.read/update</code> per <code>permissions.constants.ts:19-22</code> (client-side gating only, see <a href="teachers/01_Product_Overview.html">01 §5</a>).</li> <li><strong>Goals:</strong> create teacher profiles from onboarding paperwork (employee number, department, designation, joining date), update qualifications/experience, mark status changes, deactivate leavers.</li> <li><strong>Pain points:</strong> teacher form requires a pre-existing <strong>User</strong> (<code>userId</code> is the only required identity field, <code>create-teacher.dto.ts:5-7</code>) — HR must create the user in Users module first (cross-module flow, OQ-1).</li> <li><strong>Context:</strong> mostly desktop web; repeated entry; values consistency (employeeNumber format like <code>TCH001</code>, <code>create-teacher.dto.ts:9</code>).</li> </ul> <h2 id="3-teacher-self"><a class="header" href="#3-teacher-self">3. Teacher (self)</a></h2> <ul> <li><strong>Blueprint anchor:</strong> "Delivers instruction. Goals: Mark attendance, assign homework, enter exam marks. Pain: Wants minimal clicks; mobile-friendly. Access: Own classes, own students." (<code>USER_PERSONAS.md:27-32</code>); module map: "Teacher | Attendance, Homework, Exams, Results" (<code>USER_PERSONAS.md:85</code>).</li> <li><strong>System role:</strong> <code>teacher</code> — <code>permissions: ['student.read','attendance.mark','attendance.edit']</code> (<code>role.schema.ts:26-32</code>). <em><em>No teacher.</em> permission, no self-profile endpoint</em>* (OQ-2) — "My schedule" is reachable via <code>GET /timetable?teacherId=<me></code> (<code>timetable.controller.ts:21-27</code>) and <code>GET /subject-assignments/by-teacher/<me></code> (<code>subject-assignment.controller.ts:30-35</code>) using their own teacher id.</li> <li><strong>Goals:</strong> 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.</li> <li><strong>Pain points:</strong> no self-service screen exists in the backend — the client must compose one from assignments + timetable; status <code>on_leave</code> should suppress schedule emphasis but nothing server-side does that.</li> <li><strong>Context:</strong> phone-first during school hours; check "what do I teach / where do I go" in < 10 s.</li> </ul> <h2 id="4-academic-coordinator-teaching-pattern-oversight"><a class="header" href="#4-academic-coordinator-teaching-pattern-oversight">4. Academic Coordinator (teaching-pattern oversight)</a></h2> <ul> <li><strong>Blueprint anchor:</strong> derived from Staff persona ("coordinators… read access across modules", <code>USER_PERSONAS.md:34-39</code>) + the Academics module being the assignment owner (<code>subject-assignment.controller.ts:16-17</code>). <em>(derived — not a literal blueprint persona)</em></li> <li><strong>System role:</strong> custom role holding read perms (<code>student.read</code>, <code>timetable.read</code>, <code>settings.read</code> where granted) — again client-gated.</li> <li><strong>Goals:</strong> 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.</li> <li><strong>Pain points:</strong> duplicate assignments are storable (no unique index, <code>subject-assignment.schema.ts:24-25</code>), so a coordinator can double-assign; two assignment stores (<code>teacher.subjects[]</code> vs <code>subject_assignments</code>) can disagree.</li> <li><strong>Context:</strong> desktop, term-start bursts (August–September), report-style views.</li> </ul> <hr /> <h2 id="5-persona--surface-map"><a class="header" href="#5-persona--surface-map">5. Persona → surface map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Persona</th><th>Primary surfaces (this module)</th><th>Key endpoints</th></tr></thead><tbody> <tr><td>Org Admin</td><td>Teachers list, detail, create/edit, deactivate, dashboard KPI</td><td><code>GET/POST/PATCH/DELETE /teachers</code> (<code>teacher.controller.ts:24-38</code>)</td></tr> <tr><td>HR Admin</td><td>Create/edit teacher, status changes, deactivate</td><td><code>POST/PATCH/DELETE /teachers</code></td></tr> <tr><td>Teacher (self)</td><td>My profile, My assignments, My schedule</td><td><code>GET /subject-assignments/by-teacher/:teacherId</code> (<code>subject-assignment.controller.ts:30</code>), <code>GET /timetable?teacherId=</code> (<code>timetable.controller.ts:21-27</code>)</td></tr> <tr><td>Academic Coordinator</td><td>Assignment editor, by-class view</td><td><code>POST /subject-assignments</code>, <code>GET /subject-assignments/by-class/:classId</code> (<code>subject-assignment.controller.ts:21-29</code>)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journeys-teachers-module"><a class="header" href="#03--user-journeys-teachers-module">03 — User Journeys (Teachers Module)</a></h1> <ul> <li><a href="teachers/03_User_Journey.html#journey-1--create-a-teacher-org-admin--hr">Journey 1 — Create a teacher (Org Admin / HR)</a></li> <li><a href="teachers/03_User_Journey.html#journey-2--assign-subjectsclasses-org-admin--academic-coordinator">Journey 2 — Assign subjects/classes (Org Admin / Academic Coordinator)</a></li> <li><a href="teachers/03_User_Journey.html#journey-3--edit-teacher-profile-hr--admin">Journey 3 — Edit teacher profile (HR / Admin)</a></li> <li><a href="teachers/03_User_Journey.html#journey-4--deactivate-a-teacher-hr--admin">Journey 4 — Deactivate a teacher (HR / Admin)</a></li> <li><a href="teachers/03_User_Journey.html#journey-5--teacher-self-view-profile-assignments-schedule-teacher">Journey 5 — Teacher self-view: profile, assignments, schedule (Teacher)</a></li> </ul> <blockquote> <p>Five journeys mapped 1:1 to the implemented API. Endpoint citations: <code>teacher.controller.ts:24-38</code>, <code>subject-assignment.controller.ts:21-38</code>, <code>timetable.controller.ts:21-27</code>. <code>(planned)</code> = roadmap/documented but not in code.</p> </blockquote> <hr /> <h2 id="journey-1--create-a-teacher-org-admin--hr"><a class="header" href="#journey-1--create-a-teacher-org-admin--hr">Journey 1 — Create a teacher (Org Admin / HR)</a></h2> <pre class="mermaid">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] </pre> <ul> <li>Steps: <code>POST /teachers</code> with <code>CreateTeacherDto</code> (<code>create-teacher.dto.ts:4-52</code>).</li> <li>Business rules: <code>findByUserId</code> → 409 (<code>teacher.service.ts:27-31</code>); <code>findByEmployeeNumber</code> → 409 (<code>teacher.service.ts:32-38</code>).</li> <li>Side-effects: <code>TeacherCreated</code> → <code>in-app</code> queue job <code>teacher-created</code> (<code>event-queue-map.ts:31</code>) → notification (<code>inapp.worker.ts:46-53</code>); search index upsert (<code>search-indexer.service.ts:10,90-96</code>).</li> <li><strong>Note:</strong> <code>PLAN.md:36</code> says "→ email queue → welcome email" — the code routes to <code>in-app</code>, not <code>emails</code> (see <code>01 §6</code>, QA-12).</li> <li>Failure exits: 401 (expired token → refresh), 429 (backoff), 5xx (generic + requestId).</li> </ul> <h2 id="journey-2--assign-subjectsclasses-org-admin--academic-coordinator"><a class="header" href="#journey-2--assign-subjectsclasses-org-admin--academic-coordinator">Journey 2 — Assign subjects/classes (Org Admin / Academic Coordinator)</a></h2> <pre class="mermaid">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] </pre> <ul> <li>Sources: <code>subject-assignment.service.ts:13-17</code> (create — <strong>no server dup check</strong>), <code>subject-assignment.service.ts:26-31</code> (byTeacher, requires <code>academicYearId</code>), <code>subject-assignment.schema.ts:24-25</code> (non-unique indexes).</li> <li><code>academicYearId</code> comes from <code>GET /academic-years</code> (<code>academic-year.controller.ts:27-30</code>) with <code>isCurrent</code> flag (<code>academic-year.schema.ts:31-32</code>).</li> <li>Class picker: <code>GET /classes?academicYearId</code> (<code>class.controller.ts:27-33</code>); subject picker: <code>GET /subjects</code> (<code>subject.controller.ts:27-30</code>).</li> </ul> <h2 id="journey-3--edit-teacher-profile-hr--admin"><a class="header" href="#journey-3--edit-teacher-profile-hr--admin">Journey 3 — Edit teacher profile (HR / Admin)</a></h2> <pre class="mermaid">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] </pre> <ul> <li><code>UpdateTeacherDto</code> (<code>update-teacher.dto.ts:4-53</code>) — <code>userId</code> immutable, <code>metadata</code> added (only in update DTO, <code>update-teacher.dto.ts:51-53</code>).</li> <li>Side-effect: <code>TeacherUpdated</code> → <code>audit-write</code> (<code>event-queue-map.ts:32</code>) + search re-index (<code>search-indexer.service.ts:17</code>).</li> <li>Server pre-check <code>findById</code> before <code>$set</code> (<code>teacher.service.ts:80-83</code>) → PATCH of a soft-deleted teacher returns 404 automatically (soft-delete scope).</li> </ul> <h2 id="journey-4--deactivate-a-teacher-hr--admin"><a class="header" href="#journey-4--deactivate-a-teacher-hr--admin">Journey 4 — Deactivate a teacher (HR / Admin)</a></h2> <pre class="mermaid">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)] </pre> <ul> <li>Soft-delete mechanics: <code>base.repository.ts:68-74</code> sets <code>isDeleted/deletedAt/deletedBy</code>, all reads exclude (<code>base.repository.ts:20-30</code>).</li> <li><strong>No server-side guard</strong> on active assignments/timetable/substitutions (OQ-5) — dialog copy must warn that schedule data is <em>not</em> cleaned up.</li> <li>No re-activation endpoint exists → deactivate is irreversible via API (manual DB restore only). Dialog states this.</li> </ul> <h2 id="journey-5--teacher-self-view-profile-assignments-schedule-teacher"><a class="header" href="#journey-5--teacher-self-view-profile-assignments-schedule-teacher">Journey 5 — Teacher self-view: profile, assignments, schedule (Teacher)</a></h2> <pre class="mermaid">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)] </pre> <ul> <li><code>teacherId</code> for self: client resolves from <code>GET /teachers/:id</code>? There is <strong>no <code>/teachers/me</code></strong> — client needs the teacher record linked to the logged-in user (OQ-2). Options: resolve via <code>GET /teachers</code> list scan (O(n) — bad), or a <code>GET /teachers/:id</code> per recorded id; backend support for "me" is <code>(planned)</code>.</li> <li>Schedule source: <code>GET /timetable?teacherId=</code> (<code>timetable.controller.ts:21-27</code>) — note the controller's query param is <code>teacherId</code>, so <code>teacherId=me</code> string from <code>END_TO_END_USER_FLOWS.md:266</code> is <strong>doc-fiction</strong>; the client must send the real id.</li> <li>Sort is server-side: <code>{dayOfWeek:1, startTime:1}</code> (<code>timetable.service.ts:56-59</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-teachers-module"><a class="header" href="#04--information-architecture-teachers-module">04 — Information Architecture (Teachers Module)</a></h1> <ul> <li><a href="teachers/04_Information_Architecture.html#1-placement-in-the-app-shell">1. Placement in the app shell</a></li> <li><a href="teachers/04_Information_Architecture.html#2-sitemap-client-this-module">2. Sitemap (client, this module)</a></li> <li><a href="teachers/04_Information_Architecture.html#3-screen-relationships">3. Screen relationships</a></li> <li><a href="teachers/04_Information_Architecture.html#4-navigation-details">4. Navigation details</a></li> <li><a href="teachers/04_Information_Architecture.html#5-role--surface-gating-client-side-server-enforces-jwt-only--01-5">5. Role → surface gating (client-side; server enforces JWT only — <code>01 §5</code>)</a></li> <li><a href="teachers/04_Information_Architecture.html#6-information-units-primary-entities">6. Information units (primary entities)</a></li> <li><a href="teachers/04_Information_Architecture.html#7-empty-state-strategy">7. Empty-state strategy</a></li> </ul> <blockquote> <p>Fits the shared shell (<a href="teachers/../00-shared/05_Global_Information_Architecture.html">00-shared/05_Global_Information_Architecture.md</a>). Teachers is a <strong>Staff-adjacent, Academics-linked</strong> module: routes live under a <code>People</code>/<code>Staff</code> destination, while assignment data lives in the Academics domain.</p> </blockquote> <hr /> <h2 id="1-placement-in-the-app-shell-3"><a class="header" href="#1-placement-in-the-app-shell-3">1. Placement in the app shell</a></h2> <ul> <li><strong>Primary home:</strong> <code>Staff</code> destination (<code>/staff</code>, role-gated admin/HR per <a href="teachers/05_Screen_Inventory.html">05_Global_Information_Architecture.md:39</a>) — teachers and staff share the "people/employment" cluster (same user+department+designation model).</li> <li><strong>Teacher self surface:</strong> <code>(planned)</code> dedicated "My Teaching" entry for the <code>teacher</code> role — today the client composes it from timetable + assignments (<a href="teachers/03_User_Journey.html">03 §5</a>). Not a top-level destination in the shared IA (Teachers isn't listed there at all — derivation: only <code>Staff</code> exists).</li> <li><strong>Cross-links:</strong> Academics (subjects/classes/years), Timetable (schedule), Users (identity), Notifications (teacher-created), Dashboard (count KPI).</li> </ul> <h2 id="2-sitemap-client-this-module"><a class="header" href="#2-sitemap-client-this-module">2. Sitemap (client, this module)</a></h2> <pre><code>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 </code></pre> <h2 id="3-screen-relationships"><a class="header" href="#3-screen-relationships">3. Screen relationships</a></h2> <ul> <li>List → Detail (push on phone; master-detail right-pane ≥ 840 dp, <code>05_Global_Information_Architecture.md:58</code>).</li> <li>Detail → Edit form (returns → detail refresh).</li> <li>Detail → Assignment editor (bottom sheet on phone, dialog on desktop, <code>03_Component_Library.md</code> AppBottomSheet).</li> <li>Detail → Schedule tab (client-side tab within detail; data from Timetable module).</li> <li>Deactivate is a <strong>dialog on the detail screen</strong> (destructive, <code>AppDialog</code>), not a page.</li> </ul> <h2 id="4-navigation-details"><a class="header" href="#4-navigation-details">4. Navigation details</a></h2> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Rule</th></tr></thead><tbody> <tr><td>Route params</td><td><code>:id</code> = teacher <code>_id</code> (Mongo ObjectId, <code>teacher.schema.ts</code>)</td></tr> <tr><td>Tab state</td><td><code>StatefulShellBranch</code> keeps tab index across navigation (<code>05 §3</code>)</td></tr> <tr><td>Deep link</td><td><code>studylyon://teachers/:id</code> (teacher detail); <code>studylyon://teachers/:id/assignments</code> — <code>(planned)</code>, no backend constraint</td></tr> <tr><td>Breadcrumbs</td><td>desktop detail screens: Staff › Teachers › {name} (<code>05 §3</code>)</td></tr> <tr><td>FAB</td><td>List screen only: "Add teacher" (<code>05 §7</code>); hidden on scroll-down</td></tr> <tr><td>Context menu</td><td>Row long-press / right-click: Edit, Deactivate (<code>03_Component_Library.md</code> AppMenu)</td></tr> <tr><td>Keyboard</td><td><code>+</code>/<code>N</code> = new teacher on list (desktop, <code>05 §7</code>)</td></tr> </tbody></table> </div> <h2 id="5-role--surface-gating-client-side-server-enforces-jwt-only--01-5"><a class="header" href="#5-role--surface-gating-client-side-server-enforces-jwt-only--01-5">5. Role → surface gating (client-side; server enforces JWT only — <code>01 §5</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Surface</th><th>org_admin</th><th>HR/custom (staff.read+)</th><th>teacher (self)</th><th>staff read-only</th></tr></thead><tbody> <tr><td>Teachers list</td><td>full</td><td>full</td><td>—</td><td>read (<code>(proposed)</code> — <code>staff.read</code> exists at <code>permissions.constants.ts:19</code>)</td></tr> <tr><td>Teacher detail</td><td>full</td><td>full</td><td>own only <code>(planned)</code></td><td>read</td></tr> <tr><td>Create / edit / deactivate</td><td>full</td><td>full</td><td>—</td><td>—</td></tr> <tr><td>Assignments (by teacher / by class)</td><td>full</td><td>full</td><td>own (read)</td><td>read</td></tr> <tr><td>My Teaching</td><td>—</td><td>—</td><td>full</td><td>—</td></tr> </tbody></table> </div> <p>Server reality: no endpoint carries role metadata (<code>rbac.guard.ts:29</code> passes metadata-less routes) → the client is the only gate. Cross-tenant access is still structurally blocked server-side via <code>scopedFilter</code> (<code>base.repository.ts:20-30</code>).</p> <h2 id="6-information-units-primary-entities"><a class="header" href="#6-information-units-primary-entities">6. Information units (primary entities)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Entity</th><th>Collection</th><th>Key fields</th><th>Client model</th></tr></thead><tbody> <tr><td>Teacher</td><td><code>teachers</code> (<code>COLLECTIONS.md:1418</code>)</td><td>userId, employeeNumber, departmentId, designationId, joiningDate, employmentStatus, qualification, experienceYears, subjects[], classTeacherFor[], metadata</td><td><code>Teacher</code></td></tr> <tr><td>SubjectAssignment</td><td><code>subject_assignments</code> (<code>COLLECTIONS.md:1644</code>)</td><td>teacherId, subjectId, classId, academicYearId</td><td><code>SubjectAssignment</code></td></tr> <tr><td>Department / Designation</td><td><code>departments</code> / <code>designations</code> (Staff-owned)</td><td>name, code, level, status</td><td><code>Department</code>, <code>Designation</code> (reference)</td></tr> <tr><td>Subject</td><td><code>subjects</code> (<code>COLLECTIONS.md:1598</code>)</td><td>code, name, shortName, credits, marks</td><td><code>Subject</code> (reference)</td></tr> <tr><td>Class</td><td><code>classes</code> (<code>COLLECTIONS.md</code>)</td><td>academicYearId, gradeId, sectionId, name, classTeacherId</td><td><code>Class</code> (reference)</td></tr> <tr><td>AcademicYear</td><td><code>academic_years</code> (<code>COLLECTIONS.md</code>)</td><td>name, startDate, endDate, status, isCurrent</td><td><code>AcademicYear</code> (reference)</td></tr> </tbody></table> </div> <h2 id="7-empty-state-strategy"><a class="header" href="#7-empty-state-strategy">7. Empty-state strategy</a></h2> <ul> <li>List with zero teachers → <code>AppEmptyState</code> "No teachers yet — add your first teacher".</li> <li>Detail with zero assignments → inline empty per tab, CTA "Add assignment".</li> <li>Schedule tab with zero timetable entries → "No classes scheduled".</li> <li>List with filters returning zero → "No teachers match filters" + clear-filters action.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-teachers-module"><a class="header" href="#05--screen-inventory-teachers-module">05 — Screen Inventory (Teachers Module)</a></h1> <ul> <li><a href="teachers/05_Screen_Inventory.html#screen-index">Screen index</a></li> <li><a href="teachers/05_Screen_Inventory.html#s1--teachers-list">S1 — Teachers List</a></li> <li><a href="teachers/05_Screen_Inventory.html#s2--teacher-detail--profile-tab">S2 — Teacher Detail · Profile tab</a></li> <li><a href="teachers/05_Screen_Inventory.html#s3--teacher-detail--assignments-tab">S3 — Teacher Detail · Assignments tab</a></li> <li><a href="teachers/05_Screen_Inventory.html#s4--teacher-detail--schedule-tab">S4 — Teacher Detail · Schedule tab</a></li> <li><a href="teachers/05_Screen_Inventory.html#s5--create-teacher-form-full-page">S5 — Create Teacher form (full page)</a></li> <li><a href="teachers/05_Screen_Inventory.html#s6--edit-teacher-form-full-page">S6 — Edit Teacher form (full page)</a></li> <li><a href="teachers/05_Screen_Inventory.html#s7--subject-assignment-editor-bottom-sheet--dialog">S7 — Subject Assignment Editor (bottom sheet / dialog)</a></li> <li><a href="teachers/05_Screen_Inventory.html#s8--deactivate-confirmation-dialog">S8 — Deactivate confirmation dialog</a></li> <li><a href="teachers/05_Screen_Inventory.html#s9s10--my-teaching--my-schedule-teacher-self-planned">S9/S10 — My Teaching / My Schedule (teacher self, <code>(planned)</code>)</a></li> </ul> <blockquote> <p>All screens are client-side compositions over the implemented API (<code>teacher.controller.ts:24-38</code>, <code>subject-assignment.controller.ts:21-38</code>, <code>timetable.controller.ts:21-27</code>). <code>(planned)</code> = needs backend/roadmap work.</p> </blockquote> <hr /> <h2 id="screen-index"><a class="header" href="#screen-index">Screen index</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Screen</th><th>Route</th><th>Type</th><th>Access</th></tr></thead><tbody> <tr><td>S1</td><td>Teachers List (filters + pagination)</td><td><code>/staff/teachers</code></td><td>List</td><td>admin, HR (<code>staff.read</code> client-gated)</td></tr> <tr><td>S2</td><td>Teacher Detail — Profile tab</td><td><code>/staff/teachers/:id</code></td><td>Detail (tabs)</td><td>admin, HR; own-record <code>(planned)</code> for teacher</td></tr> <tr><td>S3</td><td>Teacher Detail — Assignments tab</td><td><code>/staff/teachers/:id</code> (tab 2)</td><td>Detail (tabs)</td><td>admin, HR, coordinator</td></tr> <tr><td>S4</td><td>Teacher Detail — Schedule tab</td><td><code>/staff/teachers/:id</code> (tab 3)</td><td>Detail (tabs)</td><td>admin, HR, teacher self</td></tr> <tr><td>S5</td><td>Create Teacher form</td><td><code>/staff/teachers/new</code></td><td>Form (full page)</td><td>admin, HR</td></tr> <tr><td>S6</td><td>Edit Teacher form</td><td><code>/staff/teachers/:id/edit</code></td><td>Form (full page)</td><td>admin, HR</td></tr> <tr><td>S7</td><td>Subject Assignment Editor</td><td>sheet/dialog on S3</td><td>Editor</td><td>admin, coordinator</td></tr> <tr><td>S8</td><td>Deactivate confirmation</td><td>dialog on S2</td><td>Dialog (destructive)</td><td>admin, HR</td></tr> <tr><td>S9</td><td>My Teaching (teacher self)</td><td><code>/my/teaching</code> <code>(planned)</code></td><td>Dashboard-ish summary</td><td>teacher</td></tr> <tr><td>S10</td><td>My Schedule (teacher self)</td><td><code>/my/teaching/schedule</code> <code>(planned)</code></td><td>List/grid</td><td>teacher</td></tr> </tbody></table> </div> <p>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).</p> <hr /> <h2 id="s1--teachers-list"><a class="header" href="#s1--teachers-list">S1 — Teachers List</a></h2> <ul> <li><strong>Purpose:</strong> browse, search, filter, paginate the teacher roster.</li> <li><strong>Elements:</strong> <code>AppSearchBar</code> (q), filter chips (employment status × 4, <code>teacher.schema.ts:7-12</code>; department; designation), <code>AppListTile</code> rows (avatar/initials, display name, employeeNumber, status chip, department), pagination via <code>PaginatedListMixin</code> (infinite scroll, <a href="teachers/06_Screen_Specifications.html">06_State_Management.md §3.2</a>), FAB "Add teacher".</li> <li><strong>Data:</strong> <code>GET /teachers?page=&limit=&sort=&q=</code> (<code>teacher.controller.ts:27-29</code>). <strong>Caveat:</strong> <code>sort</code>/<code>q</code> not applied server-side (<code>teacher.service.ts:66-78</code>) → client re-sorts cached page; server search <code>(planned)</code> (OQ-3).</li> <li><strong>States:</strong> skeleton → content | empty | error (retry). Filter change = refetch page 1.</li> </ul> <h2 id="s2--teacher-detail--profile-tab"><a class="header" href="#s2--teacher-detail--profile-tab">S2 — Teacher Detail · Profile tab</a></h2> <ul> <li><strong>Purpose:</strong> read-only profile; entry point for edit/deactivate.</li> <li><strong>Elements:</strong> header card (avatar initials, name, employeeNumber, status chip), info rows (department, designation, joining date, qualification, experienceYears, <code>teacher.schema.ts:16-51</code>), subjects badges (<code>subjects[]</code>), class-teacher badges (<code>classTeacherFor[]</code>), metadata section when present, AppBar actions: Edit, overflow menu (Deactivate).</li> <li><strong>Data:</strong> <code>GET /teachers/:id</code> (<code>teacher.controller.ts:30-32</code>). 404 → treat as removed (empty state + back).</li> <li><strong>Enrichment:</strong> department/designation/subject/class names resolved client-side from reference catalogs (<a href="teachers/04_Information_Architecture.html">04 §6</a>); server returns raw ObjectIds only.</li> </ul> <h2 id="s3--teacher-detail--assignments-tab"><a class="header" href="#s3--teacher-detail--assignments-tab">S3 — Teacher Detail · Assignments tab</a></h2> <ul> <li><strong>Purpose:</strong> per-academic-year teaching matrix for this teacher.</li> <li><strong>Elements:</strong> <code>AppDropdown</code> academic-year (default <code>isCurrent</code>, <code>academic-year.schema.ts:31-32</code>), list of <code>SubjectAssignment</code> rows (subject name, class name, year), "Add assignment" button → S7, row overflow: Remove (confirm dialog).</li> <li><strong>Data:</strong> <code>GET /subject-assignments/by-teacher/:teacherId?academicYearId=</code> (<code>subject-assignment.controller.ts:30-35</code>). <strong>Empty result when <code>academicYearId</code> omitted</strong> (<code>subject-assignment.service.ts:26-31</code>) → picker always set.</li> <li><strong>States:</strong> year with none → <code>AppEmptyState</code> + CTA; loading skeleton rows.</li> </ul> <h2 id="s4--teacher-detail--schedule-tab"><a class="header" href="#s4--teacher-detail--schedule-tab">S4 — Teacher Detail · Schedule tab</a></h2> <ul> <li><strong>Purpose:</strong> weekly teaching timetable for this teacher.</li> <li><strong>Elements:</strong> week grid (Mon–Sun × time slots), cards per period (subject/class/ startTime–endTime), empty state.</li> <li><strong>Data:</strong> <code>GET /timetable?teacherId=<id></code> (<code>timetable.controller.ts:21-27</code>, <code>timetable.service.ts:55-60</code>, sorted <code>dayOfWeek, startTime</code>).</li> <li><strong>Caveat:</strong> <code>teacherId=me</code> magic value from <code>END_TO_END_USER_FLOWS.md:266</code> is not implemented — always send the real id (OQ-2).</li> </ul> <h2 id="s5--create-teacher-form-full-page"><a class="header" href="#s5--create-teacher-form-full-page">S5 — Create Teacher form (full page)</a></h2> <ul> <li><strong>Purpose:</strong> create a teacher profile; must reference an existing User.</li> <li><strong>Fields (server contract, <code>create-teacher.dto.ts:4-52</code>):</strong> userId* (user picker), employeeNumber*, departmentId, designationId, joiningDate, employmentStatus (default <code>active</code>), qualification, experienceYears, subjects[] (multi), classTeacherFor[] (multi).</li> <li><strong>Submit:</strong> <code>POST /teachers</code> → 200 data = created teacher (<code>teacher.controller.ts:24-26</code>).</li> <li><strong>Errors:</strong> 409 duplicate user/employeeNumber (<code>teacher.service.ts:27-38</code>) shown inline; 400 field errors mapped (<a href="teachers/../00-shared/07_API_Conventions.html">00-shared/07 §3</a>).</li> </ul> <h2 id="s6--edit-teacher-form-full-page"><a class="header" href="#s6--edit-teacher-form-full-page">S6 — Edit Teacher form (full page)</a></h2> <ul> <li>Same field set as S5 <strong>minus userId</strong>, plus optional <code>metadata</code> object (<code>update-teacher.dto.ts:4-53</code>). Prefilled from <code>GET /teachers/:id</code>.</li> <li><strong>Submit:</strong> <code>PATCH /teachers/:id</code> → 200 updated doc (<code>teacher.controller.ts:33-35</code>).</li> <li>Identity link immutable (userId absent from DTO).</li> </ul> <h2 id="s7--subject-assignment-editor-bottom-sheet--dialog"><a class="header" href="#s7--subject-assignment-editor-bottom-sheet--dialog">S7 — Subject Assignment Editor (bottom sheet / dialog)</a></h2> <ul> <li><strong>Purpose:</strong> create one <code>SubjectAssignment</code> (teacher × subject × class × year).</li> <li><strong>Fields (<code>create-subject-assignment.dto.ts:4-19</code>):</strong> teacherId (prefilled from detail), subjectId* (picker), classId* (picker, year-scoped), academicYearId* (picker, default current).</li> <li><strong>Submit:</strong> <code>POST /subject-assignments</code> → 200 doc (<code>subject-assignment.controller.ts:21-23</code>).</li> <li><strong>Client-side duplicate guard:</strong> block if (teacher, subject, class, year) already in matrix — <strong>server has no check</strong> (<code>subject-assignment.service.ts:13-17</code>) (OQ-2).</li> </ul> <h2 id="s8--deactivate-confirmation-dialog"><a class="header" href="#s8--deactivate-confirmation-dialog">S8 — Deactivate confirmation dialog</a></h2> <ul> <li><strong>Purpose:</strong> confirm irreversible soft-delete.</li> <li><strong>Content:</strong> title "Deactivate teacher?", body: name + employeeNumber; warning copy: hidden from all lists; historical records (assignments, audit) retained; no reactivation via API (OQ-5).</li> <li><strong>Action:</strong> <code>DELETE /teachers/:id</code> (<code>teacher.controller.ts:36-38</code>) → 200 void → snackbar, navigate back. 404 → "Already deactivated" → back.</li> </ul> <h2 id="s9s10--my-teaching--my-schedule-teacher-self-planned"><a class="header" href="#s9s10--my-teaching--my-schedule-teacher-self-planned">S9/S10 — My Teaching / My Schedule (teacher self, <code>(planned)</code>)</a></h2> <ul> <li>Composition over existing endpoints: <code>GET /subject-assignments/by-teacher/:id</code> (assignments), <code>GET /timetable?teacherId=</code> (schedule), <code>GET /teachers/:id</code> (profile).</li> <li>Requires resolution of "my teacher id" — no <code>/teachers/me</code> endpoint (<code>teacher.controller.ts:24-38</code> has none) (OQ-2).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-teachers-module"><a class="header" href="#06--screen-specifications-teachers-module">06 — Screen Specifications (Teachers Module)</a></h1> <ul> <li><a href="teachers/06_Screen_Specifications.html#s1--teachers-list">S1 — Teachers List</a> <ul> <li><a href="teachers/06_Screen_Specifications.html#layout-phone--tablet--desktop">Layout (phone / tablet / desktop)</a></li> <li><a href="teachers/06_Screen_Specifications.html#behaviour--states">Behaviour & states</a></li> <li><a href="teachers/06_Screen_Specifications.html#data--interactions">Data & interactions</a></li> <li><a href="teachers/06_Screen_Specifications.html#accessibility">Accessibility</a></li> </ul> </li> <li><a href="teachers/06_Screen_Specifications.html#s2--teacher-detail--profile-tab">S2 — Teacher Detail · Profile tab</a> <ul> <li><a href="teachers/06_Screen_Specifications.html#layout">Layout</a></li> <li><a href="teachers/06_Screen_Specifications.html#data--states">Data & states</a></li> <li><a href="teachers/06_Screen_Specifications.html#interactions">Interactions</a></li> </ul> </li> <li><a href="teachers/06_Screen_Specifications.html#s3--teacher-detail--assignments-tab">S3 — Teacher Detail · Assignments tab</a> <ul> <li><a href="teachers/06_Screen_Specifications.html#layout-1">Layout</a></li> <li><a href="teachers/06_Screen_Specifications.html#data--states-1">Data & states</a></li> </ul> </li> <li><a href="teachers/06_Screen_Specifications.html#s4--teacher-detail--schedule-tab">S4 — Teacher Detail · Schedule tab</a> <ul> <li><a href="teachers/06_Screen_Specifications.html#layout-2">Layout</a></li> <li><a href="teachers/06_Screen_Specifications.html#data--states-2">Data & states</a></li> </ul> </li> <li><a href="teachers/06_Screen_Specifications.html#s5--create-teacher-form">S5 — Create Teacher form</a> <ul> <li><a href="teachers/06_Screen_Specifications.html#layout--flow">Layout & flow</a></li> <li><a href="teachers/06_Screen_Specifications.html#field-by-field-validation-server-dto-vs-client">Field-by-field validation (server DTO vs client)</a></li> <li><a href="teachers/06_Screen_Specifications.html#submit--errors">Submit & errors</a></li> </ul> </li> <li><a href="teachers/06_Screen_Specifications.html#s6--edit-teacher-form">S6 — Edit Teacher form</a></li> <li><a href="teachers/06_Screen_Specifications.html#s7--subject-assignment-editor-bottom-sheet-on-phone-dialog-on-desktop">S7 — Subject Assignment Editor (bottom sheet on phone, dialog on desktop)</a> <ul> <li><a href="teachers/06_Screen_Specifications.html#layout-3">Layout</a></li> <li><a href="teachers/06_Screen_Specifications.html#rules">Rules</a></li> </ul> </li> <li><a href="teachers/06_Screen_Specifications.html#s8--deactivate-confirmation-dialog">S8 — Deactivate confirmation dialog</a></li> <li><a href="teachers/06_Screen_Specifications.html#s9s10--my-teaching--my-schedule-teacher-self--planned">S9/S10 — My Teaching / My Schedule (teacher self) — <code>(planned)</code></a></li> </ul> <blockquote> <p>The definitive per-screen spec. Wire contract per <a href="teachers/../00-shared/07_API_Conventions.html">00-shared/07</a>. All field names and rules cite the DTO/schema they come from. <code>(planned)</code> = backend/roadmap gap.</p> </blockquote> <hr /> <h2 id="s1--teachers-list-1"><a class="header" href="#s1--teachers-list-1">S1 — Teachers List</a></h2> <h3 id="layout-phone--tablet--desktop"><a class="header" href="#layout-phone--tablet--desktop">Layout (phone / tablet / desktop)</a></h3> <pre><code>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" </code></pre> <ul> <li>Breakpoints: phone single column list; tablet 2-column grid of <code>AppCard</code>s; desktop master-detail (tap row → detail in right pane, <code>05_Global_Information_Architecture.md:58</code>).</li> </ul> <h3 id="behaviour--states"><a class="header" href="#behaviour--states">Behaviour & states</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>UI</th><th>Source</th></tr></thead><tbody> <tr><td>Initial/Loading</td><td>8 × <code>AppSkeleton</code> rows</td><td><code>06_State_Management.md §3.1</code></td></tr> <tr><td>Success + data</td><td>list, status chips colored</td><td>—</td></tr> <tr><td>Success + empty (no filters)</td><td><code>AppEmptyState</code> "No teachers yet" + CTA add</td><td>—</td></tr> <tr><td>Success + empty (filters)</td><td><code>AppEmptyState</code> "No teachers match" + clear filters</td><td>—</td></tr> <tr><td>Error</td><td><code>AppErrorState(code, message, retry)</code></td><td>envelope <code>error.code</code></td></tr> </tbody></table> </div> <h3 id="data--interactions"><a class="header" href="#data--interactions">Data & interactions</a></h3> <ul> <li>Fetch: <code>GET /teachers?page=&limit=20</code> (<code>teacher.controller.ts:27-29</code>); <code>meta</code> drives <code>hasNext</code> (<code>buildPaginationMeta</code>, <code>pagination-query.dto.ts:41-55</code>).</li> <li><code>q</code>/<code>sort</code> params: <strong>sent but ignored by <code>TeacherService.find</code></strong> (<code>teacher.service.ts:66-78</code>) — client filters cached list by q locally; <code>(planned)</code> server search (OQ-3).</li> <li>Filter chips: status (4 enum values, <code>teacher.schema.ts:7-12</code>), department, designation — <strong>no server filter params exist</strong> → client-side filtering of loaded pages (OQ-3); document as limitation.</li> <li>Row tap → S2. Overflow menu: Edit → S6, Deactivate → S8.</li> <li>Pull-to-refresh (<code>RefreshIndicator</code>) bypasses cache; FAB scroll-hide behaviour.</li> </ul> <h3 id="accessibility-3"><a class="header" href="#accessibility-3">Accessibility</a></h3> <ul> <li>Chips: <code>FilterChip</code> with selected state semantics (<code>09_Accessibility_Baseline.md §5</code>).</li> <li>Status never color-only: chip = icon + label (status enum text) (<code>09 §9</code>).</li> <li>Live region announces search result count.</li> </ul> <hr /> <h2 id="s2--teacher-detail--profile-tab-1"><a class="header" href="#s2--teacher-detail--profile-tab-1">S2 — Teacher Detail · Profile tab</a></h2> <h3 id="layout-19"><a class="header" href="#layout-19">Layout</a></h3> <pre><code>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 </code></pre> <h3 id="data--states"><a class="header" href="#data--states">Data & states</a></h3> <ul> <li><code>GET /teachers/:id</code> (<code>teacher.controller.ts:30-32</code>). 404 → <code>AppErrorState</code> RESOURCE_NOT_FOUND + "back to list" (treat as removed/soft-deleted).</li> <li>Reference names resolved from cached catalogs; missing ref id → "—".</li> <li>Empty <code>subjects[]</code>/<code>classTeacherFor[]</code> → "Not set" bodyMedium text, no empty-state.</li> </ul> <h3 id="interactions-2"><a class="header" href="#interactions-2">Interactions</a></h3> <ul> <li>Edit → S6 (prefill from current doc).</li> <li>Menu → Deactivate (S8). If <code>employmentStatus === 'terminated'</code> → deactivate hidden.</li> <li>If <code>on_leave</code> → <code>AppBanner</code> info: "On leave since {updatedAt}".</li> </ul> <hr /> <h2 id="s3--teacher-detail--assignments-tab-1"><a class="header" href="#s3--teacher-detail--assignments-tab-1">S3 — Teacher Detail · Assignments tab</a></h2> <h3 id="layout-20"><a class="header" href="#layout-20">Layout</a></h3> <pre><code>AppSectionHeader "Assignments" [Academic year ▾] [+ Add] ───────────────────────────────────────────────────── [AssignmentCard × N] subject name (titleMedium) [⋮ remove] class name · academic year (bodyMedium) ───────────────────────────────────────────────────── empty: AppEmptyState "No assignments for {year}" + CTA </code></pre> <h3 id="data--states-1"><a class="header" href="#data--states-1">Data & states</a></h3> <ul> <li><code>GET /subject-assignments/by-teacher/:teacherId?academicYearId=</code> (<code>subject-assignment.controller.ts:30-35</code>); <code>academicYearId</code> <strong>required in practice</strong> (<code>subject-assignment.service.ts:26-31</code> returns [] when missing).</li> <li>Year picker from <code>GET /academic-years</code> (<code>academic-year.controller.ts:27-30</code>), default = <code>isCurrent</code> (<code>academic-year.schema.ts:31-32</code>).</li> <li>Loading: 3 skeleton cards. Error: inline <code>AppErrorState(compact)</code> + retry.</li> <li>Remove: confirm <code>AppDialog</code> → <code>DELETE /subject-assignments/:id</code> (<code>subject-assignment.controller.ts:36-38</code>); 404 → treat as already removed.</li> </ul> <hr /> <h2 id="s4--teacher-detail--schedule-tab-1"><a class="header" href="#s4--teacher-detail--schedule-tab-1">S4 — Teacher Detail · Schedule tab</a></h2> <h3 id="layout-21"><a class="header" href="#layout-21">Layout</a></h3> <pre><code>AppSectionHeader "Weekly schedule" [Week ▾ (current week)] Grid: 7 columns (Mon..Sun) × time rows; period cards: "10:00–10:45 · Mathematics · 10-A" </code></pre> <h3 id="data--states-2"><a class="header" href="#data--states-2">Data & states</a></h3> <ul> <li><code>GET /timetable?teacherId=<id></code> (<code>timetable.controller.ts:21-27</code>), server-sorted <code>dayOfWeek, startTime</code> (<code>timetable.service.ts:55-60</code>).</li> <li>Empty → "No classes scheduled." Stale-year handling: show current term by default.</li> </ul> <hr /> <h2 id="s5--create-teacher-form"><a class="header" href="#s5--create-teacher-form">S5 — Create Teacher form</a></h2> <h3 id="layout--flow"><a class="header" href="#layout--flow">Layout & flow</a></h3> <pre><code>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 </code></pre> <h3 id="field-by-field-validation-server-dto-vs-client"><a class="header" href="#field-by-field-validation-server-dto-vs-client">Field-by-field validation (server DTO vs client)</a></h3> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Server (<code>create-teacher.dto.ts</code>)</th><th>Client rules</th></tr></thead><tbody> <tr><td>userId</td><td>required <code>@IsMongoId</code> (line 6-7)</td><td>required; from user picker</td></tr> <tr><td>employeeNumber</td><td>required <code>@IsString</code> (10-11)</td><td>required; trim; unique check against loaded roster (server 409 = race-safe)</td></tr> <tr><td>departmentId</td><td>optional <code>@IsMongoId</code> (15-16)</td><td>picker</td></tr> <tr><td>designationId</td><td>optional <code>@IsMongoId</code> (20-21)</td><td>picker</td></tr> <tr><td>joiningDate</td><td>optional <code>@IsDateString</code> (25-26)</td><td>date picker; ≤ today <code>(proposed)</code></td></tr> <tr><td>employmentStatus</td><td>optional string, enum hint <code>['active','inactive','on_leave','terminated']</code> (28-33)</td><td>default <code>active</code>; <strong>server does not enum-validate</strong> → client restricts to 4 values</td></tr> <tr><td>qualification</td><td>optional <code>@IsString</code> (37-38)</td><td>free text</td></tr> <tr><td>experienceYears</td><td>optional number, <strong>no validator</strong> (41-42)</td><td>client: int ≥ 0 <code>(proposed)</code></td></tr> <tr><td>subjects</td><td>optional string[], <strong>no array/MongoId validator</strong> (45-46)</td><td>client: array of valid ObjectIds</td></tr> <tr><td>classTeacherFor</td><td>optional string[], no validator (48-52)</td><td>client: array of valid ObjectIds</td></tr> </tbody></table> </div> <h3 id="submit--errors"><a class="header" href="#submit--errors">Submit & errors</a></h3> <ul> <li><code>POST /teachers</code> → 200 <code>data</code> = teacher doc (unwrapped by envelope interceptor).</li> <li>409 → inline <code>AppBanner(error)</code> per field ("A teacher profile already exists for this user." / "Employee number … already exists.", <code>teacher.service.ts:29-37</code>).</li> <li>400 → map <code>error.details[].field</code> to form fields (<code>00-shared/07 §3</code>).</li> <li>Double-submit guard (anti-double-submit, <code>08_Interaction_&_Motion.md §6</code>).</li> <li>Offline → blocked with banner (no offline write queue for teachers, <code>00-shared/06 §3.7</code>).</li> </ul> <hr /> <h2 id="s6--edit-teacher-form"><a class="header" href="#s6--edit-teacher-form">S6 — Edit Teacher form</a></h2> <ul> <li>Same spec as S5 with: userId field <strong>hidden & immutable</strong> (<code>update-teacher.dto.ts:4-8</code> lacks userId), metadata editor <code>(proposed)</code> simple key/value rows (<code>update-teacher.dto.ts:51-53</code>), prefilled values from detail doc.</li> <li>Submit <code>PATCH /teachers/:id</code> → 200 updated doc; 404 → leave form + list.</li> <li><code>metadata</code> is <code>Record<string, unknown></code> — client sends only primitive values <code>(proposed)</code>.</li> </ul> <hr /> <h2 id="s7--subject-assignment-editor-bottom-sheet-on-phone-dialog-on-desktop"><a class="header" href="#s7--subject-assignment-editor-bottom-sheet-on-phone-dialog-on-desktop">S7 — Subject Assignment Editor (bottom sheet on phone, dialog on desktop)</a></h2> <h3 id="layout-22"><a class="header" href="#layout-22">Layout</a></h3> <pre><code>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 </code></pre> <h3 id="rules-1"><a class="header" href="#rules-1">Rules</a></h3> <ul> <li>All four ids <code>@IsMongoId</code> required (<code>create-subject-assignment.dto.ts:5-19</code>); teacherId prefilled from context (S3) or user-selected.</li> <li><strong>Client duplicate guard:</strong> exact (teacher, subject, class, academicYear) already in matrix → disable Add + inline error "Already assigned" (server gap, OQ-2).</li> <li>Submit <code>POST /subject-assignments</code> → 200 doc appended to matrix; snackbar "Assigned".</li> <li>Errors: 400 → inline; 500 → generic + requestId; no optimistic insert (server confirms only, <code>00-shared/06 §3.5</code>).</li> </ul> <hr /> <h2 id="s8--deactivate-confirmation-dialog-1"><a class="header" href="#s8--deactivate-confirmation-dialog-1">S8 — Deactivate confirmation dialog</a></h2> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Spec</th></tr></thead><tbody> <tr><td>Title</td><td>"Deactivate {displayName}?"</td></tr> <tr><td>Body</td><td>"{employeeNumber} will be hidden from all lists. Historical assignments and audit records are kept. This action cannot be undone in the app."</td></tr> <tr><td>Actions</td><td>[Cancel] [Deactivate — destructive <code>AppButton</code>, error colors]</td></tr> <tr><td>Flow</td><td><code>DELETE /teachers/:id</code> → 200 → snackbar "Teacher deactivated" → pop to list; 404 → snackbar "Already deactivated" → pop</td></tr> <tr><td>Focus</td><td>trapped in dialog; ESC/back cancels (<code>03_Component_Library.md</code> AppDialog)</td></tr> <tr><td>A11y</td><td><code>Semantics</code> destructive-action label; haptic <code>heavyImpact()</code> on confirm (<code>08 §3</code>)</td></tr> </tbody></table> </div> <hr /> <h2 id="s9s10--my-teaching--my-schedule-teacher-self--planned"><a class="header" href="#s9s10--my-teaching--my-schedule-teacher-self--planned">S9/S10 — My Teaching / My Schedule (teacher self) — <code>(planned)</code></a></h2> <ul> <li>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).</li> <li>Own-profile banner when <code>on_leave</code>: "Your profile is on leave — schedule may be reassigned."</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-teachers-module"><a class="header" href="#07--component-library-teachers-module">07 — Component Library (Teachers Module)</a></h1> <ul> <li><a href="teachers/07_Component_Library.html#a-module-components">A. Module components</a> <ul> <li><a href="teachers/07_Component_Library.html#teacherlisttile-extends-applisttile">TeacherListTile (extends AppListTile)</a></li> <li><a href="teachers/07_Component_Library.html#statuschip-employment-status">StatusChip (employment status)</a></li> <li><a href="teachers/07_Component_Library.html#teachercard-desktop-grid-variant-extends-appcard">TeacherCard (desktop grid variant, extends AppCard)</a></li> <li><a href="teachers/07_Component_Library.html#assignmentcard-extends-appcard">AssignmentCard (extends AppCard)</a></li> <li><a href="teachers/07_Component_Library.html#assignmentmatrixheader">AssignmentMatrixHeader</a></li> <li><a href="teachers/07_Component_Library.html#emptyassignmentsstate-extends-appemptystate">EmptyAssignmentsState (extends AppEmptyState)</a></li> <li><a href="teachers/07_Component_Library.html#subjectbadge--classbadge-extends-appbadge--appchips">SubjectBadge / ClassBadge (extends AppBadge + AppChips)</a></li> <li><a href="teachers/07_Component_Library.html#teacheravatar-extends-appavatar">TeacherAvatar (extends AppAvatar)</a></li> <li><a href="teachers/07_Component_Library.html#teachercountstat-extends-appstattile">TeacherCountStat (extends AppStatTile)</a></li> <li><a href="teachers/07_Component_Library.html#myteachingheader-teacher-self-planned">MyTeachingHeader (teacher self, <code>(planned)</code>)</a></li> </ul> </li> <li><a href="teachers/07_Component_Library.html#b-reused-shared-components">B. Reused shared components</a></li> <li><a href="teachers/07_Component_Library.html#c-composition-rules">C. Composition rules</a></li> </ul> <blockquote> <p>Module-specific components; all reference the shared library (<a href="teachers/../00-shared/03_Component_Library.html">00-shared/03</a>). Every component listed here is a client composition over the implemented API — none invent backend data.</p> </blockquote> <hr /> <h2 id="a-module-components"><a class="header" href="#a-module-components">A. Module components</a></h2> <h3 id="teacherlisttile-extends-applisttile"><a class="header" href="#teacherlisttile-extends-applisttile">TeacherListTile (extends AppListTile)</a></h3> <ul> <li>Props: <code>teacher</code> (id, name, employeeNumber, status, departmentLabel), <code>onTap</code>, <code>onMenu</code> (Edit/Deactivate), <code>selected?</code>.</li> <li>Content: <code>AppAvatar</code> initials (name), title = display name, subtitle = <code>employeeNumber · department</code>, trailing = <code>StatusChip</code> + overflow <code>AppMenu</code>.</li> <li>Tokens: <code>surface</code> row, height 56 (<a href="teachers/02_User_Personas.html">02_Design_Tokens.md §3</a>).</li> <li>A11y: combined label "Ramesh Kumar, TCH001, Science, active".</li> </ul> <h3 id="statuschip-employment-status"><a class="header" href="#statuschip-employment-status">StatusChip (employment status)</a></h3> <ul> <li>Props: <code>status: EmploymentStatus</code> (<code>teacher.schema.ts:7-12</code>).</li> <li>Mapping (icon + label — never color-only, <a href="teachers/09_User_Behaviour.html">09 §9</a>):</li> </ul> <div class="table-wrapper"><table><thead><tr><th>Status</th><th>Icon (Material Symbols)</th><th>Container token</th><th>Label</th></tr></thead><tbody> <tr><td><code>active</code></td><td><code>check_circle</code> (filled)</td><td><code>secondaryContainer</code></td><td>Active</td></tr> <tr><td><code>inactive</code></td><td><code>pause_circle</code></td><td><code>surfaceVariant</code></td><td>Inactive</td></tr> <tr><td><code>on_leave</code></td><td><code>beach_access</code></td><td><code>tertiaryContainer</code></td><td>On leave</td></tr> <tr><td><code>terminated</code></td><td><code>person_off</code></td><td><code>errorContainer</code></td><td>Terminated</td></tr> </tbody></table> </div> <ul> <li>Implementation: <code>AppBadge</code> + icon; Flutter <code>Chip</code>/custom <code>Container</code>.</li> </ul> <h3 id="teachercard-desktop-grid-variant-extends-appcard"><a class="header" href="#teachercard-desktop-grid-variant-extends-appcard">TeacherCard (desktop grid variant, extends AppCard)</a></h3> <ul> <li>Content: header avatar+name+status, body: 3 key InfoRows (dept, designation, employeeNumber), footer: subject count + class count (<code>teacher.schema.ts:44-48</code>).</li> <li>Props: <code>teacher</code>, <code>onTap</code>, <code>elevation: e-1</code>.</li> </ul> <h3 id="assignmentcard-extends-appcard"><a class="header" href="#assignmentcard-extends-appcard">AssignmentCard (extends AppCard)</a></h3> <ul> <li>Props: <code>assignment</code> (subjectLabel, classLabel, yearLabel), <code>onRemove?</code>.</li> <li>Content: subject (titleMedium) / class · year (bodyMedium) / trailing remove icon (tooltip "Remove assignment").</li> <li>A11y: card tap = one button semantics; remove icon separate.</li> </ul> <h3 id="assignmentmatrixheader"><a class="header" href="#assignmentmatrixheader">AssignmentMatrixHeader</a></h3> <ul> <li>Props: <code>year</code> (current <code>AcademicYear</code>), <code>onYearChanged</code>, <code>onAdd</code>.</li> <li>Renders <code>AppSectionHeader</code> + <code>AppDropdown</code> (academic year) + tonal add button ("Add assignment").</li> </ul> <h3 id="emptyassignmentsstate-extends-appemptystate"><a class="header" href="#emptyassignmentsstate-extends-appemptystate">EmptyAssignmentsState (extends AppEmptyState)</a></h3> <ul> <li>Icons: <code>menu_book</code>; copy per context (teacher/class/year).</li> </ul> <h3 id="subjectbadge--classbadge-extends-appbadge--appchips"><a class="header" href="#subjectbadge--classbadge-extends-appbadge--appchips">SubjectBadge / ClassBadge (extends AppBadge + AppChips)</a></h3> <ul> <li>Props: <code>label</code>, <code>onDelete?</code>. Read-only chips on profile; input-chip mode in multi-select fields (S5).</li> <li>Max chips shown before "+N more" (list row overflow), <code>(proposed)</code>.</li> </ul> <h3 id="teacheravatar-extends-appavatar"><a class="header" href="#teacheravatar-extends-appavatar">TeacherAvatar (extends AppAvatar)</a></h3> <ul> <li>Initials from <code>firstName/lastName</code> of linked User (<code>user.schema.ts:17-23</code>) — client resolves from user reference; no photo field exists (fallback always).</li> </ul> <h3 id="teachercountstat-extends-appstattile"><a class="header" href="#teachercountstat-extends-appstattile">TeacherCountStat (extends AppStatTile)</a></h3> <ul> <li>Dashboard integration: <code>teachers.total</code> from <code>GET /dashboard/overview</code> (<code>dashboard.service.ts:32,52</code>); tap → S1.</li> </ul> <h3 id="myteachingheader-teacher-self-planned"><a class="header" href="#myteachingheader-teacher-self-planned">MyTeachingHeader (teacher self, <code>(planned)</code>)</a></h3> <ul> <li>Banner + summary tiles (assignments count, periods this week) — composed client-side.</li> </ul> <hr /> <h2 id="b-reused-shared-components"><a class="header" href="#b-reused-shared-components">B. Reused shared components</a></h2> <div class="table-wrapper"><table><thead><tr><th>Shared component</th><th>Used in</th></tr></thead><tbody> <tr><td><code>AppSearchBar</code></td><td>S1 (q)</td></tr> <tr><td><code>AppSkeleton</code></td><td>S1/S2/S3/S4 loading</td></tr> <tr><td><code>AppEmptyState</code></td><td>S1 empty, S3 empty, S4 empty</td></tr> <tr><td><code>AppErrorState</code></td><td>S1/S3 errors (envelope code)</td></tr> <tr><td><code>AppBanner</code></td><td>S5 409 conflict, S2 on-leave</td></tr> <tr><td><code>AppSnackbar</code></td><td>save/deactivate/remove confirmations</td></tr> <tr><td><code>AppDropdown</code></td><td>year picker (S3), department/designation/subject/class pickers (S5/S7)</td></tr> <tr><td><code>AppSearchBar</code>-style searchable picker</td><td>user picker (S5)</td></tr> <tr><td><code>AppDatePicker</code></td><td>joiningDate (S5/S6)</td></tr> <tr><td><code>AppTextField</code></td><td>employeeNumber, qualification, experienceYears (S5/S6)</td></tr> <tr><td><code>AppChips</code> (filter)</td><td>status/department/designation filters (S1)</td></tr> <tr><td><code>AppTabs</code></td><td>profile/assignments/schedule (S2-S4)</td></tr> <tr><td><code>AppDialog</code></td><td>S8 deactivate, assignment remove, duplicate warning</td></tr> <tr><td><code>AppBottomSheet</code></td><td>S7 assignment editor (phone), pickers</td></tr> <tr><td><code>AppMenu</code></td><td>row/detail overflow menus</td></tr> <tr><td><code>AppFAB</code></td><td>S1 "Add teacher"</td></tr> <tr><td><code>AppButton</code></td><td>all CTAs (filled/tonal/outlined/destructive)</td></tr> <tr><td><code>AppInfoRow</code></td><td>S2 profile rows, S1 card body</td></tr> <tr><td><code>AppOfflineBanner</code></td><td>module-wide offline reads</td></tr> <tr><td><code>AppSectionHeader</code></td><td>S2/S3/S4 sections</td></tr> <tr><td><code>AppProgress</code></td><td>submit spinners, page loaders</td></tr> <tr><td><code>AppStatTile</code></td><td>S9 summary, dashboard</td></tr> </tbody></table> </div> <hr /> <h2 id="c-composition-rules"><a class="header" href="#c-composition-rules">C. Composition rules</a></h2> <ol> <li>No screen renders raw ObjectIds — every id resolved via reference-catalog lookup or <code>—</code> fallback (<a href="teachers/04_Information_Architecture.html">04 §6</a>).</li> <li>Status color mapping centralized in <code>StatusChip</code> — no ad-hoc status colors in screens (enforced, <a href="teachers/04_Information_Architecture.html">04_Design_System_Mapping.md §7</a>).</li> <li>Golden tests: <code>StatusChip</code> (4 states × light/dark), <code>TeacherListTile</code>, <code>AssignmentCard</code>, <code>TeacherCard</code> (3 sizes), per <a href="teachers/../00-shared/03_Component_Library.html">00-shared/03 §G</a>.</li> <li>New components only if shared library lacks the need (YAGNI — check shared first).</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-teachers-module"><a class="header" href="#08--form-specifications-teachers-module">08 — Form Specifications (Teachers Module)</a></h1> <ul> <li><a href="teachers/08_Form_Specifications.html#f1--create-teacher-post-teachers">F1 — Create Teacher (<code>POST /teachers</code>)</a> <ul> <li><a href="teachers/08_Form_Specifications.html#submit-payload-exact">Submit payload (exact)</a></li> <li><a href="teachers/08_Form_Specifications.html#server-responses">Server responses</a></li> </ul> </li> <li><a href="teachers/08_Form_Specifications.html#f2--update-teacher-patch-teachersid">F2 — Update Teacher (<code>PATCH /teachers/:id</code>)</a></li> <li><a href="teachers/08_Form_Specifications.html#f3--subject-assignment-post-subject-assignments">F3 — Subject Assignment (<code>POST /subject-assignments</code>)</a> <ul> <li><a href="teachers/08_Form_Specifications.html#client-side-rules-server-gaps">Client-side rules (server gaps)</a></li> <li><a href="teachers/08_Form_Specifications.html#responses">Responses</a></li> </ul> </li> </ul> <blockquote> <p>Field-by-field specs. Server column = exact DTO contract (<code>create-teacher.dto.ts</code>, <code>update-teacher.dto.ts</code>, <code>create-subject-assignment.dto.ts</code>). <strong>Weak-validation warnings are derived facts</strong> (missing class-validator decorators).</p> </blockquote> <hr /> <h2 id="f1--create-teacher-post-teachers"><a class="header" href="#f1--create-teacher-post-teachers">F1 — Create Teacher (<code>POST /teachers</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Type</th><th>Required</th><th>Server validation</th><th>Client input</th><th>Default</th></tr></thead><tbody> <tr><td>1</td><td><code>userId</code></td><td>string (ObjectId)</td><td>✅</td><td><code>@IsMongoId</code> (<code>create-teacher.dto.ts:6-7</code>)</td><td>Searchable user picker (Users module)</td><td>—</td></tr> <tr><td>2</td><td><code>employeeNumber</code></td><td>string</td><td>✅</td><td><code>@IsString</code> (<code>create-teacher.dto.ts:10-11</code>)</td><td>Text, mono font, uppercase hint</td><td>—</td></tr> <tr><td>3</td><td><code>departmentId</code></td><td>string (ObjectId)</td><td>—</td><td><code>@IsOptional @IsMongoId</code> (<code>:15-16</code>)</td><td><code>AppDropdown</code> from <code>GET /departments</code></td><td>—</td></tr> <tr><td>4</td><td><code>designationId</code></td><td>string (ObjectId)</td><td>—</td><td><code>@IsOptional @IsMongoId</code> (<code>:20-21</code>)</td><td><code>AppDropdown</code> from <code>GET /designations</code></td><td>—</td></tr> <tr><td>5</td><td><code>joiningDate</code></td><td>date string</td><td>—</td><td><code>@IsOptional @IsDateString</code> (<code>:25-26</code>)</td><td><code>AppDatePicker</code>, <code>maxDate = today</code> <code>(proposed)</code></td><td>—</td></tr> <tr><td>6</td><td><code>employmentStatus</code></td><td>string enum</td><td>—</td><td><code>@IsOptional @IsString</code> — <strong>enum not validated</strong> (<code>:31-33</code>)</td><td>Segmented <code>AppDropdown</code>, 4 values from <code>EmploymentStatus</code> (<code>teacher.schema.ts:7-12</code>)</td><td><code>active</code></td></tr> <tr><td>7</td><td><code>qualification</code></td><td>string</td><td>—</td><td><code>@IsOptional @IsString</code> (<code>:37-38</code>)</td><td>Multiline <code>AppTextField</code>, 2 lines max <code>(proposed)</code></td><td>—</td></tr> <tr><td>8</td><td><code>experienceYears</code></td><td>number</td><td>—</td><td><strong>no validator at all</strong> (<code>:41-42</code>)</td><td>Numeric <code>AppTextField</code>, int ≥ 0, ≤ 60 <code>(proposed)</code></td><td>—</td></tr> <tr><td>9</td><td><code>subjects</code></td><td>string[] (ObjectId[])</td><td>—</td><td><strong>no array/MongoId validator</strong> (<code>:45-46</code>)</td><td>Multi-select chips from <code>GET /subjects</code></td><td><code>[]</code> (schema default <code>teacher.schema.ts:44</code>)</td></tr> <tr><td>10</td><td><code>classTeacherFor</code></td><td>string[] (ObjectId[])</td><td>—</td><td><strong>no validator</strong> (<code>:48-52</code>)</td><td>Multi-select chips from <code>GET /classes</code></td><td><code>[]</code> (schema default <code>teacher.schema.ts:47</code>)</td></tr> </tbody></table> </div> <h3 id="submit-payload-exact"><a class="header" href="#submit-payload-exact">Submit payload (exact)</a></h3> <pre><code class="language-json">{ "userId": "…", "employeeNumber": "TCH001", "departmentId": "…", "designationId": "…", "joiningDate": "2024-08-12", "employmentStatus": "active", "qualification": "M.Sc.", "experienceYears": 6, "subjects": ["…"], "classTeacherFor": ["…"] } </code></pre> <h3 id="server-responses"><a class="header" href="#server-responses">Server responses</a></h3> <ul> <li>201→200 envelope <code>data</code> = created <code>Teacher</code> doc (timestamps auto, <code>teacher.schema.ts:14</code>).</li> <li>409 <code>DUPLICATE_RESOURCE</code>: user-linked profile exists / employee number exists (<code>teacher.service.ts:29-37</code>) → inline <code>AppBanner</code>; keep form state (no reload).</li> <li>400 <code>VALIDATION_ERROR</code>: field details mapped (<code>00-shared/07 §3</code>).</li> <li>Notes: <code>tenantId</code> <strong>never sent</strong> (from token, <code>base.repository.ts:33-35</code>); <code>metadata</code> not part of create DTO (update only).</li> </ul> <hr /> <h2 id="f2--update-teacher-patch-teachersid"><a class="header" href="#f2--update-teacher-patch-teachersid">F2 — Update Teacher (<code>PATCH /teachers/:id</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Notes</th></tr></thead><tbody> <tr><td>1–9</td><td>same as F1 minus <code>userId</code></td><td><code>update-teacher.dto.ts:4-49</code>; userId immutable</td></tr> <tr><td>10</td><td><code>metadata</code></td><td><code>Record<string, unknown></code> optional (<code>update-teacher.dto.ts:51-53</code>); client sends <code>{key: primitive}</code> rows only <code>(proposed)</code></td></tr> </tbody></table> </div> <ul> <li><strong>Partial semantics:</strong> <code>$set</code> merge (<code>teacher.service.ts:82</code>) — omitted fields untouched; client sends only changed fields.</li> <li>Conflict rules on update: <strong>no server checks</strong> — <code>PATCH</code> can set <code>employeeNumber</code> to an existing number with no 409 (unlike create; <code>teacher.service.ts:80-93</code> only checks existence of the target record). Client pre-validates against loaded roster; server-side race remains (OQ-3).</li> <li>Status transitions allowed: any of 4 enum values, no workflow restriction server-side.</li> </ul> <hr /> <h2 id="f3--subject-assignment-post-subject-assignments"><a class="header" href="#f3--subject-assignment-post-subject-assignments">F3 — Subject Assignment (<code>POST /subject-assignments</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Type</th><th>Required</th><th>Server validation</th><th>Client input</th></tr></thead><tbody> <tr><td>1</td><td><code>teacherId</code></td><td>string (ObjectId)</td><td>✅</td><td><code>@IsMongoId</code> (<code>create-subject-assignment.dto.ts:6-7</code>)</td><td>Prefilled from teacher detail; editable in standalone mode <code>(proposed)</code></td></tr> <tr><td>2</td><td><code>subjectId</code></td><td>string (ObjectId)</td><td>✅</td><td><code>@IsMongoId</code> (<code>:9-10</code>)</td><td>Searchable picker from <code>GET /subjects</code></td></tr> <tr><td>3</td><td><code>classId</code></td><td>string (ObjectId)</td><td>✅</td><td><code>@IsMongoId</code> (<code>:12-13</code>)</td><td>Year-scoped picker from <code>GET /classes</code></td></tr> <tr><td>4</td><td><code>academicYearId</code></td><td>string (ObjectId)</td><td>✅</td><td><code>@IsMongoId</code> (<code>:15-16</code>)</td><td><code>AppDropdown</code> from <code>GET /academic-years</code>, default <code>isCurrent</code></td></tr> </tbody></table> </div> <h3 id="client-side-rules-server-gaps"><a class="header" href="#client-side-rules-server-gaps">Client-side rules (server gaps)</a></h3> <ul> <li><strong>Duplicate guard:</strong> block exact (teacher, subject, class, year) already in matrix — no server uniqueness (<code>subject-assignment.schema.ts:24-25</code> indexes are non-unique; <code>subject-assignment.service.ts:13-17</code> has no check) (OQ-2).</li> <li>Same teacher teaching 2 subjects in one class: allowed (no cross-check with timetable slots — timetable has its own conflict detection at <code>timetable.service.ts:16-30</code>).</li> <li>Submit disabled until all 4 set; double-submit guard.</li> </ul> <h3 id="responses"><a class="header" href="#responses">Responses</a></h3> <ul> <li>200 <code>data</code> = <code>SubjectAssignment</code> doc. Errors: 400 (invalid ids), 500; <strong>no 404 path for unknown teacherId/subjectId/classId</strong> (no existence checks — client must ensure picker values are valid).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-teachers-module"><a class="header" href="#09--user-behaviour-teachers-module">09 — User Behaviour (Teachers Module)</a></h1> <ul> <li><a href="teachers/09_User_Behaviour.html#1-global-behaviours">1. Global behaviours</a></li> <li><a href="teachers/09_User_Behaviour.html#2-list-screen-s1">2. List screen (S1)</a></li> <li><a href="teachers/09_User_Behaviour.html#3-detail-screen-s2s4">3. Detail screen (S2–S4)</a></li> <li><a href="teachers/09_User_Behaviour.html#4-createedit-forms-s5s6">4. Create/Edit forms (S5/S6)</a></li> <li><a href="teachers/09_User_Behaviour.html#5-assignment-editor-s7">5. Assignment editor (S7)</a></li> <li><a href="teachers/09_User_Behaviour.html#6-deactivate-s8">6. Deactivate (S8)</a></li> <li><a href="teachers/09_User_Behaviour.html#7-teacher-self-s9s10-planned">7. Teacher self (S9/S10, <code>(planned)</code>)</a></li> <li><a href="teachers/09_User_Behaviour.html#8-error-copy-table-module">8. Error copy table (module)</a></li> <li><a href="teachers/09_User_Behaviour.html#9-behavioural-edge-cases-derived">9. Behavioural edge cases (derived)</a></li> </ul> <blockquote> <p>Rules of engagement: what the UI may/must not do, derived strictly from backend behaviour. Contradictions with <code>PLAN.md</code> / flow docs are flagged, not resolved by invention.</p> </blockquote> <hr /> <h2 id="1-global-behaviours"><a class="header" href="#1-global-behaviours">1. Global behaviours</a></h2> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Source</th></tr></thead><tbody> <tr><td>Every list is tenant-scoped automatically; client never sends <code>tenantId</code></td><td><code>base.repository.ts:20-30,33-35</code></td></tr> <tr><td>Every request carries Bearer JWT; 401 → single-flight refresh → <code>sessionExpired</code></td><td><a href="teachers/../00-shared/06_State_Management.html">00-shared/06 §3.6</a></td></tr> <tr><td>Deleted (soft) records are invisible to the client — 404, not a flag in payload</td><td><code>base.repository.ts:20-30</code></td></tr> <tr><td><code>success:false</code> never renders raw message for 5xx — generic + requestId; 4xx business text allowed</td><td><a href="teachers/../00-shared/07_API_Conventions.html">00-shared/07 §11</a></td></tr> <tr><td>Server returns raw ObjectIds for refs — client joins names from catalogs; missing ref = "—"</td><td><code>teacher.schema.ts:22-26,44-48</code></td></tr> </tbody></table> </div> <h2 id="2-list-screen-s1"><a class="header" href="#2-list-screen-s1">2. List screen (S1)</a></h2> <ul> <li>Search input debounced 300 ms (<code>AppSearchBar</code>); since <code>q</code> is ignored server-side (<code>teacher.service.ts:66-78</code>), client filters loaded pages locally and shows "Searching across loaded page only" helper when filter active + more pages exist (OQ-3).</li> <li>Filter chips re-run local filter on loaded items; <strong>server-side filters don't exist</strong> — never claim otherwise in copy.</li> <li>Pagination: infinite scroll; <code>meta.hasNext</code> stops loader (<code>pagination-query.dto.ts:41-55</code>).</li> <li>Pull-to-refresh resets page 1 + bypasses cache (<a href="teachers/../00-shared/06_State_Management.html">00-shared/06 §3.3</a>).</li> <li>Empty states per <a href="teachers/04_Information_Architecture.html">04 §7</a>.</li> </ul> <h2 id="3-detail-screen-s2s4"><a class="header" href="#3-detail-screen-s2s4">3. Detail screen (S2–S4)</a></h2> <ul> <li>Tabs keep state; switching year in Assignments refetches matrix (server keyed by <code>academicYearId</code> — <code>subject-assignment.service.ts:26-31</code>).</li> <li>If detail fetch 404 → user deleted concurrently → leave detail, snackbar "Teacher removed".</li> <li>Edit navigates to S6 with prefill; back from form → refresh detail.</li> </ul> <h2 id="4-createedit-forms-s5s6"><a class="header" href="#4-createedit-forms-s5s6">4. Create/Edit forms (S5/S6)</a></h2> <ul> <li><strong>Never optimistic</strong> — create/update are server-confirmed writes (<a href="teachers/../00-shared/06_State_Management.html">00-shared/06 §3.5</a>).</li> <li>On 409: keep form values; show <code>AppBanner</code> + focus related field.</li> <li>On 400: map <code>details[].field</code> → inline field errors; focus first invalid (<code>09_Accessibility_Baseline.md §10</code>).</li> <li>On 5xx/network: keep form values, <code>AppErrorState</code> inline with Retry (idempotent because create duplicate → 409 guard, update → idempotent <code>$set</code>).</li> <li>Offline: form entry blocked with <code>AppOfflineBanner</code> (no offline queue for teachers, <a href="teachers/../00-shared/06_State_Management.html">00-shared/06 §3.7</a>).</li> </ul> <h2 id="5-assignment-editor-s7"><a class="header" href="#5-assignment-editor-s7">5. Assignment editor (S7)</a></h2> <ul> <li>Add = server-confirmed (no optimistic row).</li> <li>Client duplicate check runs on picker selection AND on submit (race between two admins possible — server accepts both; see QA-3).</li> <li>Remove: confirm dialog (destructive) → server 200 → row removed; 404 → row already gone → remove locally + snackbar.</li> <li>Year switch in S3 discards in-progress editor (sheet closes).</li> </ul> <h2 id="6-deactivate-s8"><a class="header" href="#6-deactivate-s8">6. Deactivate (S8)</a></h2> <ul> <li>Server-confirmed, never optimistic (irreversible).</li> <li>Confirm button pending state; haptic <code>heavyImpact()</code>; on 200 → back + snackbar.</li> <li>No guard server-side on active assignments/timetable (OQ-5) — dialog warns: "Existing assignments and schedule entries are not modified."</li> </ul> <h2 id="7-teacher-self-s9s10-planned"><a class="header" href="#7-teacher-self-s9s10-planned">7. Teacher self (S9/S10, <code>(planned)</code>)</a></h2> <ul> <li>Read-only; no create/edit/remove affordances for <code>teacher</code> role.</li> <li>On <code>on_leave</code>/<code>inactive</code>: banner on My Teaching; schedule still visible.</li> <li>No server "me" endpoint — client resolves own teacher id via stored session mapping (see OQ-2; documented, not invented).</li> </ul> <h2 id="8-error-copy-table-module"><a class="header" href="#8-error-copy-table-module">8. Error copy table (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Code</th><th>Screen</th><th>Client message (i18n key)</th></tr></thead><tbody> <tr><td>409 user</td><td>F1</td><td><code>teachers.create.conflict.user</code> — "A teacher profile already exists for this user."</td></tr> <tr><td>409 emp</td><td>F1</td><td><code>teachers.create.conflict.employeeNumber</code> — "Employee number already exists."</td></tr> <tr><td>404</td><td>S2/S3/S8</td><td><code>teachers.notFound</code> — "Teacher not found or removed."</td></tr> <tr><td>404 assignment</td><td>S3 remove</td><td><code>assignments.notFound</code> — "Assignment already removed."</td></tr> <tr><td>400</td><td>F1/F2/F3</td><td>per-field from <code>details</code></td></tr> <tr><td>429</td><td>all</td><td><code>errors.rateLimited</code> — "Too many requests. Try again in a moment."</td></tr> <tr><td>5xx</td><td>all</td><td><code>errors.server</code> + requestId</td></tr> </tbody></table> </div> <h2 id="9-behavioural-edge-cases-derived"><a class="header" href="#9-behavioural-edge-cases-derived">9. Behavioural edge cases (derived)</a></h2> <ul> <li><code>POST /teachers</code> with <code>userId</code> already having a profile → 409 (create path only; update cannot change userId).</li> <li><code>PATCH</code> <code>employeeNumber</code> → no uniqueness 409 (only create checks; <code>teacher.service.ts:32-38</code> vs <code>80-93</code>) — client pre-check is best-effort.</li> <li>Assignment lists keyed by <code>academicYearId</code> return [] when param missing (<code>subject-assignment.service.ts:26-31</code>) — client always sends it.</li> <li>Timetable by teacher id returns entries sorted <code>dayOfWeek, startTime</code> (<code>timetable.service.ts:55-60</code>) — client must not re-sort by date.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-teachers-module"><a class="header" href="#10--interaction-specification-teachers-module">10 — Interaction Specification (Teachers Module)</a></h1> <ul> <li><a href="teachers/10_Interaction_Specification.html#1-touch-gestures-phonetablet">1. Touch gestures (phone/tablet)</a></li> <li><a href="teachers/10_Interaction_Specification.html#2-desktopweb-mouse--keyboard">2. Desktop/web (mouse + keyboard)</a></li> <li><a href="teachers/10_Interaction_Specification.html#3-haptics-mobile">3. Haptics (mobile)</a></li> <li><a href="teachers/10_Interaction_Specification.html#4-motion-per-screen">4. Motion per screen</a></li> <li><a href="teachers/10_Interaction_Specification.html#5-micro-interactions">5. Micro-interactions</a></li> <li><a href="teachers/10_Interaction_Specification.html#6-frustration-controls">6. Frustration controls</a></li> </ul> <blockquote> <p>Specializes <a href="teachers/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a> for this module. All motion tokens from <a href="teachers/02_User_Personas.html">02_Design_Tokens.md §6</a>.</p> </blockquote> <hr /> <h2 id="1-touch-gestures-phonetablet-2"><a class="header" href="#1-touch-gestures-phonetablet-2">1. Touch gestures (phone/tablet)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Gesture</th><th>Behaviour</th><th>Screen</th></tr></thead><tbody> <tr><td>Tap row</td><td>Ink ripple (<code>m-fast</code>) → S2</td><td>S1</td></tr> <tr><td>Long-press row</td><td><code>AppMenu</code>: Edit, Deactivate (+ <code>selectionClick</code> haptic)</td><td>S1</td></tr> <tr><td>Swipe left</td><td>Remove assignment (with confirm) — keep ≤ 2 actions (<a href="teachers/08_Form_Specifications.html">08 §1</a>)</td><td>S3 rows</td></tr> <tr><td>Pull down</td><td>Refresh list / tab content</td><td>S1, S3, S4</td></tr> <tr><td>Tap FAB</td><td>Scale-in (<code>m-entrance</code>) → S5</td><td>S1</td></tr> <tr><td>Back gesture</td><td>Form → detail (discard confirm if dirty, <code>(proposed)</code>); detail → list</td><td>S5/S6</td></tr> <tr><td>Tap assignment card</td><td>No navigation (info only) — remove via trailing menu</td><td>S3</td></tr> </tbody></table> </div> <h2 id="2-desktopweb-mouse--keyboard-2"><a class="header" href="#2-desktopweb-mouse--keyboard-2">2. Desktop/web (mouse + keyboard)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Input</th><th>Behaviour</th></tr></thead><tbody> <tr><td>Hover row</td><td><code>m-fast</code> surface tint; tooltip after 500 ms (<a href="teachers/08_Form_Specifications.html">08 §2</a>)</td></tr> <tr><td>Right-click row</td><td>same <code>AppMenu</code> as long-press</td></tr> <tr><td><code>+</code> / <code>N</code></td><td>New teacher (S5) on list screen (<a href="teachers/05_Screen_Inventory.html">05_Global_Information_Architecture.md §7</a>)</td></tr> <tr><td><code>Ctrl+F</code></td><td>Focus search bar <code>(proposed)</code></td></tr> <tr><td><code>Esc</code></td><td>Close editor sheet / dialog / menu</td></tr> <tr><td><code>Enter</code></td><td>Submit focused form; <code>Tab</code> logical order; visible focus ring <code>primary</code> 2 dp (<a href="teachers/09_User_Behaviour.html">09 §6</a>)</td></tr> <tr><td><code>Delete</code> key</td><td>On selected row → deactivate confirm <code>(proposed)</code></td></tr> </tbody></table> </div> <h2 id="3-haptics-mobile-10"><a class="header" href="#3-haptics-mobile-10">3. Haptics (mobile)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Haptic</th></tr></thead><tbody> <tr><td>Assignment added</td><td><code>lightImpact()</code></td></tr> <tr><td>Deactivate confirmed</td><td><code>heavyImpact()</code></td></tr> <tr><td>Duplicate-assignment blocked</td><td><code>mediumImpact()</code></td></tr> <tr><td>Long-press menu</td><td><code>selectionClick()</code></td></tr> <tr><td>Pull-to-refresh done</td><td><code>lightImpact()</code></td></tr> <tr><td>Rule</td><td>disabled when system haptics off; none on web/desktop (<a href="teachers/08_Form_Specifications.html">08 §3</a>)</td></tr> </tbody></table> </div> <h2 id="4-motion-per-screen"><a class="header" href="#4-motion-per-screen">4. Motion per screen</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Entrance</th><th>Element motion</th></tr></thead><tbody> <tr><td>S1</td><td>page fade+rise <code>m-slow</code>; list rows stagger <code>m-entrance</code> 40 ms</td><td>chips animate selection <code>m-fast</code>; status chip badge pop</td></tr> <tr><td>S2</td><td>header card hero from list avatar (Hero <code>m-slow</code>)</td><td>tab indicator slide <code>m-base</code>; info rows fade-in staggered 40 ms</td></tr> <tr><td>S3</td><td>tab content fade <code>m-fast</code></td><td>year dropdown <code>m-base</code>; row add/remove: <code>AnimatedList</code> insert/remove <code>m-base</code></td></tr> <tr><td>S4</td><td>grid fade <code>m-fast</code></td><td>none (data-dense)</td></tr> <tr><td>S5/S6</td><td>full-page slide <code>m-base</code></td><td>field focus states <code>m-fast</code>; button spinner replaces label</td></tr> <tr><td>S7</td><td>sheet slide-up <code>m-base</code>, <code>r-xl</code> corners</td><td>picker option highlight <code>m-fast</code></td></tr> <tr><td>S8</td><td>dialog scale+fade <code>m-base</code></td><td>destructive button press ripple <code>m-fast</code></td></tr> <tr><td>S9/S10</td><td>page fade+rise</td><td>summary tiles entrance stagger 40 ms</td></tr> </tbody></table> </div> <p>Reduced-motion: all above collapse to fades ≤ <code>m-instant</code>, no stagger, no hero (<a href="teachers/08_Form_Specifications.html">08 §4</a>, <a href="teachers/09_User_Behaviour.html">09 §8</a>).</p> <h2 id="5-micro-interactions"><a class="header" href="#5-micro-interactions">5. Micro-interactions</a></h2> <ul> <li><strong>FAB hide/show on scroll</strong> (S1): hide on scroll-down, show on scroll-up (<a href="teachers/03_User_Journey.html">03 §D</a>).</li> <li><strong>Submit button</strong>: label → spinner; on success brief check + snackbar (<a href="teachers/08_Form_Specifications.html">08 §5</a>); disabled while pending (anti-double-submit <a href="teachers/08_Form_Specifications.html">08 §6</a>).</li> <li><strong>Year switch in S3</strong>: dropdown + matrix cross-fade <code>m-fast</code>; stale request dropped (request generation token, <a href="teachers/13_State_Management.html">13 §4</a>).</li> <li><strong>Infinite scroll</strong>: bottom spinner fade-in <code>m-instant</code>; "End of list" footer bodySmall (<a href="teachers/03_User_Journey.html">03 §F</a>).</li> <li><strong>Status chip change after PATCH</strong>: <code>AnimatedSwitcher</code> icon morph <code>m-fast</code>.</li> </ul> <h2 id="6-frustration-controls-2"><a class="header" href="#6-frustration-controls-2">6. Frustration controls</a></h2> <ul> <li>Every interactive element responds ≤ 100 ms (<a href="teachers/08_Form_Specifications.html">08 §6</a>).</li> <li>Network-bound actions always pending-visible — never silent.</li> <li>Form save double-tap prevented; deactivate dialog requires explicit confirm (typed confirm of employeeNumber NOT required — soft-delete is reversible server-side; <a href="teachers/05_Screen_Inventory.html">05_Global_Information_Architecture.md §5</a> rule applies only to irreversible purge).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-teachers-module"><a class="header" href="#11--design-system-mapping-teachers-module">11 — Design System Mapping (Teachers Module)</a></h1> <ul> <li><a href="teachers/11_Design_System_Mapping.html#1-screen--m3-component-map">1. Screen → M3 component map</a></li> <li><a href="teachers/11_Design_System_Mapping.html#2-status-color-semantics-module-canon">2. Status color semantics (module canon)</a></li> <li><a href="teachers/11_Design_System_Mapping.html#3-typography">3. Typography</a></li> <li><a href="teachers/11_Design_System_Mapping.html#4-spacing--layout-tokens">4. Spacing & layout tokens</a></li> <li><a href="teachers/11_Design_System_Mapping.html#5-elevation">5. Elevation</a></li> <li><a href="teachers/11_Design_System_Mapping.html#6-icons-material-symbols-outlined-filled-for-status--02-7">6. Icons (Material Symbols outlined, filled for status — 02 §7)</a></li> <li><a href="teachers/11_Design_System_Mapping.html#7-module-level-theming-rules">7. Module-level theming rules</a></li> </ul> <blockquote> <p>Extends <a href="teachers/../00-shared/04_Design_System_Mapping.html">00-shared/04</a> with module components. Every widget → token → Flutter mapping.</p> </blockquote> <hr /> <h2 id="1-screen--m3-component-map"><a class="header" href="#1-screen--m3-component-map">1. Screen → M3 component map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Primary widgets</th><th>M3 roles</th></tr></thead><tbody> <tr><td>S1 list</td><td><code>AppListTile</code>, <code>AppSearchBar</code>, <code>FilterChip</code>, <code>FAB</code></td><td><code>surface</code>, <code>primary</code>, <code>secondaryContainer</code> chips</td></tr> <tr><td>S1 cards (tablet)</td><td><code>AppCard</code> (e-1)</td><td><code>surfaceContainerLow</code></td></tr> <tr><td>S2 profile</td><td><code>AppCard</code> tonal, <code>AppInfoRow</code>, <code>AppTabs</code>, <code>AppBadge</code></td><td><code>primaryContainer</code> header, <code>secondaryContainer</code> status</td></tr> <tr><td>S3 assignments</td><td><code>AppDropdown</code>, <code>AssignmentCard</code>, <code>AppEmptyState</code></td><td><code>surfaceContainerLow</code>, <code>outline</code></td></tr> <tr><td>S4 schedule</td><td>grid cards, <code>AppSectionHeader</code></td><td><code>surfaceContainerLow</code>, <code>e-1</code></td></tr> <tr><td>S5/S6 forms</td><td><code>TextFormField</code> (filled), <code>AppDropdown</code>, <code>FilterChip</code> multi, <code>AppDatePicker</code>, <code>FilledButton</code></td><td>fill <code>surfaceVariant</code>, focus <code>primary</code>, error <code>error</code></td></tr> <tr><td>S7 editor</td><td><code>AppBottomSheet</code> + pickers, <code>FilledButton</code></td><td><code>surfaceContainerHigh</code>, <code>e-5</code></td></tr> <tr><td>S8 deactivate</td><td><code>AlertDialog</code>, destructive <code>FilledButton</code></td><td><code>surfaceContainerHigh</code>, <code>error</code>/<code>onError</code></td></tr> <tr><td>S9/S10 self</td><td><code>AppStatTile</code>, <code>AppBanner</code>, grid</td><td><code>secondaryContainer</code> banner</td></tr> </tbody></table> </div> <h2 id="2-status-color-semantics-module-canon"><a class="header" href="#2-status-color-semantics-module-canon">2. Status color semantics (module canon)</a></h2> <div class="table-wrapper"><table><thead><tr><th>EmploymentStatus</th><th>Token pair</th><th>Rationale</th></tr></thead><tbody> <tr><td><code>active</code></td><td><code>successContainer</code>/<code>onSuccessContainer</code></td><td>success role (<a href="teachers/02_User_Personas.html">02 §1.1</a>)</td></tr> <tr><td><code>inactive</code></td><td><code>surfaceVariant</code>/<code>onSurfaceVariant</code></td><td>neutral</td></tr> <tr><td><code>on_leave</code></td><td><code>warningContainer</code>/<code>onWarningContainer</code></td><td>warning role</td></tr> <tr><td><code>terminated</code></td><td><code>errorContainer</code>/<code>onErrorContainer</code></td><td>error role</td></tr> </tbody></table> </div> <p>Always paired with icon + label (<a href="teachers/09_User_Behaviour.html">09 §9</a> color-blind rule).</p> <h2 id="3-typography-1"><a class="header" href="#3-typography-1">3. Typography</a></h2> <div class="table-wrapper"><table><thead><tr><th>Use</th><th>Token</th><th>Widget</th></tr></thead><tbody> <tr><td>Screen title</td><td><code>headlineMedium</code></td><td>S1 AppBar</td></tr> <tr><td>Teacher name (header)</td><td><code>titleLarge</code></td><td>S2</td></tr> <tr><td>Row titles / card titles</td><td><code>titleMedium</code></td><td>S1 rows, S3 cards</td></tr> <tr><td>Employee number</td><td><code>mono</code> (<code>FontFeature.tabularFigures</code>, <a href="teachers/02_User_Personas.html">02 §2</a>)</td><td>S1 subtitle, S2</td></tr> <tr><td>Metadata / timestamps</td><td><code>bodySmall</code></td><td>S2, S6</td></tr> <tr><td>Buttons, chips, tabs</td><td><code>labelLarge</code></td><td>everywhere</td></tr> <tr><td>Status text</td><td><code>labelMedium</code></td><td>StatusChip</td></tr> </tbody></table> </div> <h2 id="4-spacing--layout-tokens"><a class="header" href="#4-spacing--layout-tokens">4. Spacing & layout tokens</a></h2> <div class="table-wrapper"><table><thead><tr><th>Token</th><th>Value</th><th>Use</th></tr></thead><tbody> <tr><td>Screen padding</td><td>16 / 24 (tablet+) (<a href="teachers/04_Information_Architecture.html">04 §6</a>)</td><td>all screens</td></tr> <tr><td>Card padding</td><td>16</td><td>S2 header, S3 cards</td></tr> <tr><td>Section gap</td><td>24</td><td>S2 sections</td></tr> <tr><td>Field gap</td><td>16</td><td>S5/S6 forms</td></tr> <tr><td>List row</td><td>56</td><td>S1</td></tr> <tr><td>Tab bar</td><td>standard M3</td><td>S2</td></tr> <tr><td>Dialog corner</td><td><code>r-lg</code> 12</td><td>S8</td></tr> <tr><td>Sheet corner</td><td><code>r-xl</code> 16 top</td><td>S7</td></tr> </tbody></table> </div> <h2 id="5-elevation"><a class="header" href="#5-elevation">5. Elevation</a></h2> <div class="table-wrapper"><table><thead><tr><th>Component</th><th>Token</th></tr></thead><tbody> <tr><td>List rows / flat cards</td><td><code>e-0</code> / <code>e-1</code></td></tr> <tr><td>S2 header card</td><td><code>e-1</code></td></tr> <tr><td>FAB</td><td><code>e-3</code></td></tr> <tr><td>S8 dialog</td><td><code>e-4</code></td></tr> <tr><td>S7 sheet</td><td><code>e-5</code></td></tr> </tbody></table> </div> <h2 id="6-icons-material-symbols-outlined-filled-for-status--02-7"><a class="header" href="#6-icons-material-symbols-outlined-filled-for-status--02-7">6. Icons (Material Symbols outlined, filled for status — <a href="teachers/02_User_Personas.html">02 §7</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Icon</th><th>Use</th></tr></thead><tbody> <tr><td><code>person_add</code></td><td>FAB "Add teacher"</td></tr> <tr><td><code>groups</code></td><td>list empty state</td></tr> <tr><td><code>menu_book</code></td><td>assignments empty state</td></tr> <tr><td><code>edit</code>, <code>person_off</code></td><td>row menu</td></tr> <tr><td><code>delete</code></td><td>deactivate</td></tr> <tr><td><code>check_circle</code> / <code>pause_circle</code> / <code>beach_access</code> / <code>person_off</code></td><td>StatusChip states</td></tr> <tr><td><code>filter_list</code></td><td>filter chips toggle</td></tr> <tr><td><code>search</code></td><td>search</td></tr> </tbody></table> </div> <p>All icons have tooltip or <code>Semantics.label</code> (<a href="teachers/09_User_Behaviour.html">09 §7</a>).</p> <h2 id="7-module-level-theming-rules"><a class="header" href="#7-module-level-theming-rules">7. Module-level theming rules</a></h2> <ol> <li>Status colors ONLY via <code>StatusChip</code> (single source of truth).</li> <li>Employee numbers always <code>mono</code> — consistent identity scanning.</li> <li>Destructive actions always <code>error</code> token — never custom red.</li> <li>Assignment "added" confirmation = <code>secondaryContainer</code> banner; duplicates = <code>errorContainer</code>.</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-teachers-module"><a class="header" href="#12--api-mapping-teachers-module">12 — API Mapping (Teachers Module)</a></h1> <ul> <li><a href="teachers/12_API_Mapping.html#0-module-wide-request-envelope--client-policy">0. Module-wide request envelope & client policy</a></li> <li><a href="teachers/12_API_Mapping.html#1-teachers-crud--module-core">1. Teachers CRUD — module core</a> <ul> <li><a href="teachers/12_API_Mapping.html#get-teachers--list-s1"><code>GET /teachers</code> — list (S1)</a></li> <li><a href="teachers/12_API_Mapping.html#get-teachersid--detail-s2"><code>GET /teachers/:id</code> — detail (S2)</a></li> <li><a href="teachers/12_API_Mapping.html#post-teachers--create-s5"><code>POST /teachers</code> — create (S5)</a></li> <li><a href="teachers/12_API_Mapping.html#patch-teachersid--update-s6"><code>PATCH /teachers/:id</code> — update (S6)</a></li> <li><a href="teachers/12_API_Mapping.html#delete-teachersid--deactivate-s8"><code>DELETE /teachers/:id</code> — deactivate (S8)</a></li> </ul> </li> <li><a href="teachers/12_API_Mapping.html#2-subject-assignments--teaching-matrix-s3s7">2. Subject assignments — teaching matrix (S3/S7)</a> <ul> <li><a href="teachers/12_API_Mapping.html#post-subject-assignments--create-s7"><code>POST /subject-assignments</code> — create (S7)</a></li> <li><a href="teachers/12_API_Mapping.html#get-subject-assignmentsby-teacherteacheridacademicyearid-s3"><code>GET /subject-assignments/by-teacher/:teacherId?academicYearId=</code> (S3)</a></li> <li><a href="teachers/12_API_Mapping.html#get-subject-assignmentsby-classclassidacademicyearid-coordinator-view"><code>GET /subject-assignments/by-class/:classId?academicYearId=</code> (coordinator view)</a></li> <li><a href="teachers/12_API_Mapping.html#delete-subject-assignmentsid--remove"><code>DELETE /subject-assignments/:id</code> — remove</a></li> </ul> </li> <li><a href="teachers/12_API_Mapping.html#3-supporting-catalogs-read-only-for-this-module">3. Supporting catalogs (read-only for this module)</a></li> <li><a href="teachers/12_API_Mapping.html#4-loading--streaming--realtime">4. Loading / streaming / realtime</a></li> <li><a href="teachers/12_API_Mapping.html#5-client-error-mapping-table-module">5. Client error mapping table (module)</a></li> <li><a href="teachers/12_API_Mapping.html#6-pagination-summary">6. Pagination summary</a></li> <li><a href="teachers/12_API_Mapping.html#7-optimistic--undo">7. Optimistic / undo</a></li> </ul> <blockquote> <p>Exact wire contract per <a href="teachers/../00-shared/07_API_Conventions.html">00-shared/07</a>. Base <code>/api/v1</code>; envelope <code>{success,message,data,meta?,timestamp,requestId}</code>. Global guards: <code>RateLimitGuard → JwtAuthGuard → RbacGuard</code> (<code>app.module.ts:129-131</code>); <strong>no teachers endpoint carries RBAC metadata</strong> (<code>rbac.guard.ts:29</code> → JWT-only). <code>Bearer</code> JWT; <code>tenantId</code> from token only (<code>base.repository.ts:33-35</code>).</p> </blockquote> <hr /> <h2 id="0-module-wide-request-envelope--client-policy-14"><a class="header" href="#0-module-wide-request-envelope--client-policy-14">0. Module-wide request envelope & client policy</a></h2> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Contract</th></tr></thead><tbody> <tr><td>Headers</td><td><code>Authorization: Bearer</code>; <code>x-request-id</code> client UUID; <code>Content-Type: application/json</code></td></tr> <tr><td>Tenancy</td><td>never in body; server injects <code>tenantId</code> + <code>isDeleted:false</code> scope (<code>base.repository.ts:20-30</code>)</td></tr> <tr><td>Caching</td><td>reference catalogs (departments, designations, subjects, classes, years) 24 h TTL; teacher lists 5 min TTL (<a href="teachers/../00-shared/06_State_Management.html">00-shared/06 §3.3</a>)</td></tr> <tr><td>Offline</td><td>reads last-good cache + banner; <strong>writes blocked</strong> (no offline queue for this module)</td></tr> <tr><td>Retry</td><td>backoff on 5xx/network; no auto-retry on 429</td></tr> <tr><td>Idempotency</td><td>create is retry-safe via 409 duplicate guards (<code>teacher.service.ts:27-38</code>); <code>Idempotency-Key</code> optional (B6 shared ledger)</td></tr> </tbody></table> </div> <hr /> <h2 id="1-teachers-crud--module-core"><a class="header" href="#1-teachers-crud--module-core">1. Teachers CRUD — module core</a></h2> <h3 id="get-teachers--list-s1"><a class="header" href="#get-teachers--list-s1"><code>GET /teachers</code> — list (S1)</a></h3> <ul> <li>Query: <code>page</code> (1-based, default 1), <code>limit</code> (1–100, default 20), <code>sort</code> (<code>-field</code>), <code>q</code> (<code>pagination-query.dto.ts:5-30</code>).</li> <li><strong>Derived caveat:</strong> <code>TeacherService.find</code> ignores <code>sort</code> and <code>q</code> (<code>teacher.service.ts:66-78</code>); no status/department filters exist.</li> <li>Success: <code>data: Teacher[]</code>, <code>meta: {page,limit,totalItems,totalPages,hasNext,hasPrevious}</code> (<code>buildPaginationMeta</code>, <code>pagination-query.dto.ts:41-55</code>).</li> <li>Errors: 400 (bad page/limit), 401, 429, 5xx.</li> </ul> <h3 id="get-teachersid--detail-s2"><a class="header" href="#get-teachersid--detail-s2"><code>GET /teachers/:id</code> — detail (S2)</a></h3> <ul> <li>Success: <code>data: Teacher</code> (raw schema doc, refs as ObjectIds).</li> <li>404 <code>RESOURCE_NOT_FOUND</code> "Teacher not found." (<code>teacher.service.ts:54-58</code>).</li> </ul> <h3 id="post-teachers--create-s5"><a class="header" href="#post-teachers--create-s5"><code>POST /teachers</code> — create (S5)</a></h3> <ul> <li>Body: <code>CreateTeacherDto</code> (<code>create-teacher.dto.ts:4-52</code>, F1).</li> <li>201→200 <code>data: Teacher</code>. 409 <code>DUPLICATE_RESOURCE</code> (user profile exists, <code>teacher.service.ts:27-31</code>; employeeNumber exists, <code>:32-38</code>). 400 validation.</li> <li>Side-effects: <code>TeacherCreated</code> → <code>in-app</code> job <code>teacher-created</code> (<code>event-queue-map.ts:31</code>) → notification (<code>inapp.worker.ts:46-53</code>); search index (<code>search-indexer.service.ts:10</code>).</li> </ul> <h3 id="patch-teachersid--update-s6"><a class="header" href="#patch-teachersid--update-s6"><code>PATCH /teachers/:id</code> — update (S6)</a></h3> <ul> <li>Body: <code>UpdateTeacherDto</code> (<code>update-teacher.dto.ts:4-53</code>, F2); <code>$set</code> merge (<code>teacher.service.ts:80-93</code>); <code>userId</code> immutable.</li> <li>200 <code>data: Teacher</code>; 404 (missing or soft-deleted); 400.</li> <li>Side-effects: <code>TeacherUpdated</code> → <code>audit-write</code> <code>log-teacher-updated</code> (<code>event-queue-map.ts:32</code>); search re-index (<code>search-indexer.service.ts:17</code>).</li> </ul> <h3 id="delete-teachersid--deactivate-s8"><a class="header" href="#delete-teachersid--deactivate-s8"><code>DELETE /teachers/:id</code> — deactivate (S8)</a></h3> <ul> <li>200 <code>data</code> absent (void); soft-delete <code>isDeleted/deletedAt/deletedBy</code> + <code>version+1</code> (<code>base.repository.ts:68-74</code>).</li> <li>404 "Teacher not found." (<code>teacher.service.ts:95-97</code>).</li> <li>Side-effects: <code>TeacherDeleted</code> → <code>audit-write</code> <code>log-teacher-deleted</code> (<code>event-queue-map.ts:33</code>); search index removal (<code>search-indexer.service.ts:24,52-59</code>).</li> </ul> <hr /> <h2 id="2-subject-assignments--teaching-matrix-s3s7"><a class="header" href="#2-subject-assignments--teaching-matrix-s3s7">2. Subject assignments — teaching matrix (S3/S7)</a></h2> <h3 id="post-subject-assignments--create-s7"><a class="header" href="#post-subject-assignments--create-s7"><code>POST /subject-assignments</code> — create (S7)</a></h3> <ul> <li>Body: <code>CreateSubjectAssignmentDto</code> (<code>create-subject-assignment.dto.ts:4-19</code>, F3).</li> <li>200 <code>data: SubjectAssignment</code>. 400 validation. <strong>No 404/409 paths</strong> — no existence or duplicate checks (<code>subject-assignment.service.ts:13-17</code>) (OQ-2).</li> </ul> <h3 id="get-subject-assignmentsby-teacherteacheridacademicyearid-s3"><a class="header" href="#get-subject-assignmentsby-teacherteacheridacademicyearid-s3"><code>GET /subject-assignments/by-teacher/:teacherId?academicYearId=</code> (S3)</a></h3> <ul> <li><code>academicYearId</code> required in practice (missing → <code>[]</code> since filter includes it, <code>subject-assignment.service.ts:26-31</code>).</li> <li>Success: <code>data: SubjectAssignment[]</code> — <strong>non-paginated array</strong>, no <code>meta</code>.</li> <li>Sort: insertion order (no index sort).</li> </ul> <h3 id="get-subject-assignmentsby-classclassidacademicyearid-coordinator-view"><a class="header" href="#get-subject-assignmentsby-classclassidacademicyearid-coordinator-view"><code>GET /subject-assignments/by-class/:classId?academicYearId=</code> (coordinator view)</a></h3> <ul> <li>Same shape; filter <code>{classId, academicYearId}</code> (<code>subject-assignment.service.ts:19-24</code>).</li> </ul> <h3 id="delete-subject-assignmentsid--remove"><a class="header" href="#delete-subject-assignmentsid--remove"><code>DELETE /subject-assignments/:id</code> — remove</a></h3> <ul> <li>Soft-delete (<code>subject-assignment.service.ts:33-36</code>); 404 "Assignment not found.".</li> <li><strong>Caveat:</strong> <code>subject_assignments</code> has no <code>timestamps: true</code> (<code>subject-assignment.schema.ts:7</code>) and <code>isDeleted</code> scoping comes from <code>BaseSchema</code>.</li> </ul> <hr /> <h2 id="3-supporting-catalogs-read-only-for-this-module"><a class="header" href="#3-supporting-catalogs-read-only-for-this-module">3. Supporting catalogs (read-only for this module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Endpoint</th><th>Used by</th><th>Source</th></tr></thead><tbody> <tr><td><code>GET /departments</code> (paginated)</td><td>F1/F2 department picker, S1 filter</td><td><code>department.controller.ts:29-31</code></td></tr> <tr><td><code>GET /designations</code> (paginated)</td><td>F1/F2 designation picker</td><td><code>designation.controller.ts</code></td></tr> <tr><td><code>GET /subjects</code> (paginated)</td><td>F1 subjects multi, S7 subject picker</td><td><code>subject.controller.ts:27-29</code></td></tr> <tr><td><code>GET /classes</code> (+ <code>by-year/:academicYearId</code>)</td><td>F1 classTeacherFor multi, S7 class picker</td><td><code>class.controller.ts:27-33</code></td></tr> <tr><td><code>GET /academic-years</code></td><td>S3/S7 year picker (<code>isCurrent</code> flag)</td><td><code>academic-year.controller.ts:27-30</code></td></tr> <tr><td><code>GET /timetable?teacherId=</code></td><td>S4 schedule</td><td><code>timetable.controller.ts:21-27</code></td></tr> <tr><td><code>GET /dashboard/overview</code></td><td><code>teachers.total</code> KPI</td><td><code>dashboard.service.ts:32,52</code></td></tr> <tr><td><code>GET /users</code></td><td>F5 user picker</td><td>Users module</td></tr> </tbody></table> </div> <hr /> <h2 id="4-loading--streaming--realtime-1"><a class="header" href="#4-loading--streaming--realtime-1">4. Loading / streaming / realtime</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Loading</th><th>Realtime</th></tr></thead><tbody> <tr><td>S1 list</td><td>skeleton rows</td><td>— (teacher.created WS event <code>(planned)</code>)</td></tr> <tr><td>S2/S3/S4 detail</td><td>skeleton card / rows</td><td>refresh on <code>notification.new</code> for own tenant <code>(planned)</code></td></tr> <tr><td>S5/S6/S7 forms</td><td>submit spinner only</td><td>—</td></tr> <tr><td>S8 dialog</td><td>button spinner</td><td>—</td></tr> </tbody></table> </div> <h2 id="5-client-error-mapping-table-module"><a class="header" href="#5-client-error-mapping-table-module">5. Client error mapping table (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Code</th><th>UI</th></tr></thead><tbody> <tr><td>S1/S2/S3</td><td>401</td><td>silent refresh; fail → <code>sessionExpired</code></td></tr> <tr><td>S1/S2/S3</td><td>403</td><td>403 screen (future server enforcement)</td></tr> <tr><td>S2/S3/S8</td><td>404</td><td>"Teacher not found or removed" → back</td></tr> <tr><td>S3 remove</td><td>404</td><td>treat as removed locally</td></tr> <tr><td>S5</td><td>409</td><td>inline conflict banner, keep form</td></tr> <tr><td>S5/S6/S7</td><td>400</td><td>per-field errors</td></tr> <tr><td>all</td><td>429</td><td>"Try again in a moment", no auto-retry</td></tr> <tr><td>all</td><td>5xx</td><td>generic + requestId + retry</td></tr> </tbody></table> </div> <h2 id="6-pagination-summary"><a class="header" href="#6-pagination-summary">6. Pagination summary</a></h2> <ul> <li><code>GET /teachers</code>: paginated (page/limit/meta) — infinite scroll.</li> <li><code>GET /subject-assignments/by-*</code>, <code>GET /timetable</code>, catalogs: <strong>non-paginated arrays</strong> except catalogs which paginate via <code>PaginationQueryDto</code> — client treats catalogs as page-through (limit 100) or caches 24 h.</li> <li><code>GET /departments</code>/<code>/designations</code> paginate — client loads with <code>limit=100</code> and caches.</li> </ul> <h2 id="7-optimistic--undo"><a class="header" href="#7-optimistic--undo">7. Optimistic / undo</a></h2> <ul> <li>List refresh + detail refresh: <strong>server-first</strong> (read-after-write on navigation back).</li> <li>Status change (F2) is a <code>$set</code> — safe; client applies after 200 only (no rollback complexity).</li> <li>No destructive op is optimistic (<a href="teachers/../00-shared/06_State_Management.html">00-shared/06 §3.5</a>); UNDO snackbar not offered for deactivate (irreversible by design) — offering undo would be inventing an API surface (no reactivation endpoint).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-teachers-module"><a class="header" href="#13--state-management-teachers-module">13 — State Management (Teachers Module)</a></h1> <ul> <li><a href="teachers/13_State_Management.html#1-cubits">1. Cubits</a></li> <li><a href="teachers/13_State_Management.html#2-state-classes">2. State classes</a></li> <li><a href="teachers/13_State_Management.html#3-repository--cache-keys">3. Repository & cache keys</a></li> <li><a href="teachers/13_State_Management.html#4-events">4. Events</a></li> <li><a href="teachers/13_State_Management.html#5-realtime--cross-cubit">5. Realtime / cross-cubit</a></li> <li><a href="teachers/13_State_Management.html#6-optimistic-policy-module">6. Optimistic policy (module)</a></li> <li><a href="teachers/13_State_Management.html#7-error-handling">7. Error handling</a></li> <li><a href="teachers/13_State_Management.html#8-testing-hooks">8. Testing hooks</a></li> </ul> <blockquote> <p>Extends <a href="teachers/../00-shared/06_State_Management.html">00-shared/06</a>. Bloc/Cubit, one cubit per screen; repository layer only place touching HTTP; typed <code>ApiException(code, status, fieldDetails)</code>.</p> </blockquote> <hr /> <h2 id="1-cubits-6"><a class="header" href="#1-cubits-6">1. Cubits</a></h2> <div class="table-wrapper"><table><thead><tr><th>Cubit</th><th>Screen</th><th>State</th><th>Notes</th></tr></thead><tbody> <tr><td><code>TeachersListCubit</code></td><td>S1</td><td><code>TeachersListState</code></td><td>paginated mixin + filters</td></tr> <tr><td><code>TeacherDetailCubit</code></td><td>S2–S4</td><td><code>TeacherDetailState</code></td><td>tabs, year selection</td></tr> <tr><td><code>TeacherFormCubit</code></td><td>S5/S6</td><td><code>TeacherFormState</code></td><td>create/update + dirty tracking</td></tr> <tr><td><code>AssignmentEditorCubit</code></td><td>S7</td><td><code>AssignmentEditorState</code></td><td>pickers + duplicate guard</td></tr> <tr><td><code>AssignmentsTabCubit</code></td><td>S3</td><td><code>AssignmentsTabState</code></td><td>year-keyed matrix</td></tr> <tr><td><code>ScheduleTabCubit</code></td><td>S4</td><td><code>ScheduleTabState</code></td><td>week grid</td></tr> <tr><td><code>MyTeachingCubit</code></td><td>S9/S10 <code>(planned)</code></td><td><code>MyTeachingState</code></td><td>composed self-view</td></tr> </tbody></table> </div> <h2 id="2-state-classes"><a class="header" href="#2-state-classes">2. State classes</a></h2> <pre><code class="language-dart">// 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) } </code></pre> <h2 id="3-repository--cache-keys"><a class="header" href="#3-repository--cache-keys">3. Repository & cache keys</a></h2> <pre><code class="language-dart">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); } </code></pre> <div class="table-wrapper"><table><thead><tr><th>Cache key</th><th>TTL</th><th>Policy</th></tr></thead><tbody> <tr><td><code>sl:{tenant}:teachers:page:{n}</code></td><td>5 min</td><td>stale-while-revalidate (<code>06 §3.3</code>)</td></tr> <tr><td><code>sl:{tenant}:teachers:refs:{depts|designations|subjects|classes|years}</code></td><td>24 h</td><td>reference data</td></tr> <tr><td><code>sl:{tenant}:teachers:assignments:{teacherId}:{year}</code></td><td>5 min</td><td>volatile matrix</td></tr> <tr><td>detail <code>teachers:{id}</code></td><td>none</td><td>server-fresh on every visit</td></tr> </tbody></table> </div> <p><code>RefreshIndicator</code> always bypasses cache (<code>06 §3.3</code>).</p> <h2 id="4-events"><a class="header" href="#4-events">4. Events</a></h2> <ul> <li><code>LoadTeachers</code>, <code>LoadMore</code>, <code>RefreshTeachers</code>, <code>ChangeStatusFilter(dept, designation)</code>, <code>Search(q)</code>, <code>ClearFilters</code></li> <li><code>LoadTeacher(id)</code>, <code>ChangeTab(i)</code>, <code>ChangeYear(yearId)</code>, <code>RefreshTab</code></li> <li><code>SubmitCreate(draft)</code>, <code>SubmitUpdate(patch)</code>, <code>Discard</code></li> <li><code>SelectSubject/Class/Year</code>, <code>AddAssignment</code>, <code>RemoveAssignment(id)</code></li> <li>Naming per <code>00-shared/06 §4</code>.</li> </ul> <h2 id="5-realtime--cross-cubit"><a class="header" href="#5-realtime--cross-cubit">5. Realtime / cross-cubit</a></h2> <ul> <li><code>notification.new</code> (WS, <code>00-shared/07 §8</code>) for own tenant → teachers list marks refresh-needed badge <code>(planned)</code>; no live row mutation (soft-delete is 404-driven).</li> <li>Dashboard <code>teachers.total</code> refreshes on <code>TeacherCreated/Deleted</code> events when dashboard visible (via <code>DashboardCubit</code> refresh — <code>dashboard.service.ts:32</code>).</li> </ul> <h2 id="6-optimistic-policy-module"><a class="header" href="#6-optimistic-policy-module">6. Optimistic policy (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Action</th><th>Optimistic?</th></tr></thead><tbody> <tr><td>List filters, search, year switch</td><td>yes (local state)</td></tr> <tr><td>Assignment add/remove</td><td><strong>no</strong> — server-confirmed (no server dedup; confirm before UI change)</td></tr> <tr><td>Teacher create/update</td><td>no</td></tr> <tr><td>Deactivate</td><td>no (irreversible)</td></tr> <tr><td>Pull-to-refresh</td><td>cache-bypass reload</td></tr> </tbody></table> </div> <h2 id="7-error-handling"><a class="header" href="#7-error-handling">7. Error handling</a></h2> <ul> <li>409 conflict → form-level state (<code>ConflictType</code>), never clears fields.</li> <li>400 → <code>fieldErrors</code> from <code>error.details</code> (<code>00-shared/07 §3</code>).</li> <li>404 → detail/list treat as removed: navigate back with snackbar.</li> <li>401 → <code>AuthCubit</code> refresh; fail → session expiry.</li> <li>5xx → <code>AppErrorState</code> with requestId; retry re-emits Load event.</li> </ul> <h2 id="8-testing-hooks-1"><a class="header" href="#8-testing-hooks-1">8. Testing hooks</a></h2> <ul> <li><code>TeachersListCubit</code> pure-Dart unit tests (mock <code>TeacherRepository</code>): pagination merge, filter application, duplicate detection, conflict mapping.</li> <li>Widget tests: S1 3-state (loading/error/empty), S5 409-banner, S7 duplicate-block.</li> <li><code>AppStateObserver</code> transition logs dev-only (<code>06 §6</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-teachers-module"><a class="header" href="#14--qa-checklist-teachers-module">14 — QA Checklist (Teachers Module)</a></h1> <ul> <li><a href="teachers/14_QA_Checklist.html#1-assignment-conflict--duplicate-matrix">1. Assignment conflict & duplicate matrix</a></li> <li><a href="teachers/14_QA_Checklist.html#2-duplicate-teacher-rules">2. Duplicate teacher rules</a></li> <li><a href="teachers/14_QA_Checklist.html#3-deactivate-rules">3. Deactivate rules</a></li> <li><a href="teachers/14_QA_Checklist.html#4-pagination--list-integrity">4. Pagination & list integrity</a></li> <li><a href="teachers/14_QA_Checklist.html#5-tenant-isolation--security">5. Tenant isolation & security</a></li> <li><a href="teachers/14_QA_Checklist.html#6-eventside-effect-correctness-backend">6. Event/side-effect correctness [backend]</a></li> <li><a href="teachers/14_QA_Checklist.html#7-client-qa-per-00-shared10">7. Client QA (per 00-shared/10)</a></li> <li><a href="teachers/14_QA_Checklist.html#8-release-gates-module">8. Release gates (module)</a></li> </ul> <blockquote> <p>Module-specific additions to <a href="teachers/../00-shared/10_QA_Baseline.html">00-shared/10</a>. Items marked <strong>[backend]</strong> are bugs or gaps derivable from source and should be raised with the API team, not papered over in the UI.</p> </blockquote> <hr /> <h2 id="1-assignment-conflict--duplicate-matrix"><a class="header" href="#1-assignment-conflict--duplicate-matrix">1. Assignment conflict & duplicate matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expected</th><th>Server reality</th></tr></thead><tbody> <tr><td>QA-1</td><td>Same (teacher, subject, class, year) assigned twice</td><td>Client blocks + inline error</td><td><strong>No server guard</strong> (<code>subject-assignment.service.ts:13-17</code>; non-unique indexes <code>subject-assignment.schema.ts:24-25</code>) — <strong>[backend]</strong> add unique index or 409</td></tr> <tr><td>QA-2</td><td>Same teacher, same subject, two classes (same year)</td><td>Allowed (two rows)</td><td>Allowed — expected</td></tr> <tr><td>QA-3</td><td>Two admins assign same triple concurrently</td><td>Client can't prevent (server race)</td><td>Both succeed — <strong>[backend]</strong>; client shows both rows, dedup display</td></tr> <tr><td>QA-4</td><td>Assign teacher to subject not in class curriculum</td><td>Client warns (no server model of curriculum)</td><td>No check — accept (no curriculum entity)</td></tr> <tr><td>QA-5</td><td>Timetable conflict for teacher (two periods same time)</td><td>Handled by Timetable module, not this one</td><td><code>timetable.service.ts:16-30</code> 409 — cross-module QA</td></tr> <tr><td>QA-6</td><td><code>by-teacher</code> / <code>by-class</code> without <code>academicYearId</code></td><td>Client never sends; if API called bare → expect <code>[]</code></td><td>Returns <code>[]</code> (<code>subject-assignment.service.ts:26-31</code>) — verify client picker always set</td></tr> </tbody></table> </div> <h2 id="2-duplicate-teacher-rules"><a class="header" href="#2-duplicate-teacher-rules">2. Duplicate teacher rules</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expected</th></tr></thead><tbody> <tr><td>QA-7</td><td><code>POST /teachers</code> with <code>userId</code> already profiled</td><td>409 <code>DUPLICATE_RESOURCE</code> (<code>teacher.service.ts:27-31</code>) — UI banner</td></tr> <tr><td>QA-8</td><td><code>POST /teachers</code> with existing <code>employeeNumber</code></td><td>409 (<code>teacher.service.ts:32-38</code>) — banner</td></tr> <tr><td>QA-9</td><td><strong><code>PATCH</code></strong> to existing <code>employeeNumber</code></td><td><strong>No 409</strong> (<code>teacher.service.ts:80-93</code> only checks record exists) — <strong>[backend]</strong>; client pre-check + accept race</td></tr> <tr><td>QA-10</td><td><code>userId</code> change via PATCH</td><td>Blocked by DTO absence (<code>update-teacher.dto.ts</code>) — verify immutability in UI</td></tr> </tbody></table> </div> <h2 id="3-deactivate-rules-1"><a class="header" href="#3-deactivate-rules-1">3. Deactivate rules</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expected</th></tr></thead><tbody> <tr><td>QA-11</td><td><code>DELETE /teachers/:id</code></td><td>Soft-delete; all subsequent list/detail queries exclude (<code>base.repository.ts:20-30,68-74</code>)</td></tr> <tr><td>QA-12</td><td>Deactivate teacher with active assignments/timetable</td><td><strong>Allowed server-side</strong> (no guard) — dialog warns; <strong>[backend]</strong> consider <code>BUSINESS_RULE_VIOLATION</code> guard (OQ-5)</td></tr> <tr><td>QA-13</td><td>Re-activation</td><td><strong>No endpoint</strong> — not offered in UI</td></tr> <tr><td>QA-14</td><td>Deactivate already-deleted id</td><td>404 → snackbar "already removed" → back</td></tr> <tr><td>QA-15</td><td>Audit trail</td><td><code>TeacherDeleted</code> → <code>audit-write</code> job (<code>event-queue-map.ts:33</code>) — verify audit UI shows it</td></tr> </tbody></table> </div> <h2 id="4-pagination--list-integrity"><a class="header" href="#4-pagination--list-integrity">4. Pagination & list integrity</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expected</th></tr></thead><tbody> <tr><td>QA-16</td><td><code>page=1,limit=20</code> → <code>meta</code> math</td><td><code>totalPages = max(1, ceil(total/limit))</code>, <code>hasNext/hasPrevious</code> (<code>pagination-query.dto.ts:41-55</code>)</td></tr> <tr><td>QA-17</td><td><code>limit > 100</code></td><td>400 VALIDATION_ERROR (<code>pagination-query.dto.ts:13-19</code>) — client clamps</td></tr> <tr><td>QA-18</td><td><code>sort</code>/<code>q</code> params</td><td><strong>Ignored by service</strong> (<code>teacher.service.ts:66-78</code>) — UI must not claim server search; local filter only; <strong>[backend]</strong> (OQ-3)</td></tr> <tr><td>QA-19</td><td>Soft-deleted rows in middle page</td><td>Never returned (scope) — no ghost rows</td></tr> <tr><td>QA-20</td><td>Page race on pull-to-refresh + load-more</td><td>Request generation token; no duplicate items</td></tr> <tr><td>QA-21</td><td>Subject-assignments/by-* non-paginated</td><td>Full array; verify memory on large schools; server pagination <code>(planned)</code></td></tr> </tbody></table> </div> <h2 id="5-tenant-isolation--security"><a class="header" href="#5-tenant-isolation--security">5. Tenant isolation & security</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expected</th></tr></thead><tbody> <tr><td>QA-22</td><td>Cross-tenant <code>GET /teachers/:id</code></td><td>404 (scope <code>tenantId</code> injected, <code>base.repository.ts:20-30</code>) — never leak existence (<a href="teachers/../00-shared/07_API_Conventions.html">00-shared/07 §3</a>)</td></tr> <tr><td>QA-23</td><td>No <code>tenantId</code> in bodies</td><td>Client never sends it (<a href="teachers/07_Component_Library.html">07 §6</a>)</td></tr> <tr><td>QA-24</td><td>Unauthenticated call</td><td>401 → refresh flow</td></tr> </tbody></table> </div> <h2 id="6-eventside-effect-correctness-backend"><a class="header" href="#6-eventside-effect-correctness-backend">6. Event/side-effect correctness <strong>[backend]</strong></a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Item</th><th>Evidence</th></tr></thead><tbody> <tr><td>QA-25</td><td><code>TeacherCreated</code> routed to <code>in-app</code>, <strong>not <code>emails</code></strong> — <code>PLAN.md:36</code> promises welcome email</td><td><code>event-queue-map.ts:31</code> vs <code>PLAN.md:36</code> — confirm email intent</td></tr> <tr><td>QA-26</td><td>In-app notification <code>type='TeacherCreated'</code> vs <code>NotificationType</code> enum (<code>email_verified|password_reset|verification_resent|welcome</code>)</td><td><code>inapp.worker.ts:46-53</code> + <code>notification.schema.ts:7-12</code> — likely enum validation failure → job retries → DLQ</td></tr> <tr><td>QA-27</td><td>Search index title for TeacherCreated payload <code>{teacherId, employeeNumber}</code> — no name → not indexed</td><td><code>search-indexer.service.ts:62-88</code> + <code>teacher.service.ts:47-49</code></td></tr> </tbody></table> </div> <h2 id="7-client-qa-per-00-shared10"><a class="header" href="#7-client-qa-per-00-shared10">7. Client QA (per <a href="teachers/../00-shared/10_QA_Baseline.html">00-shared/10</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Item</th></tr></thead><tbody> <tr><td>QA-28</td><td>A11y: status chips label+icon (color-blind), focus order S1→S2, dialog focus trap</td></tr> <tr><td>QA-29</td><td>Text scale 2×: long designation names + status chips don't clip</td></tr> <tr><td>QA-30</td><td>Dark mode parity: all status token pairs verified</td></tr> <tr><td>QA-31</td><td>Offline: list from cache + banner; forms blocked</td></tr> <tr><td>QA-32</td><td>Perf: S1 skeleton ≤ 200 ms; no duplicate parallel requests (<a href="teachers/10_Interaction_Specification.html">10 §1</a>)</td></tr> <tr><td>QA-33</td><td>Analytics <code>(proposed)</code>: <code>teachers.list.search</code>, <code>teachers.create.submit</code>, <code>teachers.assignments.add</code>, <code>teachers.deactivate.confirm</code> (<a href="teachers/10_Interaction_Specification.html">10 §8</a> naming)</td></tr> <tr><td>QA-34</td><td>Golden tests: StatusChip ×4 ×2 modes, TeacherListTile, AssignmentCard, TeacherCard ×3 sizes</td></tr> </tbody></table> </div> <h2 id="8-release-gates-module"><a class="header" href="#8-release-gates-module">8. Release gates (module)</a></h2> <ul> <li><input disabled="" type="checkbox"/> 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)</li> <li><input disabled="" type="checkbox"/> All envelope error codes map to i18n keys (no raw 5xx text)</li> <li><input disabled="" type="checkbox"/> E2E: create → assign → edit → deactivate journey on device cloud</li> <li><input disabled="" type="checkbox"/> Cross-tenant 404 verified in E2E</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-teachers-module"><a class="header" href="#15--flutter-implementation-guide-teachers-module">15 — Flutter Implementation Guide (Teachers Module)</a></h1> <ul> <li><a href="teachers/15_Flutter_Implementation_Guide.html#1-module-folder">1. Module folder</a></li> <li><a href="teachers/15_Flutter_Implementation_Guide.html#2-domain-models">2. Domain models</a></li> <li><a href="teachers/15_Flutter_Implementation_Guide.html#3-repository">3. Repository</a></li> <li><a href="teachers/15_Flutter_Implementation_Guide.html#4-cubits">4. Cubits</a></li> <li><a href="teachers/15_Flutter_Implementation_Guide.html#5-router">5. Router</a></li> <li><a href="teachers/15_Flutter_Implementation_Guide.html#6-theme--components">6. Theme & components</a></li> <li><a href="teachers/15_Flutter_Implementation_Guide.html#7-i18n-keys">7. i18n keys</a></li> <li><a href="teachers/15_Flutter_Implementation_Guide.html#8-tests">8. Tests</a></li> <li><a href="teachers/15_Flutter_Implementation_Guide.html#9-known-backend-gaps-to-coordinate-from-14-qa">9. Known backend gaps to coordinate (from 14-QA)</a></li> </ul> <blockquote> <p>Extends <a href="teachers/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a>. Forward-looking: no client repo exists (shared ledger A1). Everything below derives from <code>src/modules/teachers/**</code>, <code>src/modules/academics/**</code> (subject-assignments), <code>src/modules/staff/**</code> (catalogs), <code>src/modules/timetable/**</code>.</p> </blockquote> <hr /> <h2 id="1-module-folder"><a class="header" href="#1-module-folder">1. Module folder</a></h2> <pre><code>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 </code></pre> <h2 id="2-domain-models"><a class="header" href="#2-domain-models">2. Domain models</a></h2> <pre><code class="language-dart">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; } </code></pre> <ul> <li>DTO→model: <code>fromJson</code> with strict types; enums via <code>EnumByName</code> with unknown → <code>active</code> fallback <code>(proposed)</code> (server enum not validated on input, but stored values are enum-safe, <code>teacher.schema.ts:31-36</code>).</li> <li>Ref-name resolution: <code>TeachersService.joinNames(teacher, catalogs)</code> — catalogs (departments/designations/subjects/classes/years) loaded once, cached 24 h (<code>13 §3</code>).</li> </ul> <h2 id="3-repository-2"><a class="header" href="#3-repository-2">3. Repository</a></h2> <pre><code class="language-dart">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 } </code></pre> <ul> <li>Errors: interceptor maps envelope → <code>ApiException(code, status, fieldDetails)</code> (<code>00-shared/11 §5</code>); 409 exposes <code>conflict</code> flavor for banner copy.</li> <li>Pagination: <code>Page<T></code> helper (items + <code>meta</code>) shared with other modules.</li> </ul> <h2 id="4-cubits-3"><a class="header" href="#4-cubits-3">4. Cubits</a></h2> <ul> <li><code>TeachersListCubit</code> — <code>PaginatedListMixin<Teacher></code> (<code>00-shared/06 §3.2</code>); local filter application for status/dept/designation/q (server ignores <code>sort</code>/<code>q</code>, <code>teacher.service.ts:66-78</code>); <code>RefreshTeachers</code> bypasses cache.</li> <li><code>TeacherDetailCubit</code> — parallel loads: profile + assignments + timetable (three repos) with per-tab <code>LoadState</code>; <code>ChangeYear</code> emits token, drops stale responses.</li> <li><code>TeacherFormCubit</code> — mirrors <code>TeacherDraftDto</code>/<code>TeacherPatchDto</code>; on 400 maps <code>fieldDetails</code>; on 409 sets <code>ConflictType</code>; <code>dirty</code> guards back-navigation.</li> <li><code>AssignmentEditorCubit</code> — duplicate check against current matrix (<code>13 §2</code> state); submit disabled while pending.</li> </ul> <h2 id="5-router-1"><a class="header" href="#5-router-1">5. Router</a></h2> <pre><code class="language-dart">// 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), </code></pre> <ul> <li>Guards: <code>permissionGuard('staff.read')</code> client-side mirror of <code>permissions.constants.ts:19</code> (server is JWT-only today — <code>01 §5</code>, OQ-1).</li> <li>Deep link: <code>studylyon://teachers/:id</code>.</li> </ul> <h2 id="6-theme--components"><a class="header" href="#6-theme--components">6. Theme & components</a></h2> <ul> <li>All tokens via <code>AppTheme</code> (<code>00-shared/04</code>); module components in <code>07</code>; status colors only inside <code>StatusChip</code> (<code>11 §7</code>).</li> <li><code>employeeNumber</code> rendered with <code>mono</code> + <code>tabularFigures</code> (<code>02 §2</code>).</li> </ul> <h2 id="7-i18n-keys"><a class="header" href="#7-i18n-keys">7. i18n keys</a></h2> <pre><code>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 </code></pre> <h2 id="8-tests-1"><a class="header" href="#8-tests-1">8. Tests</a></h2> <div class="table-wrapper"><table><thead><tr><th>Layer</th><th>Cases</th></tr></thead><tbody> <tr><td>Unit</td><td><code>TeachersListCubit</code> pagination merge + local filters; <code>TeacherFormCubit</code> 400/409 mapping; <code>AssignmentEditorCubit</code> duplicate guard; DTO↔model mappers</td></tr> <tr><td>Widget</td><td>S1 3-state (skeleton/error/empty), S5 409 banner, S7 duplicate block, <code>StatusChip</code> golden ×4×2</td></tr> <tr><td>Integration</td><td>create→assign→edit→deactivate journey; 404 detail handling</td></tr> <tr><td>E2E</td><td>P0: full teacher lifecycle + cross-tenant 404 (per <code>00-shared/10 §9</code>)</td></tr> </tbody></table> </div> <h2 id="9-known-backend-gaps-to-coordinate-from-14-qa"><a class="header" href="#9-known-backend-gaps-to-coordinate-from-14-qa">9. Known backend gaps to coordinate (from 14-QA)</a></h2> <ul> <li><input disabled="" type="checkbox"/> Duplicate-assignment guard (QA-1), PATCH employeeNumber 409 (QA-9), deactivate guard (QA-12), server search/sort (QA-18)</li> <li><input disabled="" type="checkbox"/> <code>TeacherCreated</code> email intent + in-app notification enum mismatch (QA-25/26)</li> <li><input disabled="" type="checkbox"/> Search-index title for teacher events (QA-27)</li> <li><input disabled="" type="checkbox"/> <code>GET /teachers/me</code> for teacher self-view (OQ-2)</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-timetable-module"><a class="header" href="#01--product-overview-timetable-module">01 — Product Overview (Timetable Module)</a></h1> <ul> <li><a href="timetable/01_Product_Overview.html#1-purpose">1. Purpose</a></li> <li><a href="timetable/01_Product_Overview.html#2-module-boundary">2. Module boundary</a></li> <li><a href="timetable/01_Product_Overview.html#3-entry-lifecycle-end-to-end">3. Entry lifecycle (end-to-end)</a></li> <li><a href="timetable/01_Product_Overview.html#4-conflict-detection--the-one-real-business-rule">4. Conflict detection — the one real business rule</a></li> <li><a href="timetable/01_Product_Overview.html#5-permissions-reality">5. Permissions reality</a></li> <li><a href="timetable/01_Product_Overview.html#6-events--side-effects">6. Events & side-effects</a></li> <li><a href="timetable/01_Product_Overview.html#7-dependencies">7. Dependencies</a></li> <li><a href="timetable/01_Product_Overview.html#8-business-goals">8. Business goals</a></li> <li><a href="timetable/01_Product_Overview.html#9-success-metrics">9. Success metrics</a></li> <li><a href="timetable/01_Product_Overview.html#10-edge-cases-derived">10. Edge cases (derived)</a></li> <li><a href="timetable/01_Product_Overview.html#11-assumptions-module">11. Assumptions (module)</a></li> <li><a href="timetable/01_Product_Overview.html#12-open-questions-module-level">12. Open questions (module-level)</a></li> </ul> <blockquote> <p>StudyLyon — multi-tenant ERP / School Management API. This package designs the <strong>Timetable module client</strong> (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, events, and business rules are derived directly from <code>src/modules/timetable/**</code>, <code>src/modules/academics/**</code> (class/subject references), <code>src/modules/teachers/**</code>, <code>src/modules/rooms/**</code>, <code>src/modules/rbac/**</code>, and <code>docs/IMPLEMENTATION_PLAN.md</code>. No feature is invented; gaps are flagged and marked <code>(planned)</code> / <code>(proposed)</code> / <code>(forward-looking)</code>.</p> </blockquote> <hr /> <h2 id="1-purpose-26"><a class="header" href="#1-purpose-26">1. Purpose</a></h2> <p>Timetable is the <strong>scheduling domain</strong>: the weekly grid of teaching slots — one <code>timetable_entries</code> 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.</p> <div class="table-wrapper"><table><thead><tr><th>Responsibility</th><th>Source</th></tr></thead><tbody> <tr><td>Create a timetable entry (only write op implemented)</td><td><code>timetable.controller.ts:14-18</code></td></tr> <tr><td>Conflict detection: teacher double-booking + room overlap on the same day</td><td><code>timetable.service.ts:16-30</code></td></tr> <tr><td>Read timetable by class — sorted <code>dayOfWeek, startTime</code></td><td><code>timetable.service.ts:48-53</code></td></tr> <tr><td>Read timetable by teacher — sorted <code>dayOfWeek, startTime</code></td><td><code>timetable.service.ts:55-60</code></td></tr> <tr><td>Six-day week model (Monday–Saturday; <strong>no Sunday</strong>)</td><td><code>timetable.schema.ts:7-14</code></td></tr> <tr><td>Time range stored as zero-padded <code>HH:MM</code> strings (lexical compare)</td><td><code>timetable.schema.ts:33-37</code>; <code>timetable.service.ts:62-69</code></td></tr> <tr><td>Tenant isolation + soft-delete scope on every query</td><td><code>base.repository.ts:20-30</code> (via <code>TimetableRepository</code>)</td></tr> <tr><td>Domain event on create</td><td><code>timetable.service.ts:33-44</code> (<code>TimetableEntryCreated</code>)</td></tr> <tr><td>Weekly-grid feature flag (school + coaching)</td><td><code>IMPLEMENTATION_PLAN.md:327-328</code></td></tr> </tbody></table> </div> <h2 id="2-module-boundary"><a class="header" href="#2-module-boundary">2. Module boundary</a></h2> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Timetable</th><th>Neighbouring module</th></tr></thead><tbody> <tr><td>Owns</td><td><code>timetable_entries</code> docs (day/time slot semantics, conflict rule)</td><td>—</td></tr> <tr><td>References</td><td><code>Class</code>, <code>Subject</code>, <code>Teacher</code>, <code>Room</code>, <code>AcademicYear</code> as ObjectIds (raw refs, no population server-side)</td><td><code>timetable.schema.ts:18-40</code></td></tr> <tr><td>Does <strong>not</strong> own</td><td>classes, subjects, teachers, rooms, academic years — all read from their modules' catalogs</td><td><code>class.schema.ts</code>, <code>subject.schema.ts</code>, <code>teacher.schema.ts</code>, <code>room.schema.ts</code></td></tr> <tr><td>Today</td><td>create + two read paths</td><td><code>timetable.controller.ts:10</code></td></tr> <tr><td>Roadmap</td><td>bulk create, conflict UI, substitution, workload, export</td><td><code>IMPLEMENTATION_PLAN.md:226</code></td></tr> </tbody></table> </div> <p>A <code>Class</code> may carry its own default <code>roomId</code> string (<code>class.schema.ts:30-31</code>) — a <strong>separate</strong> field from the timetable entry's <code>roomId</code> ObjectId; the client must not treat them as the same source of truth.</p> <h2 id="3-entry-lifecycle-end-to-end"><a class="header" href="#3-entry-lifecycle-end-to-end">3. Entry lifecycle (end-to-end)</a></h2> <pre><code>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) </code></pre> <p>Deletion semantics today: <strong>none</strong> — there is no <code>DELETE /timetable/:id</code>. The schema extends <code>BaseSchema</code> (soft-delete columns exist) but no route, service method, or repository call uses them for timetable. Any "remove slot" UI is <code>(planned)</code>.</p> <h2 id="4-conflict-detection--the-one-real-business-rule"><a class="header" href="#4-conflict-detection--the-one-real-business-rule">4. Conflict detection — the one real business rule</a></h2> <p><code>TimetableService.create</code> loads existing entries for the same day where <code>teacherId</code> <strong>or</strong> <code>roomId</code> matches the new entry (<code>timetable.service.ts:17-22</code>), then throws <code>ConflictException('Schedule conflict detected')</code> if any of them overlaps in time (<code>timetable.service.ts:24-30</code>, <code>62-69</code>):</p> <div class="table-wrapper"><table><thead><tr><th>Check</th><th>Covered?</th><th>Evidence</th></tr></thead><tbody> <tr><td>Same teacher, same day, overlapping time</td><td>✅ 409</td><td><code>$or</code> clause <code>{teacherId, dayOfWeek}</code> (<code>timetable.service.ts:19</code>)</td></tr> <tr><td>Same room, same day, overlapping time</td><td>✅ 409 (only when <code>roomId</code> set)</td><td><code>{roomId, dayOfWeek}</code> (<code>timetable.service.ts:20</code>)</td></tr> <tr><td>Same class, same day, overlapping time</td><td>❌ <strong>not checked</strong> — two entries for one class may overlap</td><td>query has no <code>classId</code> clause (<code>timetable.service.ts:17-22</code>)</td></tr> <tr><td>Boundary overlap (<code>end == start</code> of another entry)</td><td>✅ no overlap (<code>start1 < end2 && start2 < end1</code>, <code>timetable.service.ts:68</code>)</td><td>back-to-back periods are legal</td></tr> <tr><td>Overlap with <strong>other</strong> day</td><td>❌ no check (correct by design — weekly model)</td><td>day filter only</td></tr> </tbody></table> </div> <p>When <code>roomId</code> is absent from the DTO, Mongoose strips <code>undefined</code> from the query, so the <code>roomId</code> clause is a no-op — effectively teacher-only conflict detection for room-less entries.</p> <h2 id="5-permissions-reality"><a class="header" href="#5-permissions-reality">5. Permissions reality</a></h2> <ul> <li><code>timetable.read</code> and <code>timetable.create</code> <strong>exist</strong> in the permission catalog (<code>permissions.constants.ts:44-45</code>).</li> <li><code>TimetableController</code> is guarded <strong>only</strong> by <code>@UseGuards(JwtAuthGuard)</code> (<code>timetable.controller.ts:9</code>); no <code>@Roles</code>/<code>@Permissions</code> metadata is applied, so the global <code>RbacGuard</code> passes any authenticated request (<code>rbac.guard.ts:29</code>).</li> <li>Therefore the client must enforce <code>timetable.read</code> / <code>timetable.create</code> in UI/routing itself (mirror guard), and should not assume server-side 403s.</li> </ul> <h2 id="6-events--side-effects-1"><a class="header" href="#6-events--side-effects-1">6. Events & side-effects</a></h2> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Emitted at</th><th>Queue routing</th><th>Side-effect</th></tr></thead><tbody> <tr><td><code>TimetableEntryCreated</code></td><td><code>timetable.service.ts:33-44</code> (payload <code>{entryId, classId, teacherId}</code>)</td><td><strong>none found</strong> — grep of <code>event-queue-map.ts</code> shows no mapping</td><td>nothing wired; notifications/audit/search <code>(planned)</code></td></tr> </tbody></table> </div> <p>Gap: the event is emitted but never routed; subscribers (e.g. teacher notification, audit trail) are <code>(planned)</code>.</p> <h2 id="7-dependencies-1"><a class="header" href="#7-dependencies-1">7. Dependencies</a></h2> <div class="table-wrapper"><table><thead><tr><th>Dependency</th><th>Role</th><th>Source</th></tr></thead><tbody> <tr><td>Academics — <code>Class</code></td><td><code>classId</code> target of a slot</td><td><code>class.schema.ts:8-35</code> (name, gradeId, sectionId, capacity, classTeacherId)</td></tr> <tr><td>Academics — <code>Subject</code></td><td><code>subjectId</code> of a slot</td><td><code>subject.schema.ts:8-35</code> (code, name, shortName)</td></tr> <tr><td>Academics — <code>AcademicYear</code></td><td><code>academicYearId</code> scoping</td><td><code>timetable.schema.ts:39-40</code></td></tr> <tr><td>Teachers — <code>Teacher</code></td><td><code>teacherId</code> of a slot</td><td><code>teacher.schema.ts:15-52</code></td></tr> <tr><td>Rooms — <code>Room</code></td><td>optional <code>roomId</code> of a slot</td><td><code>room.schema.ts:16-35</code>; <code>rooms.controller.ts:20</code></td></tr> <tr><td>RBAC</td><td><code>timetable.read</code> / <code>timetable.create</code> perms (client-side gate only)</td><td><code>permissions.constants.ts:44-45</code></td></tr> <tr><td>Event bus</td><td><code>TimetableEntryCreated</code> emission</td><td><code>timetable.service.ts:33</code></td></tr> </tbody></table> </div> <h2 id="8-business-goals-1"><a class="header" href="#8-business-goals-1">8. Business goals</a></h2> <div class="table-wrapper"><table><thead><tr><th>Goal</th><th>Measure</th></tr></thead><tbody> <tr><td>Build a weekly grid in < 2 min</td><td>≤ N create calls, one per slot; conflict feedback ≤ 1 round trip</td></tr> <tr><td>No teacher double-booking</td><td>409 <code>Schedule conflict detected</code> (<code>timetable.service.ts:28</code>)</td></tr> <tr><td>No room overlap</td><td>409 when <code>roomId</code> set and overlapping</td></tr> <tr><td>Tenant isolation structural</td><td>every repo call injects <code>tenantId</code> + <code>isDeleted:false</code> (<code>base.repository.ts:20-30</code>)</td></tr> <tr><td>Sorting contract stable</td><td><code>dayOfWeek, startTime</code> ascending on both read paths (<code>timetable.service.ts:51,58</code>)</td></tr> </tbody></table> </div> <h2 id="9-success-metrics-1"><a class="header" href="#9-success-metrics-1">9. Success metrics</a></h2> <ul> <li>Grid renders from a single <code>GET /timetable?classId=</code> response, server-sorted (<code>timetable.service.ts:48-53</code>) — client never re-sorts.</li> <li>Entry create with conflict → inline <code>AppBanner</code> in the editor, form values kept.</li> <li>Grid cells render ≥ 40 slots without jank on low-end devices (see <code>15 §9</code>).</li> <li>Zero <code>tenantId</code> leaks in bodies; cross-tenant reads are 404s (<code>base.repository.ts:20-30</code>).</li> </ul> <h2 id="10-edge-cases-derived-1"><a class="header" href="#10-edge-cases-derived-1">10. Edge cases (derived)</a></h2> <ul> <li><code>GET /timetable</code> with <strong>neither</strong> <code>classId</code> nor <code>teacherId</code> → <code>[]</code> (<code>timetable.controller.ts:26-28</code>).</li> <li>Both params present → <code>classId</code> wins (<code>timetable.controller.ts:26-27</code>).</li> <li>Conflict on create → 409 <code>ConflictException</code> — <strong>no error code in body beyond the default NestJS shape</strong>; client matches on 409 status + message.</li> <li>Times are plain strings (<code>@IsString</code>, <code>create-timetable-entry.dto.ts:28-33</code>): overlap math is <strong>lexical string comparison</strong> (<code>timetable.service.ts:62-69</code>) — valid only while times are zero-padded <code>HH:MM</code>; client must enforce the format (server does not).</li> <li><code>dayOfWeek</code> values: <code>monday</code>–<code>saturday</code> only (<code>timetable.schema.ts:7-14</code>); <code>sunday</code> is rejected by <code>@IsEnum</code> (<code>create-timetable-entry.dto.ts:23-25</code>).</li> </ul> <h2 id="11-assumptions-module-2"><a class="header" href="#11-assumptions-module-2">11. Assumptions (module)</a></h2> <ul> <li>Client is <strong>forward-looking</strong>: backend implements only create + two reads; the full client (grid UX, editors, room view) is specced here per user instruction. The PRD lists <strong>native mobile as a later-phase, read-only companion</strong>; this package specs a full client (read + write) anyway — the two mandates coexist; write surfaces target admin/coordinator roles.</li> <li>Update/delete/bulk-import/substitution/workload/export are <strong>not implemented</strong> (<code>IMPLEMENTATION_PLAN.md:226</code> marks "Bulk create, conflicts, substitution, workload, export" as a planned phase-4 row) → all such surfaces are <code>(planned)</code>.</li> <li>Room view exists only as a client composition (fetch per-class grids and merge); a dedicated <code>GET /timetable?roomId=</code> is <code>(planned)</code>.</li> <li>No Sunday teaching; <code>saturday</code> is a legal teaching day.</li> </ul> <h2 id="12-open-questions-module-level-1"><a class="header" href="#12-open-questions-module-level-1">12. Open questions (module-level)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Item</th><th>Impact</th></tr></thead><tbody> <tr><td>OQ-1</td><td>No class-vs-class overlap check on create (<code>timetable.service.ts:17-22</code> excludes <code>classId</code>) — is a class double-booked during the same period intentional (e.g. split classes)?</td><td>Conflict banner logic, editor warnings</td></tr> <tr><td>OQ-2</td><td>No update/delete endpoints — how are mistakes corrected today (delete+recreate via planned routes)?</td><td>Editor UX (edit-in-place vs delete+create)</td></tr> <tr><td>OQ-3</td><td><code>TimetableEntryCreated</code> is emitted but unrouted (<code>timetable.service.ts:33-44</code>, no <code>event-queue-map.ts</code> entry) — intended consumers?</td><td>Notification/audit roadmap</td></tr> <tr><td>OQ-4</td><td>No RBAC metadata on endpoints (<code>timetable.controller.ts:9</code>) despite <code>timetable.read/create</code> existing (<code>permissions.constants.ts:44-45</code>) — who may write?</td><td>Permission UI</td></tr> <tr><td>OQ-5</td><td>Time format is unvalidated <code>@IsString</code> (<code>create-timetable-entry.dto.ts:27-33</code>) yet overlap detection depends on zero-padded lexical order — enforce <code>HH:MM</code> server-side?</td><td>Validation + conflict correctness</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-timetable-module"><a class="header" href="#02--user-personas-timetable-module">02 — User Personas (Timetable Module)</a></h1> <ul> <li><a href="timetable/02_User_Personas.html#1-organization-admin">1. Organization Admin</a></li> <li><a href="timetable/02_User_Personas.html#2-academic-coordinator-scheduling-operator">2. Academic Coordinator (scheduling operator)</a></li> <li><a href="timetable/02_User_Personas.html#3-teacher-self--read-only">3. Teacher (self — read-only)</a></li> <li><a href="timetable/02_User_Personas.html#4-room--facilities-manager-derived">4. Room / Facilities Manager (derived)</a></li> <li><a href="timetable/02_User_Personas.html#5-persona--surface-map">5. Persona → surface map</a></li> </ul> <blockquote> <p>Derived from <code>blueprint/01-Product/USER_PERSONAS.md</code> (tenant-level actors), the implemented RBAC permission catalog (<code>src/modules/rbac/permissions.constants.ts:44-45</code>), and the actual API surface (<code>src/modules/timetable/**</code>). Personas marked <em>(derived)</em> are role-level specializations inferred from module access patterns, not literal blueprint personas.</p> </blockquote> <hr /> <h2 id="1-organization-admin-1"><a class="header" href="#1-organization-admin-1">1. Organization Admin</a></h2> <ul> <li><strong>Blueprint anchor:</strong> "Manages a single institution (tenant). Goals: Configure branches, invite staff, oversee operations. Access: Full tenant admin, configurable permissions." (<code>USER_PERSONAS.md:20-25</code>)</li> <li><strong>System role:</strong> <code>org_admin</code> — <code>permissions: ALL_PERMISSIONS</code> (<code>role.schema.ts:17-24</code>), which includes <code>timetable.read</code> and <code>timetable.create</code> (<code>permissions.constants.ts:44-45</code>).</li> <li><strong>Goals:</strong> a complete, conflict-free weekly timetable before term start; teachers and rooms never double-booked; changes applied fast during term (substitutions).</li> <li><strong>Pain points:</strong> there is <strong>no update/delete endpoint</strong> — fixing a mistake means recreating entries via the planned routes (OQ-2); class-vs-class overlaps are <strong>not</strong> detected server-side (<code>timetable.service.ts:17-22</code>), so the admin carries that check in their head.</li> <li><strong>Context:</strong> desktop-first, term-start bursts; the grid is the primary surface.</li> <li><strong>Success:</strong> full week built in one session with zero 409s on submit.</li> </ul> <h2 id="2-academic-coordinator-scheduling-operator"><a class="header" href="#2-academic-coordinator-scheduling-operator">2. Academic Coordinator (scheduling operator)</a></h2> <ul> <li><strong>Blueprint anchor:</strong> derived from Staff persona ("coordinators… read access across modules", <code>USER_PERSONAS.md:34-39</code>) — the person who actually builds the timetable from teacher availability and room inventory. <em>(derived)</em></li> <li><strong>System role:</strong> custom role granted <code>timetable.read</code> + <code>timetable.create</code> (<code>permissions.constants.ts:44-45</code>) — client-side gating only, server is JWT-only (<code>timetable.controller.ts:9</code>, <a href="timetable/01_Product_Overview.html">01 §5</a>).</li> <li><strong>Goals:</strong> lay out the weekly grid per class; balance teacher load; assign rooms; resolve conflicts as they appear (409 from <code>timetable.service.ts:28</code>).</li> <li><strong>Pain points:</strong> room view has <strong>no API</strong> (no <code>roomId</code> query param, <code>timetable.controller.ts:20-29</code>) — must compose it client-side from class grids <code>(planned) for backend support</code>; no bulk create (<code>IMPLEMENTATION_PLAN.md:226</code>).</li> <li><strong>Context:</strong> desktop; heavy keyboard + drag & drop usage (<a href="timetable/15_Flutter_Implementation_Guide.html">15 §8</a>).</li> </ul> <h2 id="3-teacher-self--read-only"><a class="header" href="#3-teacher-self--read-only">3. Teacher (self — read-only)</a></h2> <ul> <li><strong>Blueprint anchor:</strong> "Delivers instruction. Goals: Mark attendance, assign homework, enter exam marks. Pain: Wants minimal clicks; mobile-friendly. Access: Own classes, own students." (<code>USER_PERSONAS.md:27-32</code>)</li> <li><strong>System role:</strong> <code>teacher</code> — <code>permissions: ['student.read','attendance.mark','attendance.edit']</code> (<code>role.schema.ts:26-32</code>) — <strong>no <code>timetable.*</code> grant</strong>; "My schedule" is served by <code>GET /timetable?teacherId=<me></code> (<code>timetable.controller.ts:23-27</code>) using the teacher's own id, read-only.</li> <li><strong>Goals:</strong> know where to be at any time, in < 10 s, on a phone; see today's classes and the whole week.</li> <li><strong>Pain points:</strong> no self-resolution endpoint (<code>GET /timetable/me</code> does 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).</li> <li><strong>Context:</strong> phone-first, during school hours; minimal interaction — view-only grid.</li> </ul> <h2 id="4-room--facilities-manager-derived"><a class="header" href="#4-room--facilities-manager-derived">4. Room / Facilities Manager <em>(derived)</em></a></h2> <ul> <li><strong>Blueprint anchor:</strong> derived from Staff persona + the existence of the Rooms module (<code>room.schema.ts:16-35</code>, <code>rooms.controller.ts:20-52</code>). <em>(derived)</em></li> <li><strong>System role:</strong> custom role with <code>rooms.read</code> (<code>permissions.constants.ts:50</code>).</li> <li><strong>Goals:</strong> see which room is free when; spot room overlaps; plan lab/hall usage.</li> <li><strong>Pain points:</strong> the API cannot answer "what's booked in room X" — <code>GET /timetable</code> only filters by <code>classId</code>/<code>teacherId</code> (<code>timetable.controller.ts:22-25</code>). The client merges per-class responses; a native room filter is <code>(planned)</code>.</li> </ul> <hr /> <h2 id="5-persona--surface-map-1"><a class="header" href="#5-persona--surface-map-1">5. Persona → surface map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Persona</th><th>Primary surfaces (this module)</th><th>Key endpoints</th></tr></thead><tbody> <tr><td>Org Admin</td><td>Class grid, entry editor, conflict banner</td><td><code>POST /timetable</code> (<code>timetable.controller.ts:14-18</code>), <code>GET /timetable?classId=</code> (<code>:23</code>)</td></tr> <tr><td>Academic Coordinator</td><td>Class grid, teacher grid, room view <code>(planned)</code>, entry editor</td><td><code>POST /timetable</code>, <code>GET /timetable?classId=</code>, <code>GET /timetable?teacherId=</code> (<code>:26</code>)</td></tr> <tr><td>Teacher (self)</td><td>My schedule grid (read-only)</td><td><code>GET /timetable?teacherId=<me></code> (<code>:26</code>)</td></tr> <tr><td>Room / Facilities Manager</td><td>Room schedule <code>(planned)</code></td><td>composed from <code>GET /timetable?classId=</code>; native <code>?roomId=</code> <code>(planned)</code></td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journeys-timetable-module"><a class="header" href="#03--user-journeys-timetable-module">03 — User Journeys (Timetable Module)</a></h1> <ul> <li><a href="timetable/03_User_Journey.html#journey-1--create-a-timetable-entry-coordinator--admin">Journey 1 — Create a timetable entry (Coordinator / Admin)</a></li> <li><a href="timetable/03_User_Journey.html#journey-2--view-a-classs-weekly-grid-all-personas-read">Journey 2 — View a class's weekly grid (all personas, read)</a></li> <li><a href="timetable/03_User_Journey.html#journey-3--view-a-teachers-weekly-grid-teacher-self--coordinator">Journey 3 — View a teacher's weekly grid (teacher self / coordinator)</a></li> <li><a href="timetable/03_User_Journey.html#journey-4--resolve-a-conflict-coordinator--admin">Journey 4 — Resolve a conflict (Coordinator / Admin)</a></li> <li><a href="timetable/03_User_Journey.html#journey-5--room-schedule-view-facilities-manager--planned">Journey 5 — Room schedule view (Facilities Manager) — <code>(planned)</code></a></li> <li><a href="timetable/03_User_Journey.html#failure-exits-shared">Failure exits (shared)</a></li> </ul> <blockquote> <p>Five journeys mapped 1:1 to the implemented API. Endpoint citations: <code>timetable.controller.ts:14-29</code>, <code>timetable.service.ts:16-69</code>. <code>(planned)</code> = roadmap/documented but not in code.</p> </blockquote> <hr /> <h2 id="journey-1--create-a-timetable-entry-coordinator--admin"><a class="header" href="#journey-1--create-a-timetable-entry-coordinator--admin">Journey 1 — Create a timetable entry (Coordinator / Admin)</a></h2> <pre class="mermaid">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] </pre> <ul> <li>Body: <code>CreateTimetableEntryDto</code> (<code>create-timetable-entry.dto.ts:5-38</code>).</li> <li>Conflict rule: query matches same-day entries sharing <code>teacherId</code> <strong>or</strong> <code>roomId</code> (<code>timetable.service.ts:17-22</code>); overlapping time (<code>start1 < end2 && start2 < end1</code>, <code>timetable.service.ts:68</code>) → 409.</li> <li><strong>Not checked:</strong> same-class overlap (OQ-1) — client may warn but server accepts.</li> <li>Side-effect: <code>TimetableEntryCreated</code> (<code>timetable.service.ts:33-44</code>) — no queue routing exists (<code>01 §6</code>).</li> </ul> <h2 id="journey-2--view-a-classs-weekly-grid-all-personas-read"><a class="header" href="#journey-2--view-a-classs-weekly-grid-all-personas-read">Journey 2 — View a class's weekly grid (all personas, read)</a></h2> <pre class="mermaid">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] </pre> <ul> <li>Server-sorted <code>{dayOfWeek:1, startTime:1}</code> (<code>timetable.service.ts:48-53</code>) — client renders, never re-sorts.</li> <li>Day headers from <code>DayOfWeek</code> enum (<code>timetable.schema.ts:7-14</code>): Monday–Saturday.</li> </ul> <h2 id="journey-3--view-a-teachers-weekly-grid-teacher-self--coordinator"><a class="header" href="#journey-3--view-a-teachers-weekly-grid-teacher-self--coordinator">Journey 3 — View a teacher's weekly grid (teacher self / coordinator)</a></h2> <pre class="mermaid">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] </pre> <ul> <li>Same sort contract (<code>timetable.service.ts:55-60</code>).</li> <li>A teacher resolving "me" has <strong>no</strong> <code>/timetable/me</code> endpoint — client must know the teacher id (OQ-2, teachers <code>03 §5</code>).</li> </ul> <h2 id="journey-4--resolve-a-conflict-coordinator--admin"><a class="header" href="#journey-4--resolve-a-conflict-coordinator--admin">Journey 4 — Resolve a conflict (Coordinator / Admin)</a></h2> <pre class="mermaid">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 </pre> <ul> <li>Client can pre-flight conflicts locally: loaded grids per teacher/room let the editor warn <strong>before</strong> submit; server remains the source of truth (races possible).</li> <li>No partial write: a 409 leaves the grid untouched.</li> </ul> <h2 id="journey-5--room-schedule-view-facilities-manager--planned"><a class="header" href="#journey-5--room-schedule-view-facilities-manager--planned">Journey 5 — Room schedule view (Facilities Manager) — <code>(planned)</code></a></h2> <pre class="mermaid">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 </pre> <ul> <li>No <code>roomId</code> query param on <code>GET /timetable</code> today (<code>timetable.controller.ts:22-25</code>) — the merged client view is a documented composition, not an invention of API surface; native filter is <code>(planned)</code> per <code>IMPLEMENTATION_PLAN.md:226</code> ("conflicts… export" phase).</li> </ul> <hr /> <h2 id="failure-exits-shared"><a class="header" href="#failure-exits-shared">Failure exits (shared)</a></h2> <p>401 → silent refresh → <code>sessionExpired</code>; 429 → "try again in a moment", no auto-retry; 5xx → generic + requestId + retry (per <code>00-shared/07 §11</code>).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-timetable-module"><a class="header" href="#04--information-architecture-timetable-module">04 — Information Architecture (Timetable Module)</a></h1> <ul> <li><a href="timetable/04_Information_Architecture.html#1-placement-in-the-app-shell">1. Placement in the app shell</a></li> <li><a href="timetable/04_Information_Architecture.html#2-sitemap-client-this-module">2. Sitemap (client, this module)</a></li> <li><a href="timetable/04_Information_Architecture.html#3-screen-relationships">3. Screen relationships</a></li> <li><a href="timetable/04_Information_Architecture.html#4-navigation-details">4. Navigation details</a></li> <li><a href="timetable/04_Information_Architecture.html#5-role--surface-gating-client-side-server-enforces-jwt-only--01-5">5. Role → surface gating (client-side; server enforces JWT only — <code>01 §5</code>)</a></li> <li><a href="timetable/04_Information_Architecture.html#6-information-units-primary-entities">6. Information units (primary entities)</a></li> <li><a href="timetable/04_Information_Architecture.html#7-empty-state-strategy">7. Empty-state strategy</a></li> </ul> <blockquote> <p>Fits the shared shell (<a href="timetable/../00-shared/05_Global_Information_Architecture.html">00-shared/05_Global_Information_Architecture.md</a>). Timetable is a <strong>scheduling workspace</strong> over the Academics/Teachers/Rooms catalogs: routes live under an <code>Academics</code>/<code>Scheduling</code> destination, keyed by the entity being scheduled (class, teacher, room).</p> </blockquote> <hr /> <h2 id="1-placement-in-the-app-shell-4"><a class="header" href="#1-placement-in-the-app-shell-4">1. Placement in the app shell</a></h2> <ul> <li><strong>Primary home:</strong> <code>Academics</code> destination (admin/coordinator role-gated) — the class grid is the entry surface, per-class navigation following the <a href="timetable/05_Screen_Inventory.html">05_Global_Information_Architecture.md:39</a> shell pattern.</li> <li><strong>Teacher schedule:</strong> secondary entry from Teacher detail · Schedule tab (<code>GET /timetable?teacherId=</code>, <code>timetable.controller.ts:26</code>); and a teacher-self "My Schedule" surface <code>(planned)</code>.</li> <li><strong>Room schedule:</strong> tertiary, from Rooms module detail — client composition today, native <code>(planned)</code> (see <a href="timetable/03_User_Journey.html">03 §J5</a>).</li> <li><strong>Cross-links:</strong> Academics (classes, subjects, academic years), Teachers, Rooms, Notifications (<code>TimetableEntryCreated</code> consumers <code>(planned)</code>).</li> </ul> <h2 id="2-sitemap-client-this-module-1"><a class="header" href="#2-sitemap-client-this-module-1">2. Sitemap (client, this module)</a></h2> <pre><code>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 </code></pre> <h2 id="3-screen-relationships-1"><a class="header" href="#3-screen-relationships-1">3. Screen relationships</a></h2> <ul> <li>Class grid → tap empty slot → Entry Editor (bottom sheet on phone, dialog on desktop); tap filled slot → detail popover → "Edit" <code>(planned)</code> / "Duplicate".</li> <li>Class grid ↔ Teacher grid: switch scope via segmented control on the same route — data source swaps between <code>?classId=</code> and <code>?teacherId=</code> (<code>timetable.controller.ts:23-26</code>).</li> <li>Grid → Room view: open from a slot's room chip (composed view, <code>03 §J5</code>).</li> <li>Conflict banner is <strong>inline state of the editor</strong>, not a page.</li> </ul> <h2 id="4-navigation-details-1"><a class="header" href="#4-navigation-details-1">4. Navigation details</a></h2> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Rule</th></tr></thead><tbody> <tr><td>Route params</td><td><code>classId</code> / <code>teacherId</code> / <code>roomId</code> = Mongo ObjectIds (<code>timetable.schema.ts:18-28</code>)</td></tr> <tr><td>Grid scope state</td><td>current scope (class | teacher | room) + week offset — kept in shell state across navigation (<code>05 §3</code>)</td></tr> <tr><td>Deep link</td><td><code>studylyon://timetable?classId=:id</code>; <code>studylyon://timetable/teacher/:teacherId</code></td></tr> <tr><td>Breadcrumbs</td><td>desktop: Academics › Timetable › {Class name}</td></tr> <tr><td>FAB</td><td>Grid screens: "Add slot" opens the editor prefilled with the current class</td></tr> <tr><td>Context menu</td><td>Slot long-press / right-click: Edit <code>(planned)</code>, Duplicate, Delete <code>(planned)</code></td></tr> <tr><td>Keyboard</td><td><code>+</code> / <code>N</code> = new slot; <code>←/→</code> = week navigation (desktop)</td></tr> </tbody></table> </div> <h2 id="5-role--surface-gating-client-side-server-enforces-jwt-only--01-5-1"><a class="header" href="#5-role--surface-gating-client-side-server-enforces-jwt-only--01-5-1">5. Role → surface gating (client-side; server enforces JWT only — <code>01 §5</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Surface</th><th>org_admin / coordinator (<code>timetable.create</code>)</th><th>teacher (self)</th><th>read-only staff</th></tr></thead><tbody> <tr><td>Class grid</td><td>full</td><td>—</td><td>read (<code>timetable.read</code>, <code>permissions.constants.ts:44</code>)</td></tr> <tr><td>Teacher grid</td><td>full</td><td>own only <code>(planned)</code></td><td>read</td></tr> <tr><td>Room view <code>(planned)</code></td><td>full</td><td>—</td><td>read</td></tr> <tr><td>Entry editor (create)</td><td>full</td><td>—</td><td>—</td></tr> <tr><td>Edit / delete <code>(planned)</code></td><td>full</td><td>—</td><td>—</td></tr> </tbody></table> </div> <p>Server reality: no endpoint carries RBAC metadata (<code>timetable.controller.ts:9</code>); cross-tenant access is still structurally blocked via <code>scopedFilter</code> (<code>base.repository.ts:20-30</code>).</p> <h2 id="6-information-units-primary-entities-1"><a class="header" href="#6-information-units-primary-entities-1">6. Information units (primary entities)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Entity</th><th>Collection</th><th>Key fields</th><th>Client model</th></tr></thead><tbody> <tr><td>TimetableEntry</td><td><code>timetable_entries</code></td><td>classId, subjectId, teacherId, roomId?, dayOfWeek, startTime, endTime, academicYearId</td><td><code>TimetableEntry</code></td></tr> <tr><td>Class (ref)</td><td><code>classes</code></td><td>name, gradeId, sectionId, academicYearId, roomId (string, distinct)</td><td><code>Class</code> (reference)</td></tr> <tr><td>Subject (ref)</td><td><code>subjects</code></td><td>code, name, shortName</td><td><code>Subject</code> (reference)</td></tr> <tr><td>Teacher (ref)</td><td><code>teachers</code></td><td>employeeNumber, employmentStatus, userId</td><td><code>Teacher</code> (reference)</td></tr> <tr><td>Room (ref)</td><td><code>rooms</code></td><td>name, code, capacity, type</td><td><code>Room</code> (reference)</td></tr> <tr><td>AcademicYear (ref)</td><td><code>academic_years</code></td><td>name, startDate, endDate, isCurrent</td><td><code>AcademicYear</code> (reference)</td></tr> </tbody></table> </div> <p>Server returns <strong>raw ObjectIds</strong> for all refs (<code>timetable.schema.ts:18-40</code>) — the client joins names from catalogs; missing ref id renders "—".</p> <h2 id="7-empty-state-strategy-1"><a class="header" href="#7-empty-state-strategy-1">7. Empty-state strategy</a></h2> <ul> <li>Grid with zero entries → <code>AppEmptyState</code> "No classes scheduled" + CTA "Add slot".</li> <li>Single empty day → dashed empty cells, no empty-state page.</li> <li>Teacher grid empty → "No classes scheduled" (no editor CTA for teacher role).</li> <li>Room view with no matches → "No bookings for this room".</li> <li>Conflict submit → inline <code>AppBanner</code>, never an empty state.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-timetable-module"><a class="header" href="#05--screen-inventory-timetable-module">05 — Screen Inventory (Timetable Module)</a></h1> <ul> <li><a href="timetable/05_Screen_Inventory.html#legend">Legend</a></li> <li><a href="timetable/05_Screen_Inventory.html#s1--class-timetable-grid-academicstimetableclassidid">S1 — Class Timetable Grid (<code>/academics/timetable?classId=:id</code>)</a></li> <li><a href="timetable/05_Screen_Inventory.html#s2--teacher-timetable-grid-academicstimetableteacherteacherid">S2 — Teacher Timetable Grid (<code>/academics/timetable/teacher/:teacherId</code>)</a></li> <li><a href="timetable/05_Screen_Inventory.html#s3--room-timetable-grid-academicstimetableroomroomid-planned">S3 — Room Timetable Grid (<code>/academics/timetable/room/:roomId</code>) <code>(planned)</code></a></li> <li><a href="timetable/05_Screen_Inventory.html#s4--entry-editor-bottom-sheet-on-phone--dialog-on-desktop">S4 — Entry Editor (bottom sheet on phone / dialog on desktop)</a></li> <li><a href="timetable/05_Screen_Inventory.html#s5--conflict-banner-inline-state-not-a-page">S5 — Conflict banner (inline state, not a page)</a></li> <li><a href="timetable/05_Screen_Inventory.html#s6--week-navigation--today-view">S6 — Week Navigation & Today View</a></li> <li><a href="timetable/05_Screen_Inventory.html#shared-components-used">Shared components used</a></li> <li><a href="timetable/05_Screen_Inventory.html#analytics-events-proposed">Analytics events (proposed)</a></li> <li><a href="timetable/05_Screen_Inventory.html#keyboard-landscape-tablet-desktop">Keyboard, landscape, tablet, desktop</a></li> </ul> <blockquote> <p>Every screen of the Timetable module, its intent, route, composition, states, permissions, platform behaviour and events. Authoritative components in <a href="timetable/../00-shared/03_Component_Library.html">00-shared/03</a>; this file enumerates which ones each screen uses with module specifics. The <strong>weekly grid is the hero component</strong> — see <a href="timetable/06_Screen_Specifications.html">06 §S1</a> and <a href="timetable/07_Component_Library.html">07 §A1</a>.</p> </blockquote> <hr /> <h2 id="legend-18"><a class="header" href="#legend-18">Legend</a></h2> <p>States = idle / loading / success / empty / error(offline, rate, invalid) / disabled / permission. Analytics events follow <code>{module}.{screen}.{action}</code> (proposed; SDK open — <a href="timetable/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a>).</p> <hr /> <h2 id="s1--class-timetable-grid-academicstimetableclassidid"><a class="header" href="#s1--class-timetable-grid-academicstimetableclassidid">S1 — Class Timetable Grid (<code>/academics/timetable?classId=:id</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Weekly grid for one class: 6 day columns × time rows; slot cards = subject · teacher · room</td></tr> <tr><td>Entry</td><td>Academics nav → pick class; deep link; FAB "Add slot"</td></tr> <tr><td>Exit</td><td>tap slot → S4; scope switch → S2; room chip → S3 <code>(planned)</code></td></tr> <tr><td>Inputs</td><td>scope picker (class/teacher/room), week navigator, class picker</td></tr> <tr><td>Primary CTA</td><td>FAB "Add slot" (opens S4 prefilled with class)</td></tr> <tr><td>Source</td><td><code>GET /timetable?classId=</code> (<code>timetable.controller.ts:23</code>), server-sorted <code>dayOfWeek, startTime</code> (<code>timetable.service.ts:48-53</code>)</td></tr> <tr><td>States</td><td>loading (skeleton grid), empty (no entries), error, offline (cached last-good + banner), permission (<code>timetable.read</code>, <code>permissions.constants.ts:44</code>)</td></tr> <tr><td>Analytics</td><td><code>timetable.class.view</code>, <code>timetable.slot.tap</code> (proposed)</td></tr> <tr><td>Adaptive</td><td>phone: vertical list of days with horizontal time strip; tablet/desktop: true 6-column grid, drag & drop (<a href="timetable/15_Flutter_Implementation_Guide.html">15 §8</a>)</td></tr> <tr><td>a11y</td><td>each slot a single button semantics with full label; grid headers announced; focus order left→right, top→bottom</td></tr> <tr><td>Motion</td><td>grid fade <code>m-fast</code>; slot insert/remove <code>AnimatedList</code> <code>m-base</code> (<a href="timetable/10_Interaction_Specification.html">10 §4</a>)</td></tr> </tbody></table> </div> <h2 id="s2--teacher-timetable-grid-academicstimetableteacherteacherid"><a class="header" href="#s2--teacher-timetable-grid-academicstimetableteacherteacherid">S2 — Teacher Timetable Grid (<code>/academics/timetable/teacher/:teacherId</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Same grid component, teacher scope; read-only for teacher self</td></tr> <tr><td>Entry</td><td>Teacher detail · Schedule tab; scope switch from S1; deep link</td></tr> <tr><td>Source</td><td><code>GET /timetable?teacherId=</code> (<code>timetable.controller.ts:26</code>), sorted <code>dayOfWeek, startTime</code> (<code>timetable.service.ts:55-60</code>)</td></tr> <tr><td>Writes</td><td><strong>none</strong> for teacher role — no editor affordances (<a href="timetable/02_User_Personas.html">02 §3</a>)</td></tr> <tr><td>States</td><td>as S1; "no classes scheduled" empty state (no CTA for teachers)</td></tr> <tr><td>Analytics</td><td><code>timetable.teacher.view</code> (proposed)</td></tr> </tbody></table> </div> <h2 id="s3--room-timetable-grid-academicstimetableroomroomid-planned"><a class="header" href="#s3--room-timetable-grid-academicstimetableroomroomid-planned">S3 — Room Timetable Grid (<code>/academics/timetable/room/:roomId</code>) <code>(planned)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Booked slots per room, read-only</td></tr> <tr><td>Entry</td><td>slot's room chip; Rooms module detail</td></tr> <tr><td>Source</td><td><strong>no <code>roomId</code> filter exists</strong> (<code>timetable.controller.ts:22-25</code>) → client merges <code>GET /timetable?classId=</code> responses for classes using the room; native filter <code>(planned)</code> (<code>IMPLEMENTATION_PLAN.md:226</code>)</td></tr> <tr><td>States</td><td>as S1; "No bookings for this room"</td></tr> <tr><td>Analytics</td><td><code>timetable.room.view</code> (proposed)</td></tr> </tbody></table> </div> <h2 id="s4--entry-editor-bottom-sheet-on-phone--dialog-on-desktop"><a class="header" href="#s4--entry-editor-bottom-sheet-on-phone--dialog-on-desktop">S4 — Entry Editor (bottom sheet on phone / dialog on desktop)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Create one timetable entry (the only server write)</td></tr> <tr><td>Entry</td><td>FAB / empty cell tap / slot "Duplicate"</td></tr> <tr><td>Exit</td><td>success → grid insert; cancel; 409 conflict → stays open with banner</td></tr> <tr><td>Inputs</td><td>class ▾, subject ▾, teacher ▾, room ▾ (optional), day ▾, start time, end time, academic year ▾ (default <code>isCurrent</code>) — full spec <a href="timetable/08_Form_Specifications.html">08 F1</a></td></tr> <tr><td>Primary CTA</td><td>"Save" (disabled until valid; double-submit guard)</td></tr> <tr><td>Source</td><td><code>POST /timetable</code> (<code>timetable.controller.ts:14-18</code>), <code>CreateTimetableEntryDto</code> (<code>create-timetable-entry.dto.ts:5-38</code>)</td></tr> <tr><td>States</td><td>idle / submitting / 400 field errors / 409 conflict banner (<code>timetable.service.ts:28</code>) / 5xx</td></tr> <tr><td>Conflict UX</td><td><code>AppBanner</code>: "Teacher already booked {day} {start}–{end}" or "Room already in use…" — derived from the single server message + local pre-flight (<a href="timetable/06_Screen_Specifications.html">06 §S4</a>)</td></tr> <tr><td>Analytics</td><td><code>timetable.entry.submit</code>, <code>timetable.entry.conflict</code>, <code>timetable.entry.success</code> (proposed)</td></tr> </tbody></table> </div> <h2 id="s5--conflict-banner-inline-state-not-a-page"><a class="header" href="#s5--conflict-banner-inline-state-not-a-page">S5 — Conflict banner (inline state, not a page)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Surface the single server conflict rule: teacher or room double-booking on the same day with overlapping time (<code>timetable.service.ts:16-30,62-69</code>)</td></tr> <tr><td>Trigger</td><td>409 from <code>POST /timetable</code>; also local pre-flight warnings before submit (races possible — server authoritative)</td></tr> <tr><td>Placement</td><td>inside S4, above the submit button</td></tr> <tr><td>Actions</td><td>dismiss; "Change time/teacher/room" (focus related picker)</td></tr> <tr><td>Copy</td><td><code>timetable.conflict.teacher</code> / <code>timetable.conflict.room</code> — one generic server message ("Schedule conflict detected", <code>timetable.service.ts:28</code>) mapped to the specific pre-flight cause</td></tr> <tr><td>a11y</td><td>live-region announce; <code>errorContainer</code> tokens (<a href="timetable/11_Design_System_Mapping.html">11 §2</a>)</td></tr> </tbody></table> </div> <h2 id="s6--week-navigation--today-view"><a class="header" href="#s6--week-navigation--today-view">S6 — Week Navigation & Today View</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Move between weeks; collapse to today</td></tr> <tr><td>Behaviour</td><td><code>WeekNavigator</code> (<a href="timetable/07_Component_Library.html">07 §A7</a>): ‹ today › offset −1..0..+n <code>(forward-looking: weekly shift)</code> — pure client state, no server concept of "week"; entries are weekly-recurring by model (<code>timetable.schema.ts:30-37</code> stores weekday + time only, no date)</td></tr> <tr><td>Note</td><td>The backend has <strong>no date dimension</strong> — week navigation merely re-filters the same 6-day data; push notifications for "class in 15 min" <code>(forward-looking)</code> would need a new date-aware model</td></tr> </tbody></table> </div> <hr /> <h2 id="shared-components-used-23"><a class="header" href="#shared-components-used-23">Shared components used</a></h2> <p><code>AppDropdown</code>, <code>AppButton</code>, <code>AppSnackbar</code>, <code>AppCard</code>, <code>AppBottomSheet</code>, <code>AppDialog</code>, <code>AppMenu</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppErrorState</code>, <code>AppOfflineBanner</code>, <code>AppFAB</code>, <code>AppChips</code>, <code>AppBadge</code>, <code>AppTabs</code>, <code>AppSectionHeader</code>, <code>AppTimePicker</code>-style time field, <code>AppSearchBar</code>-style searchable pickers. Module-specific: <code>TimetableGrid</code>, <code>TimetableSlotCard</code>, <code>DayHeader</code>, <code>TimeGutter</code>, <code>GridCell</code>, <code>ConflictBanner</code>, <code>WeekNavigator</code> — defined in <a href="timetable/07_Component_Library.html">07_Component_Library.md</a>.</p> <h2 id="analytics-events-proposed-20"><a class="header" href="#analytics-events-proposed-20">Analytics events (proposed)</a></h2> <p><code>timetable.{class,teacher,room}.view</code>, <code>timetable.slot.tap</code>, <code>timetable.entry.{submit,conflict,success}</code>, <code>timetable.week.{prev,next,today}</code>, <code>timetable.conflict.dismiss</code> (all proposed).</p> <h2 id="keyboard-landscape-tablet-desktop-18"><a class="header" href="#keyboard-landscape-tablet-desktop-18">Keyboard, landscape, tablet, desktop</a></h2> <ul> <li>Phone portrait: day-strip list; landscape/tablet: grid with fixed day headers.</li> <li>Desktop: true grid + drag & drop (<a href="timetable/15_Flutter_Implementation_Guide.html">15 §8</a>), <code>+</code>/<code>N</code> new slot, <code>←/→</code> week nav.</li> <li>Grid scrolls horizontally on narrow widths; time gutter + day headers pinned (<a href="timetable/15_Flutter_Implementation_Guide.html">15 §9</a>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-timetable-module"><a class="header" href="#06--screen-specifications-timetable-module">06 — Screen Specifications (Timetable Module)</a></h1> <ul> <li><a href="timetable/06_Screen_Specifications.html#s1--class-timetable-grid-hero">S1 — Class Timetable Grid (hero)</a> <ul> <li><a href="timetable/06_Screen_Specifications.html#layout-phone--tablet--desktop">Layout (phone / tablet / desktop)</a></li> <li><a href="timetable/06_Screen_Specifications.html#behaviour--states">Behaviour & states</a></li> <li><a href="timetable/06_Screen_Specifications.html#data--interactions">Data & interactions</a></li> <li><a href="timetable/06_Screen_Specifications.html#accessibility">Accessibility</a></li> </ul> </li> <li><a href="timetable/06_Screen_Specifications.html#s2--teacher-timetable-grid">S2 — Teacher Timetable Grid</a></li> <li><a href="timetable/06_Screen_Specifications.html#s3--room-timetable-grid--planned">S3 — Room Timetable Grid — <code>(planned)</code></a></li> <li><a href="timetable/06_Screen_Specifications.html#s4--entry-editor-bottom-sheet-on-phone--dialog-on-desktop">S4 — Entry Editor (bottom sheet on phone / dialog on desktop)</a> <ul> <li><a href="timetable/06_Screen_Specifications.html#layout">Layout</a></li> <li><a href="timetable/06_Screen_Specifications.html#field-by-field-validation-server-dto-vs-client">Field-by-field validation (server DTO vs client)</a></li> <li><a href="timetable/06_Screen_Specifications.html#pre-flight-conflict-check-client-best-effort">Pre-flight conflict check (client, best-effort)</a></li> <li><a href="timetable/06_Screen_Specifications.html#submit--errors">Submit & errors</a></li> </ul> </li> <li><a href="timetable/06_Screen_Specifications.html#s5--conflict-banner-spec">S5 — Conflict Banner (spec)</a></li> <li><a href="timetable/06_Screen_Specifications.html#s6--week-navigation">S6 — Week Navigation</a></li> </ul> <blockquote> <p>The definitive per-screen spec. Wire contract per <a href="timetable/../00-shared/07_API_Conventions.html">00-shared/07</a>. All field names and rules cite the DTO/schema they come from. <code>(planned)</code> = backend/roadmap gap. <strong>The weekly grid (S1) is the hero component</strong> — largest spec, shared by S2/S3.</p> </blockquote> <hr /> <h2 id="s1--class-timetable-grid-hero"><a class="header" href="#s1--class-timetable-grid-hero">S1 — Class Timetable Grid (hero)</a></h2> <h3 id="layout-phone--tablet--desktop-1"><a class="header" href="#layout-phone--tablet--desktop-1">Layout (phone / tablet / desktop)</a></h3> <pre><code>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" </code></pre> <ul> <li><strong>Columns:</strong> 6 day columns from <code>DayOfWeek</code> enum — <code>monday</code>…<code>saturday</code> (<code>timetable.schema.ts:7-14</code>). <strong>No Sunday column</strong> (Sunday is not a legal value).</li> <li><strong>Rows:</strong> derived from the union of <code>startTime</code> values present in the week's data, sorted ascending (server contract <code>dayOfWeek, startTime</code>, <code>timetable.service.ts:51</code>). A slot spans row-height × duration (endTime − startTime), minimum 1 row.</li> <li><strong>Slot card content (TimetableSlotCard, <code>07 §A2</code>):</strong> <code>{subject shortName}</code> (titleSmall), <code>{teacher name}</code> · <code>{room code}</code> (bodySmall), optional room icon. Conflicts (same teacher/room in another slot of the SAME grid) get an <code>errorContainer</code> border + warning icon — <strong>client-derived</strong> (server allows same-class overlaps, OQ-1).</li> <li>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 (<code>15 §8</code>).</li> </ul> <h3 id="behaviour--states-1"><a class="header" href="#behaviour--states-1">Behaviour & states</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>UI</th><th>Source</th></tr></thead><tbody> <tr><td>Loading</td><td>skeleton grid (6 columns × 6 ghost rows, shimmer)</td><td><code>00-shared/06 §3.1</code></td></tr> <tr><td>Success + data</td><td>grid, slots placed</td><td>—</td></tr> <tr><td>Success + empty</td><td><code>AppEmptyState</code> "No classes scheduled" + CTA "Add slot"</td><td>—</td></tr> <tr><td>Error</td><td><code>AppErrorState(code, message, retry)</code></td><td>envelope <code>error.code</code></td></tr> <tr><td>Offline</td><td>cached last-good grid + <code>AppOfflineBanner</code></td><td><code>00-shared/06 §3.3</code></td></tr> <tr><td>Permission</td><td><code>timetable.read</code> gate (<code>permissions.constants.ts:44</code>)</td><td>client-side, <code>01 §5</code></td></tr> </tbody></table> </div> <h3 id="data--interactions-1"><a class="header" href="#data--interactions-1">Data & interactions</a></h3> <ul> <li>Fetch: <code>GET /timetable?classId=<id></code> (<code>timetable.controller.ts:23</code>) — non-paginated array, server-sorted <code>dayOfWeek, startTime</code> (<code>timetable.service.ts:48-53</code>). <strong>Client never re-sorts.</strong></li> <li>Both <code>classId</code> + <code>teacherId</code> supplied → <code>classId</code> wins (<code>timetable.controller.ts:26-27</code>).</li> <li>Tap empty cell → S4 (entry editor, prefilled class + day + time).</li> <li>Tap slot → detail popover (subject, teacher, room, time, year) with actions: Duplicate (opens S4 prefilled), Edit/Delete <code>(planned)</code> — no endpoints exist (<code>01 §10</code>).</li> <li>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 <strong>create</strong>, since no PATCH exists; original remains until the new entry is saved and the user deletes it <code>(planned)</code> — see <code>10 §2</code> and OQ-2).</li> <li>Long-press slot (mobile) → same actions as detail popover (<code>10 §1</code>).</li> <li>Pull-to-refresh bypasses cache; scope switch re-fetches; week navigator re-filters locally (no server date concept, <code>05 §S6</code>).</li> </ul> <h3 id="accessibility-4"><a class="header" href="#accessibility-4">Accessibility</a></h3> <ul> <li>Grid has one <code>Semantics</code> container per slot: "Mathematics, R. Kumar, Room 201, Monday 08:00–09:00".</li> <li>Headers: <code>dayOfWeek</code> + time gutter announced as column/row headers (<code>09 §6</code>).</li> <li>Conflict slots never color-only — border + warning icon + label ("double-booked", <code>09 §9</code>).</li> <li>Keyboard (desktop): arrow keys move cell focus; <code>Enter</code> opens editor; focus ring <code>primary</code> 2 dp (<code>09 §6</code>).</li> </ul> <hr /> <h2 id="s2--teacher-timetable-grid"><a class="header" href="#s2--teacher-timetable-grid">S2 — Teacher Timetable Grid</a></h2> <ul> <li>Same grid component, scope = teacher: <code>GET /timetable?teacherId=<id></code> (<code>timetable.controller.ts:26</code>), sorted <code>dayOfWeek, startTime</code> (<code>timetable.service.ts:55-60</code>).</li> <li>Slot content: <code>{subject shortName} · {class name}</code> (teacher's own slots).</li> <li>Teacher role → <strong>read-only</strong>: no FAB, no drag & drop, no long-press editor actions (<code>02 §3</code>); slot tap shows detail popover only.</li> <li>Empty → "No classes scheduled" (no CTA for teacher role).</li> <li>Coordinator/admin view additionally shows per-slot room code and opens S4 on empty cells.</li> </ul> <h2 id="s3--room-timetable-grid--planned"><a class="header" href="#s3--room-timetable-grid--planned">S3 — Room Timetable Grid — <code>(planned)</code></a></h2> <ul> <li>Client composition: fetch <code>GET /timetable?classId=</code> for every class whose default room or entries reference the room; merge slots; render read-only.</li> <li><strong>No <code>roomId</code> filter on the API</strong> (<code>timetable.controller.ts:22-25</code>); native <code>?roomId=</code> is <code>(planned)</code> (<code>IMPLEMENTATION_PLAN.md:226</code>).</li> <li>Slot content: <code>{subject shortName} · {class name} · {teacher}</code>.</li> <li>Empty → "No bookings for this room".</li> </ul> <h2 id="s4--entry-editor-bottom-sheet-on-phone--dialog-on-desktop-1"><a class="header" href="#s4--entry-editor-bottom-sheet-on-phone--dialog-on-desktop-1">S4 — Entry Editor (bottom sheet on phone / dialog on desktop)</a></h2> <h3 id="layout-23"><a class="header" href="#layout-23">Layout</a></h3> <pre><code>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] </code></pre> <h3 id="field-by-field-validation-server-dto-vs-client-1"><a class="header" href="#field-by-field-validation-server-dto-vs-client-1">Field-by-field validation (server DTO vs client)</a></h3> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Server (<code>create-timetable-entry.dto.ts</code>)</th><th>Client rules</th></tr></thead><tbody> <tr><td>classId</td><td>required <code>@IsMongoId</code> (6-8)</td><td>required; prefilled</td></tr> <tr><td>subjectId</td><td>required <code>@IsMongoId</code> (10-12)</td><td>required picker</td></tr> <tr><td>teacherId</td><td>required <code>@IsMongoId</code> (14-16)</td><td>required picker</td></tr> <tr><td>roomId</td><td>optional <code>@IsOptional @IsMongoId</code> (18-21)</td><td>optional picker</td></tr> <tr><td>dayOfWeek</td><td>required <code>@IsEnum(DayOfWeek)</code> (23-25)</td><td>6 values from <code>timetable.schema.ts:7-14</code></td></tr> <tr><td>startTime</td><td>required <code>@IsString</code> (27-29) — <strong>format unvalidated</strong></td><td>24 h <code>HH:MM</code> (e.g. <code>08:00</code>); regex `^([01]\d</td></tr> <tr><td>endTime</td><td>required <code>@IsString</code> (31-33) — <strong>no >start check server-side</strong></td><td><code>end > start</code> client-enforced <code>(proposed)</code>; 1-row minimum</td></tr> <tr><td>academicYearId</td><td>required <code>@IsMongoId</code> (35-37)</td><td>required; default <code>isCurrent</code> from <code>GET /academic-years</code></td></tr> </tbody></table> </div> <h3 id="pre-flight-conflict-check-client-best-effort"><a class="header" href="#pre-flight-conflict-check-client-best-effort">Pre-flight conflict check (client, best-effort)</a></h3> <ul> <li>Before submit, consult cached per-day grids: same <code>teacherId</code> overlapping → warn "Teacher already booked {day} {start}–{end}"; same <code>roomId</code> overlapping → warn "Room already in use". Server remains authoritative (409, races possible).</li> <li><strong>Same-class overlap is NOT warned</strong> — server permits it (OQ-1); editor copy must not claim a class-level conflict check.</li> </ul> <h3 id="submit--errors-1"><a class="header" href="#submit--errors-1">Submit & errors</a></h3> <ul> <li><code>POST /timetable</code> (<code>timetable.controller.ts:14-18</code>) → 200 <code>data</code> = entry doc.</li> <li>409 <code>ConflictException</code> "Schedule conflict detected" (<code>timetable.service.ts:28</code>) → <code>ConflictBanner</code> in-sheet, form values kept, focus conflicting picker.</li> <li>400 → map <code>error.details[].field</code> to form fields (<code>00-shared/07 §3</code>).</li> <li>Double-submit guard; offline → blocked with banner (no offline write queue).</li> <li>Success → slot inserted into grid (<code>AnimatedList</code>, <code>10 §4</code>), snackbar "Slot added".</li> </ul> <h2 id="s5--conflict-banner-spec"><a class="header" href="#s5--conflict-banner-spec">S5 — Conflict Banner (spec)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Detail</th></tr></thead><tbody> <tr><td>Trigger</td><td>409 on submit; pre-flight warning on picker change</td></tr> <tr><td>Placement</td><td>S4, between fields and Save</td></tr> <tr><td>Copy</td><td>teacher: "Teacher already booked {day} {start}–{end}" / room: "Room already in use {day} {start}–{end}" — mapped from the generic server message + local clash data</td></tr> <tr><td>Actions</td><td>[Dismiss] [Change time] (focuses time fields)</td></tr> <tr><td>Tokens</td><td><code>errorContainer</code>/<code>onErrorContainer</code>, warning icon <code>event_busy</code> (<code>11 §2</code>)</td></tr> <tr><td>A11y</td><td><code>SemanticsService.announce</code> on appear (live region)</td></tr> </tbody></table> </div> <h2 id="s6--week-navigation"><a class="header" href="#s6--week-navigation">S6 — Week Navigation</a></h2> <ul> <li><code>WeekNavigator</code> (<code>07 §A7</code>): <code>‹</code> / <code>›</code> shift displayed week, <code>Today</code> resets.</li> <li>Pure client state (no server date dimension — <code>timetable.schema.ts:30-37</code> stores weekday + time only); week offset stored in shell state (<code>04 §4</code>).</li> <li>Future date-aware model (specific dates, push reminders) <code>(forward-looking)</code>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-timetable-module"><a class="header" href="#07--component-library-timetable-module">07 — Component Library (Timetable Module)</a></h1> <ul> <li><a href="timetable/07_Component_Library.html#a-module-components">A. Module components</a> <ul> <li><a href="timetable/07_Component_Library.html#timetablegrid-hero-component-extends-appscrollcontainer">TimetableGrid (hero component, extends AppScrollContainer)</a></li> <li><a href="timetable/07_Component_Library.html#timetableslotcard-extends-appcard">TimetableSlotCard (extends AppCard)</a></li> <li><a href="timetable/07_Component_Library.html#gridcell">GridCell</a></li> <li><a href="timetable/07_Component_Library.html#dayheader--timegutter">DayHeader / TimeGutter</a></li> <li><a href="timetable/07_Component_Library.html#conflictbanner-extends-appbanner">ConflictBanner (extends AppBanner)</a></li> <li><a href="timetable/07_Component_Library.html#weeknavigator">WeekNavigator</a></li> <li><a href="timetable/07_Component_Library.html#entryeditorsheet-extends-appbottomsheet--appdialog">EntryEditorSheet (extends AppBottomSheet / AppDialog)</a></li> <li><a href="timetable/07_Component_Library.html#slotdetailpopover-extends-appmenu-surface">SlotDetailPopover (extends AppMenu surface)</a></li> <li><a href="timetable/07_Component_Library.html#teacherscheduletab-composition">TeacherScheduleTab (composition)</a></li> </ul> </li> <li><a href="timetable/07_Component_Library.html#b-reused-shared-components">B. Reused shared components</a></li> <li><a href="timetable/07_Component_Library.html#c-composition-rules">C. Composition rules</a></li> </ul> <blockquote> <p>Module-specific components; all reference the shared library (<a href="timetable/../00-shared/03_Component_Library.html">00-shared/03</a>). Every component listed here is a client composition over the implemented API — none invent backend data.</p> </blockquote> <hr /> <h2 id="a-module-components-1"><a class="header" href="#a-module-components-1">A. Module components</a></h2> <h3 id="timetablegrid-hero-component-extends-appscrollcontainer"><a class="header" href="#timetablegrid-hero-component-extends-appscrollcontainer">TimetableGrid (hero component, extends AppScrollContainer)</a></h3> <ul> <li>Props: <code>entries: List<TimetableEntry></code>, <code>scope</code> (class | teacher | room), <code>catalogs</code> (subject/teacher/room/class name resolvers), <code>readOnly</code>, <code>onCellTap</code>, <code>onSlotTap</code>, <code>onSlotDrag</code> (desktop), <code>onLongPress</code>.</li> <li>Structure: pinned <code>TimeGutter</code> (left) + 6 <code>DayHeader</code>s (Mon–Sat, from <code>DayOfWeek</code>, <code>timetable.schema.ts:7-14</code>) + row of <code>GridCell</code>s; rows = union of <code>startTime</code> values, ascending (server sort contract, <code>timetable.service.ts:51,58</code>).</li> <li>Slot placement: absolute-positioned <code>TimetableSlotCard</code>s within each day column, height ∝ <code>endTime − startTime</code> (min 1 row).</li> <li>Perf: row/cell widgets built via <code>ListView.builder</code>-style lazy construction; <code>RepaintBoundary</code> per slot card; scroll controllers shared gutter/header (<a href="timetable/15_Flutter_Implementation_Guide.html">15 §9</a>).</li> <li>Desktop: each slot wrapped in <code>Draggable<TimetableEntry></code>; cells are <code>DragTarget<TimetableEntry></code> (<a href="timetable/10_Interaction_Specification.html">10 §2</a>, <a href="timetable/15_Flutter_Implementation_Guide.html">15 §8</a>). Mobile: <code>LongPressDraggable</code> off — long-press opens actions instead.</li> <li>A11y: single <code>Semantics</code> node per slot with full label (<a href="timetable/06_Screen_Specifications.html">06 §S1</a>).</li> </ul> <h3 id="timetableslotcard-extends-appcard"><a class="header" href="#timetableslotcard-extends-appcard">TimetableSlotCard (extends AppCard)</a></h3> <ul> <li>Props: <code>entry</code> (subjectLabel, teacherLabel, roomLabel, startTime, endTime), <code>conflict: bool</code> (client-derived same-grid overlap), <code>onTap</code>, <code>onLongPress</code>, <code>onDrag</code> (desktop).</li> <li>Content: <code>{subject shortName}</code> (titleSmall), <code>{teacher} · {room}</code> (bodySmall); conflict → <code>errorContainer</code> border + <code>event_busy</code> icon + label "double-booked".</li> <li>Room label shows room <code>code</code> (<code>room.schema.ts:21-22</code>) — absent room → no chip.</li> <li>Tokens: <code>surfaceContainerLow</code>, <code>e-1</code>; hover (desktop) <code>e-2</code> + tint (<a href="timetable/10_Interaction_Specification.html">10 §2</a>).</li> </ul> <h3 id="gridcell"><a class="header" href="#gridcell">GridCell</a></h3> <ul> <li>Props: <code>day</code>, <code>startTime</code>, <code>endTime</code> implicit row, <code>onTap</code> (empty cell → S4), <code>acceptsDrop</code> (desktop).</li> <li>Empty state: dashed outline (<code>outlineVariant</code>), subtle hover fill.</li> <li>A11y: cell tap targets ≥ 44 dp; announced "Monday, 08:00, free".</li> </ul> <h3 id="dayheader--timegutter"><a class="header" href="#dayheader--timegutter">DayHeader / TimeGutter</a></h3> <ul> <li><code>DayHeader</code>: day label (short) + date-of-current-week (client computed); pinned top, sticky.</li> <li><code>TimeGutter</code>: <code>HH:MM</code> labels (<code>mono</code>, <code>tabularFigures</code>, <a href="timetable/02_User_Personas.html">02 §2</a>), pinned left.</li> <li>A11y: announced as column/row headers (<a href="timetable/06_Screen_Specifications.html">06 §S1</a>).</li> </ul> <h3 id="conflictbanner-extends-appbanner"><a class="header" href="#conflictbanner-extends-appbanner">ConflictBanner (extends AppBanner)</a></h3> <ul> <li>Props: <code>kind: teacher | room</code>, <code>clash</code> (day, startTime, endTime, otherPartyLabel), <code>onDismiss</code>, <code>onChangeTime</code>.</li> <li>Copy: "Teacher already booked Tue 08:00–09:00 (R. Kumar)" / "Room already in use Tue 08:00–09:00 (Room 201)".</li> <li>Tokens: <code>errorContainer</code>/<code>onErrorContainer</code> (<a href="timetable/11_Design_System_Mapping.html">11 §2</a>); live-region announce.</li> </ul> <h3 id="weeknavigator"><a class="header" href="#weeknavigator">WeekNavigator</a></h3> <ul> <li>Props: <code>offset</code>, <code>onPrev</code>, <code>onNext</code>, <code>onToday</code>.</li> <li>Content: <code>‹</code> [Week of {date} · {day range}] <code>›</code> + "Today" text button.</li> <li>Pure client state (<a href="timetable/06_Screen_Specifications.html">06 §S6</a>); disabled state: none (always navigable).</li> <li>Keyboard: <code>←/→</code> when grid focused (desktop).</li> </ul> <h3 id="entryeditorsheet-extends-appbottomsheet--appdialog"><a class="header" href="#entryeditorsheet-extends-appbottomsheet--appdialog">EntryEditorSheet (extends AppBottomSheet / AppDialog)</a></h3> <ul> <li>Props: <code>prefill</code> (classId, day, startTime, endTime, subject/teacher/room from duplicate), <code>onSaved(entry)</code>, <code>onConflict(409)</code>.</li> <li>Hosts the F1 form (<a href="timetable/08_Form_Specifications.html">08</a>) + <code>ConflictBanner</code>; phone sheet <code>r-xl</code> top corners, desktop dialog <code>e-4</code>.</li> </ul> <h3 id="slotdetailpopover-extends-appmenu-surface"><a class="header" href="#slotdetailpopover-extends-appmenu-surface">SlotDetailPopover (extends AppMenu surface)</a></h3> <ul> <li>Props: <code>entry</code> with resolved names; actions: Duplicate (→ editor), Edit/Delete <code>(planned)</code> — hidden unless <code>timetable.create</code> + coordinator role.</li> </ul> <h3 id="teacherscheduletab-composition"><a class="header" href="#teacherscheduletab-composition">TeacherScheduleTab (composition)</a></h3> <ul> <li>Wraps <code>TimetableGrid</code> with teacher scope (<code>GET /timetable?teacherId=</code>, <code>timetable.controller.ts:26</code>) in the Teachers module's detail screen — same grid, read-only for teacher role.</li> </ul> <hr /> <h2 id="b-reused-shared-components-1"><a class="header" href="#b-reused-shared-components-1">B. Reused shared components</a></h2> <div class="table-wrapper"><table><thead><tr><th>Shared component</th><th>Used in</th></tr></thead><tbody> <tr><td><code>AppDropdown</code></td><td>scope picker (S1/S2), class/day/year pickers (S4)</td></tr> <tr><td><code>AppSearchBar</code>-style searchable picker</td><td>subject/teacher/room pickers (S4)</td></tr> <tr><td><code>AppSkeleton</code></td><td>grid loading ghost</td></tr> <tr><td><code>AppEmptyState</code></td><td>S1/S2/S3 empty</td></tr> <tr><td><code>AppErrorState</code></td><td>grid/editor errors (envelope code)</td></tr> <tr><td><code>AppBanner</code></td><td>offline reads, conflict (via <code>ConflictBanner</code>)</td></tr> <tr><td><code>AppSnackbar</code></td><td>"Slot added", "Slot removed <code>(planned)</code>"</td></tr> <tr><td><code>AppFAB</code></td><td>S1 "Add slot" (hidden for teacher role)</td></tr> <tr><td><code>AppButton</code></td><td>Save (filled), Cancel (text), Change time (outlined)</td></tr> <tr><td><code>AppTimePicker</code>-style field</td><td>start/end time inputs (S4)</td></tr> <tr><td><code>AppChips</code></td><td>day picker quick-select (S4)</td></tr> <tr><td><code>AppOfflineBanner</code></td><td>module-wide offline reads</td></tr> <tr><td><code>AppSectionHeader</code></td><td>grid section titles, editor groups</td></tr> <tr><td><code>AppDialog</code></td><td>destructive confirm <code>(planned)</code>, editor on desktop</td></tr> </tbody></table> </div> <hr /> <h2 id="c-composition-rules-1"><a class="header" href="#c-composition-rules-1">C. Composition rules</a></h2> <ol> <li>No screen renders raw ObjectIds — every ref resolved via catalogs (subjects, teachers, rooms, classes, years) or "—" fallback (<a href="timetable/04_Information_Architecture.html">04 §6</a>).</li> <li><code>DayOfWeek</code> display order fixed: Mon→Sat (<code>timetable.schema.ts:7-14</code>) — never locale-reordered.</li> <li>Conflict styling centralized in <code>TimetableSlotCard</code>/<code>ConflictBanner</code> — no ad-hoc red in screens (<a href="timetable/11_Design_System_Mapping.html">11 §2</a>).</li> <li>Times rendered <code>mono</code> + <code>tabularFigures</code> everywhere (gutter, cards, editor).</li> <li>New components only if shared library lacks the need (YAGNI — check shared first).</li> <li>Golden tests: <code>TimetableSlotCard</code> (normal/conflict × light/dark), <code>TimeGutter</code>, <code>ConflictBanner</code>, <code>WeekNavigator</code> (<a href="timetable/../00-shared/03_Component_Library.html">00-shared/03 §G</a>).</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-timetable-module"><a class="header" href="#08--form-specifications-timetable-module">08 — Form Specifications (Timetable Module)</a></h1> <ul> <li><a href="timetable/08_Form_Specifications.html#f1--create-timetable-entry-post-timetable">F1 — Create Timetable Entry (<code>POST /timetable</code>)</a> <ul> <li><a href="timetable/08_Form_Specifications.html#client-side-rules-server-gaps--derived">Client-side rules (server gaps — derived)</a></li> <li><a href="timetable/08_Form_Specifications.html#submit-payload-exact">Submit payload (exact)</a></li> <li><a href="timetable/08_Form_Specifications.html#server-responses">Server responses</a></li> </ul> </li> <li><a href="timetable/08_Form_Specifications.html#f2--update--delete-timetable-entry--planned">F2 — Update / Delete Timetable Entry — <code>(planned)</code></a></li> </ul> <blockquote> <p>Field-by-field specs. Server column = exact DTO contract (<code>create-timetable-entry.dto.ts</code>). <strong>Weak-validation warnings are derived facts</strong> (the DTO validates types/ids but not time format or ordering).</p> </blockquote> <hr /> <h2 id="f1--create-timetable-entry-post-timetable"><a class="header" href="#f1--create-timetable-entry-post-timetable">F1 — Create Timetable Entry (<code>POST /timetable</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Type</th><th>Required</th><th>Server validation</th><th>Client input</th><th>Default</th></tr></thead><tbody> <tr><td>1</td><td><code>classId</code></td><td>string (ObjectId)</td><td>✅</td><td><code>@IsMongoId</code> (<code>create-timetable-entry.dto.ts:6-8</code>)</td><td>Searchable picker from <code>GET /classes</code> (or prefilled from grid scope)</td><td>—</td></tr> <tr><td>2</td><td><code>subjectId</code></td><td>string (ObjectId)</td><td>✅</td><td><code>@IsMongoId</code> (<code>:10-12</code>)</td><td>Searchable picker from <code>GET /subjects</code></td><td>—</td></tr> <tr><td>3</td><td><code>teacherId</code></td><td>string (ObjectId)</td><td>✅</td><td><code>@IsMongoId</code> (<code>:14-16</code>)</td><td>Searchable picker from <code>GET /teachers</code></td><td>—</td></tr> <tr><td>4</td><td><code>roomId</code></td><td>string (ObjectId)</td><td>—</td><td><code>@IsOptional @IsMongoId</code> (<code>:18-21</code>)</td><td>Picker from <code>GET /rooms</code> (code + name)</td><td>unset (schema optional, <code>timetable.schema.ts:27-28</code>)</td></tr> <tr><td>5</td><td><code>dayOfWeek</code></td><td>string enum</td><td>✅</td><td><code>@IsEnum(DayOfWeek)</code> (<code>:23-25</code>)</td><td>6 chips Mon–Sat from <code>DayOfWeek</code> (<code>timetable.schema.ts:7-14</code>)</td><td>current day of week <code>(proposed)</code></td></tr> <tr><td>6</td><td><code>startTime</code></td><td>string</td><td>✅</td><td><code>@IsString</code> (<code>:27-29</code>) — <strong>format NOT validated</strong></td><td><code>AppTimePicker</code> 24 h → <code>HH:MM</code> zero-padded (<code>08:00</code>); regex `^([01]\d</td><td>2[0-3]):[0-5]\d$<code> </code>(proposed)`</td></tr> <tr><td>7</td><td><code>endTime</code></td><td>string</td><td>✅</td><td><code>@IsString</code> (<code>:31-33</code>) — <strong>no <code>> start</code> check</strong></td><td><code>AppTimePicker</code>; client enforces <code>end > start</code> <code>(proposed)</code></td><td><a href="timetable/09_User_Behaviour.html">09:00</a></td></tr> <tr><td>8</td><td><code>academicYearId</code></td><td>string (ObjectId)</td><td>✅</td><td><code>@IsMongoId</code> (<code>:35-37</code>)</td><td><code>AppDropdown</code> from <code>GET /academic-years</code>, default <code>isCurrent</code> (<code>academic-year.schema.ts:31-32</code>)</td><td>current year</td></tr> </tbody></table> </div> <h3 id="client-side-rules-server-gaps--derived"><a class="header" href="#client-side-rules-server-gaps--derived">Client-side rules (server gaps — derived)</a></h3> <ul> <li><strong>Time format is load-bearing:</strong> overlap detection compares strings lexically (<code>start1 < end2 && start2 < end1</code>, <code>timetable.service.ts:62-69</code>). It is correct only for zero-padded <code>HH:MM</code>. The server never validates format/order, so the client: <ul> <li>forces 24 h <code>HH:MM</code> input (picker-based, no free text),</li> <li>blocks <code>end <= start</code> before submit (a non-ordered pair would make <code>timeOverlaps</code> math meaningless),</li> <li>never sends <code>"8:00"</code> / <code>"9:00"</code> (would silently break conflict detection).</li> </ul> </li> <li><strong><code>end == start</code> is allowed</strong> as a boundary (back-to-back slots legal — <code>timeOverlaps</code> returns false, <code>timetable.service.ts:68</code>); client should still prefer ≥ 1-row gaps for readability <code>(proposed)</code>.</li> <li><strong>Class-level overlap is not a server error</strong> (OQ-1) — the editor must NOT show a class-conflict error; server accepts.</li> <li><strong>Room-less entries:</strong> conflict detection reduces to teacher-only (the <code>roomId: undefined</code> query clause is stripped by Mongoose; <code>timetable.service.ts:20</code>).</li> <li>Pre-flight: on picker change, consult cached per-day grids; warn for teacher/room clashes (server 409 remains authoritative — races possible).</li> </ul> <h3 id="submit-payload-exact-1"><a class="header" href="#submit-payload-exact-1">Submit payload (exact)</a></h3> <pre><code class="language-json">{ "classId": "…", "subjectId": "…", "teacherId": "…", "roomId": "…", "dayOfWeek": "monday", "startTime": "08:00", "endTime": "09:00", "academicYearId": "…" } </code></pre> <p>(<code>roomId</code> omitted when unset — never send <code>null</code>/<code>""</code>; DTO <code>whitelist</code> + <code>forbidNonWhitelisted</code> pipes reject unknown keys, <code>main.ts:50-57</code>.)</p> <h3 id="server-responses-1"><a class="header" href="#server-responses-1">Server responses</a></h3> <ul> <li>200 <code>data</code> = created entry doc (envelope-unwrapped; timestamps auto, <code>timetable.schema.ts:16</code>).</li> <li>409 <code>ConflictException</code> "Schedule conflict detected" (<code>timetable.service.ts:28</code>) → <code>ConflictBanner</code>, form values kept. <strong>No structured error code</strong> — client matches on 409 status.</li> <li>400 <code>VALIDATION_ERROR</code>: field details mapped (<code>00-shared/07 §3</code>); invalid <code>dayOfWeek</code> (e.g. <code>"sunday"</code>) fails <code>@IsEnum</code> (<code>:23-25</code>).</li> <li>Notes: <code>tenantId</code> <strong>never sent</strong> (from token, <code>base.repository.ts:33-35</code>); <code>forbidNonWhitelisted</code> means the editor sends exactly the 8 fields above.</li> </ul> <hr /> <h2 id="f2--update--delete-timetable-entry--planned"><a class="header" href="#f2--update--delete-timetable-entry--planned">F2 — Update / Delete Timetable Entry — <code>(planned)</code></a></h2> <ul> <li><strong>No endpoints exist</strong> — no <code>PATCH /timetable/:id</code>, no <code>DELETE /timetable/:id</code> (<code>timetable.controller.ts:10</code> exposes only <code>POST</code> + <code>GET</code>). Any edit/delete UI is <code>(planned)</code> pending backend (roadmap row "Timetable: Bulk create, conflicts, substitution, workload, export", <code>IMPLEMENTATION_PLAN.md:226</code>).</li> <li>Interim UX: "Duplicate & correct" — open F1 prefilled from the slot; original stays until a delete surface exists (OQ-2).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-timetable-module"><a class="header" href="#09--user-behaviour-timetable-module">09 — User Behaviour (Timetable Module)</a></h1> <ul> <li><a href="timetable/09_User_Behaviour.html#1-global-behaviours">1. Global behaviours</a></li> <li><a href="timetable/09_User_Behaviour.html#2-grid-screens-s1s2s3">2. Grid screens (S1/S2/S3)</a></li> <li><a href="timetable/09_User_Behaviour.html#3-entry-editor-s4">3. Entry editor (S4)</a></li> <li><a href="timetable/09_User_Behaviour.html#4-conflict-rules-derived-from-the-server-rule">4. Conflict rules (derived from the server rule)</a></li> <li><a href="timetable/09_User_Behaviour.html#5-drag--drop--edit-semantics-desktop-planned-aware">5. Drag & drop / edit semantics (desktop, <code>(planned)</code>-aware)</a></li> <li><a href="timetable/09_User_Behaviour.html#6-teacher-self-read-only">6. Teacher self (read-only)</a></li> <li><a href="timetable/09_User_Behaviour.html#7-error-copy-table-module">7. Error copy table (module)</a></li> <li><a href="timetable/09_User_Behaviour.html#8-behavioural-edge-cases-derived">8. Behavioural edge cases (derived)</a></li> </ul> <blockquote> <p>Rules of engagement: what the UI may/must not do, derived strictly from backend behaviour. Contradictions with <code>PLAN.md</code> / flow docs are flagged, not resolved by invention.</p> </blockquote> <hr /> <h2 id="1-global-behaviours-1"><a class="header" href="#1-global-behaviours-1">1. Global behaviours</a></h2> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Source</th></tr></thead><tbody> <tr><td>Every list is tenant-scoped automatically; client never sends <code>tenantId</code></td><td><code>base.repository.ts:20-30,33-35</code></td></tr> <tr><td>Every request carries Bearer JWT; 401 → single-flight refresh → <code>sessionExpired</code></td><td><a href="timetable/../00-shared/06_State_Management.html">00-shared/06 §3.6</a></td></tr> <tr><td>Soft-deleted records are invisible — 404, not a flag in payload</td><td><code>base.repository.ts:20-30</code></td></tr> <tr><td>Server returns raw ObjectIds for refs — client joins names from catalogs; missing ref = "—"</td><td><code>timetable.schema.ts:18-40</code></td></tr> <tr><td><code>success:false</code> never renders raw message for 5xx — generic + requestId; 4xx business text allowed</td><td><a href="timetable/../00-shared/07_API_Conventions.html">00-shared/07 §11</a></td></tr> </tbody></table> </div> <h2 id="2-grid-screens-s1s2s3"><a class="header" href="#2-grid-screens-s1s2s3">2. Grid screens (S1/S2/S3)</a></h2> <ul> <li><strong>Never re-sort:</strong> <code>GET /timetable?classId=</code> / <code>?teacherId=</code> return entries sorted <code>dayOfWeek, startTime</code> (<code>timetable.service.ts:48-60</code>); the grid maps them to cells directly.</li> <li>One request per scope; scope switch (class ↔ teacher) re-fetches — no merged single-request option exists (<code>timetable.controller.ts:22-25</code>).</li> <li>Both params sent → <code>classId</code> wins (<code>timetable.controller.ts:26-27</code>); neither → <code>[]</code> (<code>:28</code>) — the client always sends exactly one.</li> <li>Week navigation is a local re-filter (no server "week" or date dimension; <code>timetable.schema.ts:30-37</code>).</li> <li>Room view (S3) merges multiple class responses client-side — never claim a server <code>roomId</code> filter exists (OQ-4; <code>(planned)</code>).</li> </ul> <h2 id="3-entry-editor-s4"><a class="header" href="#3-entry-editor-s4">3. Entry editor (S4)</a></h2> <ul> <li><strong>Never optimistic</strong> — create is server-confirmed (<a href="timetable/../00-shared/06_State_Management.html">00-shared/06 §3.5</a>).</li> <li>On 409: keep form values; <code>ConflictBanner</code>; focus the conflicting picker.</li> <li>On 400: map <code>details[].field</code> → inline field errors; focus first invalid (<code>09_Accessibility_Baseline.md §10</code>).</li> <li>On 5xx/network: keep form values, <code>AppErrorState</code> inline with Retry.</li> <li>Offline: form entry blocked with <code>AppOfflineBanner</code> (no offline write queue, <a href="timetable/../00-shared/06_State_Management.html">00-shared/06 §3.7</a>).</li> <li>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.</li> </ul> <h2 id="4-conflict-rules-derived-from-the-server-rule"><a class="header" href="#4-conflict-rules-derived-from-the-server-rule">4. Conflict rules (derived from the server rule)</a></h2> <ul> <li>The server checks <strong>teacher</strong> and <strong>room</strong> clashes on the same <code>dayOfWeek</code> with overlapping <code>[startTime, endTime)</code> intervals (<code>timetable.service.ts:17-22,62-69</code>).</li> <li><code>endTime == startTime</code> of another entry → <strong>no conflict</strong> (back-to-back allowed, <code>:68</code>).</li> <li>Room-less entries only clash on teacher (<code>:20</code> — undefined clause stripped).</li> <li><strong>Same-class overlaps are not detected</strong> (OQ-1) — the UI may highlight them in the grid (client-derived) but must not block or claim server support.</li> </ul> <h2 id="5-drag--drop--edit-semantics-desktop-planned-aware"><a class="header" href="#5-drag--drop--edit-semantics-desktop-planned-aware">5. Drag & drop / edit semantics (desktop, <code>(planned)</code>-aware)</a></h2> <ul> <li>Dropping a slot onto a new cell opens the editor prefilled (a <strong>create</strong> — no PATCH exists, <a href="timetable/01_Product_Overview.html">01 §10</a>). 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 <code>(planned)</code>").</li> <li>No optimistic move animations that imply persistence.</li> </ul> <h2 id="6-teacher-self-read-only"><a class="header" href="#6-teacher-self-read-only">6. Teacher self (read-only)</a></h2> <ul> <li>Read-only grid; no FAB/editor affordances (<a href="timetable/02_User_Personas.html">02 §3</a>).</li> <li>Teacher id for "me": client-resolved (no <code>/timetable/me</code>; teachers module has the same gap, teachers <a href="timetable/03_User_Journey.html">03 §5</a>).</li> </ul> <h2 id="7-error-copy-table-module"><a class="header" href="#7-error-copy-table-module">7. Error copy table (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Code</th><th>Screen</th><th>Client message (i18n key)</th></tr></thead><tbody> <tr><td>409 conflict</td><td>S4</td><td><code>timetable.conflict.teacher</code> / <code>timetable.conflict.room</code> — "Teacher already booked {day} {start}–{end}" / "Room already in use…" (generic server message mapped via pre-flight data)</td></tr> <tr><td>400</td><td>S4</td><td>per-field from <code>details</code></td></tr> <tr><td>404 (catalog refs missing)</td><td>grid</td><td>slot shows "—", no error screen</td></tr> <tr><td>429</td><td>all</td><td><code>errors.rateLimited</code> — "Too many requests. Try again in a moment."</td></tr> <tr><td>5xx</td><td>all</td><td><code>errors.server</code> + requestId</td></tr> </tbody></table> </div> <h2 id="8-behavioural-edge-cases-derived"><a class="header" href="#8-behavioural-edge-cases-derived">8. Behavioural edge cases (derived)</a></h2> <ul> <li><code>GET /timetable</code> with no params → <code>[]</code> (<code>timetable.controller.ts:28</code>) — a bare "all entries" fetch is <strong>not</strong> supported; client must always scope.</li> <li>Non-zero-padded time strings would break conflict math (<code>timetable.service.ts:68</code>) — client picker-only input prevents it (<a href="timetable/08_Form_Specifications.html">08 F1</a>).</li> <li><code>"sunday"</code> rejected by <code>@IsEnum</code> (<code>create-timetable-entry.dto.ts:23-25</code>) — no Sunday column exists anywhere in the UI.</li> <li><code>TimetableEntryCreated</code> is emitted (<code>timetable.service.ts:33-44</code>) but no queue routes it — the UI must not promise notifications/audit from timetable events (<a href="timetable/01_Product_Overview.html">01 §6</a>).</li> <li>Cross-tenant entry id in a URL → 404 (scoped <code>find</code>, <code>base.repository.ts:20-30</code>) — treat as removed, never leak existence (<a href="timetable/../00-shared/07_API_Conventions.html">00-shared/07 §3</a>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-timetable-module"><a class="header" href="#10--interaction-specification-timetable-module">10 — Interaction Specification (Timetable Module)</a></h1> <ul> <li><a href="timetable/10_Interaction_Specification.html#1-touch-gestures-phonetablet">1. Touch gestures (phone/tablet)</a></li> <li><a href="timetable/10_Interaction_Specification.html#2-desktopweb-mouse--keyboard">2. Desktop/web (mouse + keyboard)</a></li> <li><a href="timetable/10_Interaction_Specification.html#3-haptics-mobile">3. Haptics (mobile)</a></li> <li><a href="timetable/10_Interaction_Specification.html#4-motion-per-screen">4. Motion per screen</a></li> <li><a href="timetable/10_Interaction_Specification.html#5-micro-interactions">5. Micro-interactions</a></li> <li><a href="timetable/10_Interaction_Specification.html#6-frustration-controls">6. Frustration controls</a></li> </ul> <blockquote> <p>Specializes <a href="timetable/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a> for this module. All motion tokens from <a href="timetable/02_User_Personas.html">02_Design_Tokens.md §6</a>. The grid is the hero surface: <strong>drag-to-edit on desktop, long-press on mobile</strong> (<a href="timetable/06_Screen_Specifications.html">06 §S1</a>, <a href="timetable/07_Component_Library.html">07 §A1</a>).</p> </blockquote> <hr /> <h2 id="1-touch-gestures-phonetablet-3"><a class="header" href="#1-touch-gestures-phonetablet-3">1. Touch gestures (phone/tablet)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Gesture</th><th>Behaviour</th><th>Screen</th></tr></thead><tbody> <tr><td>Tap empty cell</td><td>Open S4 editor prefilled (class + day + time of cell)</td><td>S1/S2 (coordinator)</td></tr> <tr><td>Tap slot</td><td><code>SlotDetailPopover</code>: subject/teacher/room/time + actions (Duplicate, Edit/Delete <code>(planned)</code>)</td><td>S1/S2/S3</td></tr> <tr><td>Long-press slot</td><td>Same popover as tap (<code>selectionClick</code> haptic) — <strong>no drag on mobile</strong>; edit happens in the editor, not by dragging</td><td>S1</td></tr> <tr><td>Long-press on <code>TimetableSlotCard</code> conflict badge</td><td>Tooltip/semantics "Double-booked" detail <code>(proposed)</code></td><td>S1</td></tr> <tr><td>Swipe left on slot</td><td>Remove — <code>(planned)</code> (no DELETE endpoint; disabled until backend lands)</td><td>S1/S2</td></tr> <tr><td>Pull down</td><td>Refresh grid (cache-bypass)</td><td>S1/S2/S3</td></tr> <tr><td>Tap FAB</td><td>Scale-in (<code>m-entrance</code>) → S4</td><td>S1 (coordinator)</td></tr> <tr><td>Tap <code>‹</code>/<code>›</code></td><td>Week shift <code>m-base</code> cross-fade</td><td>S1/S2/S3</td></tr> <tr><td>Back gesture</td><td>Editor sheet → grid (discard confirm if dirty <code>(proposed)</code>); grid → previous surface</td><td>S4</td></tr> </tbody></table> </div> <h2 id="2-desktopweb-mouse--keyboard-3"><a class="header" href="#2-desktopweb-mouse--keyboard-3">2. Desktop/web (mouse + keyboard)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Input</th><th>Behaviour</th></tr></thead><tbody> <tr><td>Hover slot</td><td><code>m-fast</code> surface tint, <code>e-2</code>; tooltip after 500 ms (full label) (<a href="timetable/08_Form_Specifications.html">08 §2</a>)</td></tr> <tr><td>Hover empty cell</td><td>subtle fill; drop-target highlight <code>primaryContainer</code> when a slot is dragged over</td></tr> <tr><td><strong>Drag slot → cell</strong></td><td><code>Draggable<TimetableEntry></code> → <code>DragTarget</code> cell: drop opens S4 editor prefilled with new day/time + slot's subject/teacher/room. <strong>Creates a new entry</strong> — no PATCH exists; banner in editor: "This creates a new slot; the original stays until you delete it <code>(planned)</code>" (<a href="timetable/09_User_Behaviour.html">09 §5</a>)</td></tr> <tr><td>Drag to invalid target (occupied non-empty cell)</td><td>Rejected: cell shows <code>errorContainer</code> outline; slot returns (spring <code>m-base</code>)</td></tr> <tr><td>Right-click slot</td><td>Same popover as long-press</td></tr> <tr><td><code>+</code> / <code>N</code></td><td>New slot (S4) on grid screens (<a href="timetable/05_Screen_Inventory.html">05_Global_Information_Architecture.md §7</a>)</td></tr> <tr><td><code>←</code> / <code>→</code></td><td>Week navigation (grid focused)</td></tr> <tr><td><code>Ctrl+F</code></td><td>Focus scope/class search <code>(proposed)</code></td></tr> <tr><td><code>Esc</code></td><td>Close editor sheet/dialog/popover</td></tr> <tr><td><code>Enter</code></td><td>Submit focused editor; <code>Tab</code> logical order; visible focus ring <code>primary</code> 2 dp (<a href="timetable/09_User_Behaviour.html">09 §6</a>)</td></tr> <tr><td><code>Delete</code> key</td><td>On focused slot → delete confirm <code>(planned)</code></td></tr> </tbody></table> </div> <h2 id="3-haptics-mobile-11"><a class="header" href="#3-haptics-mobile-11">3. Haptics (mobile)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Haptic</th></tr></thead><tbody> <tr><td>Entry created (200)</td><td><code>lightImpact()</code></td></tr> <tr><td>Conflict (409 / pre-flight)</td><td><code>mediumImpact()</code> + banner</td></tr> <tr><td>Slot long-press popover</td><td><code>selectionClick()</code></td></tr> <tr><td>Pull-to-refresh done</td><td><code>lightImpact()</code></td></tr> <tr><td>Rule</td><td>disabled when system haptics off; none on web/desktop (<a href="timetable/08_Form_Specifications.html">08 §3</a>)</td></tr> </tbody></table> </div> <h2 id="4-motion-per-screen-1"><a class="header" href="#4-motion-per-screen-1">4. Motion per screen</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Entrance</th><th>Element motion</th></tr></thead><tbody> <tr><td>S1/S2 grid</td><td>page fade+rise <code>m-slow</code></td><td>slot insert/remove: <code>AnimatedList</code> <code>m-base</code>; week shift cross-fade <code>m-base</code>; conflict border pulse once <code>m-fast</code></td></tr> <tr><td>S3 room view</td><td>grid fade <code>m-fast</code></td><td>merge updates cross-fade <code>m-fast</code></td></tr> <tr><td>S4 editor</td><td>sheet slide-up <code>m-base</code>, <code>r-xl</code> corners (phone) / dialog scale <code>m-base</code> (desktop)</td><td>picker options <code>m-fast</code>; conflict banner slide-down <code>m-base</code>; Save spinner replaces label</td></tr> <tr><td>S5 banner</td><td>slide-down <code>m-base</code></td><td>dismiss slide-up <code>m-fast</code></td></tr> <tr><td>S6 week nav</td><td>—</td><td>offset label cross-fade <code>m-fast</code></td></tr> </tbody></table> </div> <p>Reduced-motion: all collapse to fades ≤ <code>m-instant</code>, no stagger, no drag ghost animation (<a href="timetable/08_Form_Specifications.html">08 §4</a>, <a href="timetable/09_User_Behaviour.html">09 §8</a>).</p> <h2 id="5-micro-interactions-1"><a class="header" href="#5-micro-interactions-1">5. Micro-interactions</a></h2> <ul> <li><strong>FAB hide/show on scroll</strong> (grid): hide on scroll-down, show on scroll-up (<a href="timetable/03_User_Journey.html">03 §D</a>).</li> <li><strong>Slot drag ghost (desktop):</strong> card lifts to <code>e-4</code> + 0.95 scale while dragged; drop snaps into cell (<a href="timetable/08_Form_Specifications.html">08 §2</a>); drop → editor opens <code>m-base</code> (create semantics per <a href="timetable/09_User_Behaviour.html">09 §5</a>).</li> <li><strong>Save button:</strong> label → spinner → check + snackbar "Slot added"; disabled while pending (anti-double-submit <a href="timetable/08_Form_Specifications.html">08 §6</a>).</li> <li><strong>Week navigator:</strong> <code>‹/›</code> + cross-fade; "Today" resets offset.</li> <li><strong>Scope switch:</strong> segmented control; grid cross-fades <code>m-fast</code> while refetching.</li> <li><strong>Conflict banner:</strong> auto-announce via live region; dismiss returns focus to conflicting field.</li> </ul> <h2 id="6-frustration-controls-3"><a class="header" href="#6-frustration-controls-3">6. Frustration controls</a></h2> <ul> <li>Every interactive element responds ≤ 100 ms (<a href="timetable/08_Form_Specifications.html">08 §6</a>).</li> <li>Network-bound actions always pending-visible — never silent.</li> <li>Submit double-tap prevented; destructive actions <code>(planned)</code> require explicit confirm dialog (typed confirm not required — soft-delete is reversible server-side; <a href="timetable/05_Screen_Inventory.html">05_Global_Information_Architecture.md §5</a> applies to irreversible purge only).</li> <li>Grid never blocks input during refetch: stale requests dropped via request generation token (<a href="timetable/13_State_Management.html">13 §5</a>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-timetable-module"><a class="header" href="#11--design-system-mapping-timetable-module">11 — Design System Mapping (Timetable Module)</a></h1> <ul> <li><a href="timetable/11_Design_System_Mapping.html#1-screen--m3-component-map">1. Screen → M3 component map</a></li> <li><a href="timetable/11_Design_System_Mapping.html#2-conflict--slot-color-semantics-module-canon">2. Conflict / slot color semantics (module canon)</a></li> <li><a href="timetable/11_Design_System_Mapping.html#3-typography">3. Typography</a></li> <li><a href="timetable/11_Design_System_Mapping.html#4-spacing--layout-tokens">4. Spacing & layout tokens</a></li> <li><a href="timetable/11_Design_System_Mapping.html#5-elevation">5. Elevation</a></li> <li><a href="timetable/11_Design_System_Mapping.html#6-icons-material-symbols-outlined-filled-for-status--02-7">6. Icons (Material Symbols outlined; filled for status — 02 §7)</a></li> <li><a href="timetable/11_Design_System_Mapping.html#7-module-level-theming-rules">7. Module-level theming rules</a></li> </ul> <blockquote> <p>Extends <a href="timetable/../00-shared/04_Design_System_Mapping.html">00-shared/04</a> with module components. Every widget → token → Flutter mapping.</p> </blockquote> <hr /> <h2 id="1-screen--m3-component-map-1"><a class="header" href="#1-screen--m3-component-map-1">1. Screen → M3 component map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Primary widgets</th><th>M3 roles</th></tr></thead><tbody> <tr><td>S1/S2 grid</td><td><code>TimetableGrid</code>, <code>TimetableSlotCard</code>, <code>DayHeader</code>, <code>TimeGutter</code>, <code>WeekNavigator</code>, <code>FAB</code></td><td><code>surfaceContainerLow</code> cells, <code>surface</code> gutters, <code>primaryContainer</code> drop-target</td></tr> <tr><td>S3 room view</td><td><code>TimetableGrid</code> (read-only), <code>AppSectionHeader</code></td><td><code>surfaceContainerLow</code></td></tr> <tr><td>S4 editor</td><td><code>AppBottomSheet</code>/<code>AppDialog</code>, pickers, <code>TimePicker</code>, <code>FilledButton</code></td><td><code>surfaceContainerHigh</code>, fill <code>surfaceVariant</code>, focus <code>primary</code>, error <code>error</code></td></tr> <tr><td>S5 conflict</td><td><code>ConflictBanner</code></td><td><code>errorContainer</code>/<code>onErrorContainer</code></td></tr> <tr><td>S6 week nav</td><td><code>WeekNavigator</code>, text buttons</td><td><code>secondaryContainer</code> "Today"</td></tr> </tbody></table> </div> <h2 id="2-conflict--slot-color-semantics-module-canon"><a class="header" href="#2-conflict--slot-color-semantics-module-canon">2. Conflict / slot color semantics (module canon)</a></h2> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Token pair</th><th>Rationale</th></tr></thead><tbody> <tr><td>Slot (normal)</td><td><code>surfaceContainerLow</code>/<code>onSurface</code>, <code>e-1</code></td><td>neutral card</td></tr> <tr><td>Slot (hover, desktop)</td><td><code>surfaceContainerHigh</code>, <code>e-2</code></td><td>affordance</td></tr> <tr><td>Slot (conflict — client-derived same-grid overlap)</td><td><code>errorContainer</code>/<code>onErrorContainer</code> border + <code>event_busy</code> icon + label</td><td>error role (<a href="timetable/09_User_Behaviour.html">09 §9</a> color-blind rule)</td></tr> <tr><td>Drop target (valid)</td><td><code>primaryContainer</code>/<code>onPrimaryContainer</code></td><td>action = primary</td></tr> <tr><td>Drop target (invalid)</td><td><code>errorContainer</code> outline</td><td>rejected</td></tr> <tr><td>Conflict banner</td><td><code>errorContainer</code>/<code>onErrorContainer</code></td><td><a href="timetable/02_User_Personas.html">02 §1</a></td></tr> </tbody></table> </div> <p>Always paired with icon + text — never color-only.</p> <h2 id="3-typography-2"><a class="header" href="#3-typography-2">3. Typography</a></h2> <div class="table-wrapper"><table><thead><tr><th>Use</th><th>Token</th><th>Widget</th></tr></thead><tbody> <tr><td>Screen title</td><td><code>headlineMedium</code></td><td>grid AppBar</td></tr> <tr><td>Slot subject</td><td><code>titleSmall</code></td><td>TimetableSlotCard</td></tr> <tr><td>Slot teacher · room</td><td><code>bodySmall</code></td><td>TimetableSlotCard</td></tr> <tr><td>Times (gutter, slot, editor)</td><td><code>mono</code> (<code>FontFeature.tabularFigures</code>, <a href="timetable/02_User_Personas.html">02 §2</a>)</td><td>TimeGutter, cards, time fields</td></tr> <tr><td>Day headers</td><td><code>labelLarge</code></td><td>DayHeader</td></tr> <tr><td>Conflict text</td><td><code>labelMedium</code></td><td>ConflictBanner</td></tr> <tr><td>Empty/error copy</td><td><code>bodyMedium</code></td><td>AppEmptyState/AppErrorState</td></tr> </tbody></table> </div> <h2 id="4-spacing--layout-tokens-1"><a class="header" href="#4-spacing--layout-tokens-1">4. Spacing & layout tokens</a></h2> <div class="table-wrapper"><table><thead><tr><th>Token</th><th>Value</th><th>Use</th></tr></thead><tbody> <tr><td>Grid cell min</td><td>96 × 48 dp (<a href="timetable/09_User_Behaviour.html">09 §5</a> touch targets)</td><td>grid cells</td></tr> <tr><td>Time gutter</td><td>48 dp fixed</td><td>TimeGutter</td></tr> <tr><td>Day header</td><td>48 dp</td><td>DayHeader</td></tr> <tr><td>Card padding</td><td>12</td><td>TimetableSlotCard</td></tr> <tr><td>Sheet corner</td><td><code>r-xl</code> 16 top (phone)</td><td>S4</td></tr> <tr><td>Dialog corner</td><td><code>r-lg</code> 12</td><td>S4 desktop</td></tr> <tr><td>Screen padding</td><td>16 / 24 (tablet+) (<a href="timetable/04_Information_Architecture.html">04 §6</a>)</td><td>grids</td></tr> </tbody></table> </div> <h2 id="5-elevation-1"><a class="header" href="#5-elevation-1">5. Elevation</a></h2> <div class="table-wrapper"><table><thead><tr><th>Component</th><th>Token</th></tr></thead><tbody> <tr><td>Grid cells / slot cards</td><td><code>e-0</code> / <code>e-1</code></td></tr> <tr><td>Slot hover (desktop)</td><td><code>e-2</code></td></tr> <tr><td>Drag ghost (desktop)</td><td><code>e-4</code></td></tr> <tr><td>S4 sheet / dialog</td><td><code>e-5</code> / <code>e-4</code></td></tr> <tr><td>FAB</td><td><code>e-3</code></td></tr> </tbody></table> </div> <h2 id="6-icons-material-symbols-outlined-filled-for-status--02-7-1"><a class="header" href="#6-icons-material-symbols-outlined-filled-for-status--02-7-1">6. Icons (Material Symbols outlined; filled for status — <a href="timetable/02_User_Personas.html">02 §7</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Icon</th><th>Use</th></tr></thead><tbody> <tr><td><code>calendar_month</code></td><td>timetable nav destination, empty state</td></tr> <tr><td><code>add</code></td><td>FAB "Add slot"</td></tr> <tr><td><code>event_busy</code></td><td>conflict badge + banner</td></tr> <tr><td><code>meeting_room</code></td><td>room chip on slot cards</td></tr> <tr><td><code>schedule</code></td><td>time gutter icon <code>(proposed)</code></td></tr> <tr><td><code>chevron_left/right</code></td><td>WeekNavigator</td></tr> <tr><td><code>today</code></td><td>"Today" button</td></tr> <tr><td><code>person</code> / <code>groups</code></td><td>teacher/class scope icons</td></tr> <tr><td><code>content_copy</code></td><td>Duplicate slot action</td></tr> <tr><td><code>edit</code> / <code>delete</code></td><td>slot actions <code>(planned)</code></td></tr> </tbody></table> </div> <p>All icons have tooltip or <code>Semantics.label</code> (<a href="timetable/09_User_Behaviour.html">09 §7</a>).</p> <h2 id="7-module-level-theming-rules-1"><a class="header" href="#7-module-level-theming-rules-1">7. Module-level theming rules</a></h2> <ol> <li>Conflict styling ONLY via <code>TimetableSlotCard</code> conflict state + <code>ConflictBanner</code> — single source of truth.</li> <li>Times always <code>mono</code> + <code>tabularFigures</code> — scannable schedule reading.</li> <li>Destructive actions <code>(planned)</code> always <code>error</code> token — never custom red.</li> <li>Day order fixed Mon→Sat (<code>timetable.schema.ts:7-14</code>) — no Sunday slot; a Sunday column must never be rendered.</li> <li>Drop-target affordance only on desktop (drag exists there); mobile long-press never shows drag affordances (<a href="timetable/10_Interaction_Specification.html">10 §1</a>).</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-timetable-module"><a class="header" href="#12--api-mapping-timetable-module">12 — API Mapping (Timetable Module)</a></h1> <ul> <li><a href="timetable/12_API_Mapping.html#0-module-wide-request-envelope--client-policy">0. Module-wide request envelope & client policy</a></li> <li><a href="timetable/12_API_Mapping.html#1-create--the-only-write">1. Create — the only write</a> <ul> <li><a href="timetable/12_API_Mapping.html#post-timetable--create-entry-s4"><code>POST /timetable</code> — create entry (S4)</a></li> </ul> </li> <li><a href="timetable/12_API_Mapping.html#2-reads--two-scopes-no-pagination">2. Reads — two scopes, no pagination</a> <ul> <li><a href="timetable/12_API_Mapping.html#get-timetableclassidid--class-grid-s1"><code>GET /timetable?classId=<id></code> — class grid (S1)</a></li> <li><a href="timetable/12_API_Mapping.html#get-timetableteacheridid--teacher-grid-s2"><code>GET /timetable?teacherId=<id></code> — teacher grid (S2)</a></li> <li><a href="timetable/12_API_Mapping.html#get-timetable--bare-no-params"><code>GET /timetable</code> — bare (no params)</a></li> <li><a href="timetable/12_API_Mapping.html#missing-surfaces-derived">Missing surfaces (derived)</a></li> </ul> </li> <li><a href="timetable/12_API_Mapping.html#3-supporting-catalogs-read-only-for-this-module">3. Supporting catalogs (read-only for this module)</a></li> <li><a href="timetable/12_API_Mapping.html#4-loading--streaming--realtime">4. Loading / streaming / realtime</a></li> <li><a href="timetable/12_API_Mapping.html#5-client-error-mapping-table-module">5. Client error mapping table (module)</a></li> <li><a href="timetable/12_API_Mapping.html#6-pagination-summary">6. Pagination summary</a></li> <li><a href="timetable/12_API_Mapping.html#7-optimistic--undo">7. Optimistic / undo</a></li> </ul> <blockquote> <p>Exact wire contract per <a href="timetable/../00-shared/07_API_Conventions.html">00-shared/07</a>. Base <code>/api/v1</code> (<code>main.ts:44-48</code>); envelope <code>{success,message,data,meta?,timestamp,requestId}</code>. Global guards: <code>RateLimitGuard → JwtAuthGuard → RbacGuard</code> (<code>app.module.ts:129-131</code>); <strong>no timetable endpoint carries RBAC metadata</strong> (<code>rbac.guard.ts:29</code> → JWT-only, <a href="timetable/01_Product_Overview.html">01 §5</a>). <code>Bearer</code> JWT; <code>tenantId</code> from token only (<code>base.repository.ts:33-35</code>).</p> </blockquote> <hr /> <h2 id="0-module-wide-request-envelope--client-policy-15"><a class="header" href="#0-module-wide-request-envelope--client-policy-15">0. Module-wide request envelope & client policy</a></h2> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Contract</th></tr></thead><tbody> <tr><td>Headers</td><td><code>Authorization: Bearer</code>; <code>x-request-id</code> client UUID; <code>Content-Type: application/json</code></td></tr> <tr><td>Tenancy</td><td>never in body; server injects <code>tenantId</code> + <code>isDeleted:false</code> scope (<code>base.repository.ts:20-30</code>)</td></tr> <tr><td>Caching</td><td>catalogs (classes, subjects, teachers, rooms, years) 24 h TTL; timetable grids 5 min TTL (<a href="timetable/../00-shared/06_State_Management.html">00-shared/06 §3.3</a>)</td></tr> <tr><td>Offline</td><td>reads last-good cache + banner; <strong>writes blocked</strong> (no offline queue)</td></tr> <tr><td>Retry</td><td>backoff on 5xx/network; no auto-retry on 429</td></tr> <tr><td>Idempotency</td><td>create is <strong>not</strong> idempotent — no duplicate guard on identical slots (class-level dupes legal, OQ-1); <code>Idempotency-Key</code> optional (B6 shared ledger)</td></tr> </tbody></table> </div> <hr /> <h2 id="1-create--the-only-write"><a class="header" href="#1-create--the-only-write">1. Create — the only write</a></h2> <h3 id="post-timetable--create-entry-s4"><a class="header" href="#post-timetable--create-entry-s4"><code>POST /timetable</code> — create entry (S4)</a></h3> <ul> <li>Body: <code>CreateTimetableEntryDto</code> (<code>create-timetable-entry.dto.ts:5-38</code>, F1): exactly <code>classId</code>, <code>subjectId</code>, <code>teacherId</code>, <code>roomId?</code>, <code>dayOfWeek</code> (enum Mon–Sat, <code>timetable.schema.ts:7-14</code>), <code>startTime</code>, <code>endTime</code>, <code>academicYearId</code>. Unknown keys rejected (<code>whitelist</code> + <code>forbidNonWhitelisted</code>, <code>main.ts:50-57</code>).</li> <li>200 <code>data: TimetableEntry</code> (raw doc, refs as ObjectIds; timestamps auto, <code>timetable.schema.ts:16</code>).</li> <li><strong>409 <code>ConflictException</code> "Schedule conflict detected"</strong> (<code>timetable.service.ts:28</code>): same-day existing entry shares <code>teacherId</code> <strong>or</strong> <code>roomId</code> with overlapping <code>[startTime,endTime)</code> (<code>timetable.service.ts:17-22,62-69</code>). No structured error code — client matches status.</li> <li>400 validation (invalid ids, <code>sunday</code> dayOfWeek, unknown keys).</li> <li>Side-effects: <code>TimetableEntryCreated</code> emitted (<code>timetable.service.ts:33-44</code>, payload <code>{entryId, classId, teacherId}</code>) — <strong>no queue routing found</strong> (<a href="timetable/01_Product_Overview.html">01 §6</a>).</li> </ul> <h2 id="2-reads--two-scopes-no-pagination"><a class="header" href="#2-reads--two-scopes-no-pagination">2. Reads — two scopes, no pagination</a></h2> <h3 id="get-timetableclassidid--class-grid-s1"><a class="header" href="#get-timetableclassidid--class-grid-s1"><code>GET /timetable?classId=<id></code> — class grid (S1)</a></h3> <ul> <li><code>classId</code> takes precedence when both params present (<code>timetable.controller.ts:26-27</code>).</li> <li>Success: <code>data: TimetableEntry[]</code> — <strong>non-paginated array</strong>, no <code>meta</code>.</li> <li>Sort: server-side <code>{dayOfWeek:1, startTime:1}</code> (<code>timetable.service.ts:48-53</code>).</li> </ul> <h3 id="get-timetableteacheridid--teacher-grid-s2"><a class="header" href="#get-timetableteacheridid--teacher-grid-s2"><code>GET /timetable?teacherId=<id></code> — teacher grid (S2)</a></h3> <ul> <li>Same shape; sort <code>{dayOfWeek:1, startTime:1}</code> (<code>timetable.service.ts:55-60</code>).</li> </ul> <h3 id="get-timetable--bare-no-params"><a class="header" href="#get-timetable--bare-no-params"><code>GET /timetable</code> — bare (no params)</a></h3> <ul> <li>Returns <code>[]</code> (<code>timetable.controller.ts:28</code>) — <strong>no "all entries" fetch</strong>; client always scopes (OQ-4).</li> </ul> <h3 id="missing-surfaces-derived"><a class="header" href="#missing-surfaces-derived">Missing surfaces (derived)</a></h3> <ul> <li><strong>No <code>?roomId=</code> filter</strong> (<code>timetable.controller.ts:22-25</code>) — room view is a client composition (<a href="timetable/03_User_Journey.html">03 §J5</a>); native filter <code>(planned)</code>.</li> <li><strong>No <code>:id</code> routes, no <code>PATCH</code>, no <code>DELETE</code></strong> (<code>timetable.controller.ts:10</code>) — update/delete <code>(planned)</code> (<code>IMPLEMENTATION_PLAN.md:226</code>).</li> <li><strong>No pagination, filters, or date params</strong> — grid pages are the whole week by model (weekday + time only, <code>timetable.schema.ts:30-37</code>).</li> </ul> <hr /> <h2 id="3-supporting-catalogs-read-only-for-this-module-1"><a class="header" href="#3-supporting-catalogs-read-only-for-this-module-1">3. Supporting catalogs (read-only for this module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Endpoint</th><th>Used by</th><th>Source</th></tr></thead><tbody> <tr><td><code>GET /classes</code> (+ <code>by-year/:academicYearId</code>)</td><td>S1 class picker, F1 classId</td><td><code>class.controller.ts:27-33</code></td></tr> <tr><td><code>GET /subjects</code> (paginated)</td><td>F1 subject picker, slot labels</td><td><code>subject.controller.ts:27-29</code></td></tr> <tr><td><code>GET /teachers</code> (paginated)</td><td>F1 teacher picker, slot labels</td><td><code>teacher.controller.ts:27-29</code></td></tr> <tr><td><code>GET /rooms</code> (paginated, <code>?page=&limit=</code>)</td><td>F1 room picker, room labels</td><td><code>rooms.controller.ts:30-34</code></td></tr> <tr><td><code>GET /academic-years</code></td><td>F1 year picker (<code>isCurrent</code>)</td><td><code>academic-year.controller.ts:27-30</code></td></tr> <tr><td><code>GET /dashboard/overview</code></td><td>timetable-related KPIs <code>(proposed)</code></td><td>dashboard module</td></tr> </tbody></table> </div> <h2 id="4-loading--streaming--realtime-2"><a class="header" href="#4-loading--streaming--realtime-2">4. Loading / streaming / realtime</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Loading</th><th>Realtime</th></tr></thead><tbody> <tr><td>S1/S2/S3 grid</td><td>skeleton grid</td><td>— (timetable WS events <code>(planned)</code>; <code>TimetableEntryCreated</code> unrouted today, <a href="timetable/01_Product_Overview.html">01 §6</a>)</td></tr> <tr><td>S4 editor</td><td>submit spinner only</td><td>—</td></tr> <tr><td>S5 banner</td><td>—</td><td>appears on 409 / pre-flight</td></tr> </tbody></table> </div> <h2 id="5-client-error-mapping-table-module-1"><a class="header" href="#5-client-error-mapping-table-module-1">5. Client error mapping table (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Code</th><th>UI</th></tr></thead><tbody> <tr><td>all</td><td>401</td><td>silent refresh; fail → <code>sessionExpired</code></td></tr> <tr><td>all</td><td>403</td><td>403 screen (future server enforcement; client-side perm gate today)</td></tr> <tr><td>grid</td><td>404 (catalog ref gone)</td><td>slot shows "—"</td></tr> <tr><td>S4</td><td>409</td><td><code>ConflictBanner</code>, keep form</td></tr> <tr><td>S4</td><td>400</td><td>per-field errors</td></tr> <tr><td>all</td><td>429</td><td>"Try again in a moment", no auto-retry</td></tr> <tr><td>all</td><td>5xx</td><td>generic + requestId + retry</td></tr> </tbody></table> </div> <h2 id="6-pagination-summary-1"><a class="header" href="#6-pagination-summary-1">6. Pagination summary</a></h2> <ul> <li><code>GET /timetable</code>: <strong>non-paginated array</strong> — one request = whole weekly grid per scope. Large-school caveat: verify payload size; server pagination <code>(planned)</code> (<code>IMPLEMENTATION_PLAN.md:226</code>).</li> <li>Catalogs paginate via <code>PaginationQueryDto</code> — client loads with <code>limit=100</code> and caches 24 h.</li> </ul> <h2 id="7-optimistic--undo-1"><a class="header" href="#7-optimistic--undo-1">7. Optimistic / undo</a></h2> <ul> <li>Grid refresh + editor create: <strong>server-first</strong> (read-after-write; insert slot only after 200).</li> <li>No destructive op exists today; delete <code>(planned)</code> will follow <a href="timetable/../00-shared/06_State_Management.html">00-shared/06 §3.5</a> (confirm dialog; soft-delete via <code>BaseRepository</code> pattern — <code>base.repository.ts:68-74</code>).</li> <li>Drag & drop opens the editor (create semantics) — never an optimistic move (<a href="timetable/09_User_Behaviour.html">09 §5</a>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-timetable-module"><a class="header" href="#13--state-management-timetable-module">13 — State Management (Timetable Module)</a></h1> <ul> <li><a href="timetable/13_State_Management.html#1-cubits">1. Cubits</a></li> <li><a href="timetable/13_State_Management.html#2-state-classes">2. State classes</a></li> <li><a href="timetable/13_State_Management.html#3-repository--cache-keys">3. Repository & cache keys</a></li> <li><a href="timetable/13_State_Management.html#4-events">4. Events</a></li> <li><a href="timetable/13_State_Management.html#5-cubit-flow-grid--editor">5. Cubit flow (grid + editor)</a></li> <li><a href="timetable/13_State_Management.html#6-realtime--cross-cubit">6. Realtime / cross-cubit</a></li> <li><a href="timetable/13_State_Management.html#7-optimistic-policy-module">7. Optimistic policy (module)</a></li> <li><a href="timetable/13_State_Management.html#8-error-handling">8. Error handling</a></li> <li><a href="timetable/13_State_Management.html#9-testing-hooks">9. Testing hooks</a></li> </ul> <blockquote> <p>Extends <a href="timetable/../00-shared/06_State_Management.html">00-shared/06</a>. Bloc/Cubit, one cubit per screen; repository layer only place touching HTTP; typed <code>ApiException(code, status, fieldDetails)</code>.</p> </blockquote> <hr /> <h2 id="1-cubits-7"><a class="header" href="#1-cubits-7">1. Cubits</a></h2> <div class="table-wrapper"><table><thead><tr><th>Cubit</th><th>Screen</th><th>State</th><th>Notes</th></tr></thead><tbody> <tr><td><code>TimetableGridCubit</code></td><td>S1/S2</td><td><code>TimetableGridState</code></td><td>scope (class/teacher), week offset, entries</td></tr> <tr><td><code>EntryFormCubit</code></td><td>S4</td><td><code>EntryFormState</code></td><td>F1 fields + conflict handling</td></tr> <tr><td><code>RoomGridCubit</code></td><td>S3 <code>(planned)</code></td><td><code>RoomGridState</code></td><td>merge of class responses</td></tr> <tr><td><code>WeekNavCubit</code></td><td>S6</td><td><code>WeekNavState</code></td><td>offset only, shell-level <code>(forward-looking)</code></td></tr> </tbody></table> </div> <h2 id="2-state-classes-1"><a class="header" href="#2-state-classes-1">2. State classes</a></h2> <pre><code class="language-dart">// 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 } </code></pre> <h2 id="3-repository--cache-keys-1"><a class="header" href="#3-repository--cache-keys-1">3. Repository & cache keys</a></h2> <pre><code class="language-dart">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 } </code></pre> <div class="table-wrapper"><table><thead><tr><th>Cache key</th><th>TTL</th><th>Policy</th></tr></thead><tbody> <tr><td><code>sl:{tenant}:timetable:class:{classId}</code></td><td>5 min</td><td>stale-while-revalidate (<code>06 §3.3</code>)</td></tr> <tr><td><code>sl:{tenant}:timetable:teacher:{teacherId}</code></td><td>5 min</td><td>stale-while-revalidate</td></tr> <tr><td><code>sl:{tenant}:timetable:refs:{classes|subjects|teachers|rooms|years}</code></td><td>24 h</td><td>reference data</td></tr> <tr><td>detail / editor</td><td>none</td><td>server-fresh on every submit</td></tr> </tbody></table> </div> <p><code>RefreshIndicator</code> always bypasses cache (<code>06 §3.3</code>).</p> <h2 id="4-events-1"><a class="header" href="#4-events-1">4. Events</a></h2> <ul> <li><code>LoadGrid(scope)</code>, <code>ChangeScope(scope)</code>, <code>ChangeWeek(offset)</code>, <code>RefreshGrid</code>, <code>GridCellTap(slot)</code>, <code>SlotTap(entry)</code></li> <li><code>SubmitEntry(draft)</code>, <code>RetrySubmit</code>, <code>DismissConflict</code>, <code>Discard</code></li> <li><code>PreflightChange(field, value)</code> — runs local teacher/room clash check</li> <li>Naming per <code>00-shared/06 §4</code>.</li> </ul> <h2 id="5-cubit-flow-grid--editor"><a class="header" href="#5-cubit-flow-grid--editor">5. Cubit flow (grid + editor)</a></h2> <pre class="mermaid">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] </pre> <h2 id="6-realtime--cross-cubit"><a class="header" href="#6-realtime--cross-cubit">6. Realtime / cross-cubit</a></h2> <ul> <li><code>notification.new</code> (WS, <code>00-shared/07 §8</code>) for own tenant → grid marks refresh-needed badge <code>(planned)</code>; no live mutation (<code>TimetableEntryCreated</code> is emitted but unrouted — <code>01 §6</code>).</li> <li>Teachers module <code>ScheduleTabCubit</code> shares <code>byTeacher</code> cache — refetch on grid mutation while tab visible.</li> <li>Dashboard KPIs <code>(proposed)</code>: refresh on <code>TimetableEntryCreated</code> when dashboard visible.</li> </ul> <h2 id="7-optimistic-policy-module"><a class="header" href="#7-optimistic-policy-module">7. Optimistic policy (module)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Action</th><th>Optimistic?</th></tr></thead><tbody> <tr><td>Scope switch, week offset</td><td>yes (local state)</td></tr> <tr><td>Grid cell/slot taps</td><td>yes (local)</td></tr> <tr><td>Entry create</td><td><strong>no</strong> — server-confirmed; insert after 200 only</td></tr> <tr><td>Conflict banner</td><td>yes (local pre-flight) but always server-verifiable</td></tr> <tr><td>Delete <code>(planned)</code></td><td>no (soft-delete confirm, <code>00-shared/06 §3.5</code>)</td></tr> </tbody></table> </div> <h2 id="8-error-handling"><a class="header" href="#8-error-handling">8. Error handling</a></h2> <ul> <li>409 → <code>ConflictClash</code> from pre-flight data + server status (server sends only "Schedule conflict detected", <code>timetable.service.ts:28</code>); form values kept.</li> <li>400 → <code>fieldErrors</code> from <code>error.details</code> (<code>00-shared/07 §3</code>).</li> <li>404 → grid treats ref as "—" (catalog gone); entries themselves are only deleted by planned routes.</li> <li>401 → <code>AuthCubit</code> refresh; fail → session expiry.</li> <li>5xx → <code>AppErrorState</code> with requestId; retry re-emits event.</li> </ul> <h2 id="9-testing-hooks-2"><a class="header" href="#9-testing-hooks-2">9. Testing hooks</a></h2> <ul> <li><code>TimetableGridCubit</code> pure-Dart unit tests (mock <code>TimetableRepository</code>): scope switch, week offset, server-sort preservation, conflict derivation.</li> <li><code>EntryFormCubit</code>: 409 mapping, fieldErrors, time-format guard (<code>08 F1</code>).</li> <li>Widget tests: grid 3-state (loading/error/empty), S4 409-banner, S5 banner announce, <code>TimetableSlotCard</code> golden ×2×2.</li> <li><code>AppStateObserver</code> transition logs dev-only (<code>06 §6</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-timetable-module"><a class="header" href="#14--qa-checklist-timetable-module">14 — QA Checklist (Timetable Module)</a></h1> <ul> <li><a href="timetable/14_QA_Checklist.html#1-conflict--overlap-matrix-the-core-rule">1. Conflict & overlap matrix (the core rule)</a></li> <li><a href="timetable/14_QA_Checklist.html#2-time--day-validation">2. Time / day validation</a></li> <li><a href="timetable/14_QA_Checklist.html#3-read-paths--sorting">3. Read paths & sorting</a></li> <li><a href="timetable/14_QA_Checklist.html#4-mutation-lifecycle-or-lack-of-it">4. Mutation lifecycle (or lack of it)</a></li> <li><a href="timetable/14_QA_Checklist.html#5-tenant-isolation--security">5. Tenant isolation & security</a></li> <li><a href="timetable/14_QA_Checklist.html#6-eventside-effect-correctness-backend">6. Event/side-effect correctness [backend]</a></li> <li><a href="timetable/14_QA_Checklist.html#7-client-qa-per-00-shared10">7. Client QA (per 00-shared/10)</a></li> <li><a href="timetable/14_QA_Checklist.html#8-release-gates-module">8. Release gates (module)</a></li> </ul> <blockquote> <p>Module-specific additions to <a href="timetable/../00-shared/10_QA_Baseline.html">00-shared/10</a>. Items marked <strong>[backend]</strong> are bugs or gaps derivable from source and should be raised with the API team, not papered over in the UI.</p> </blockquote> <hr /> <h2 id="1-conflict--overlap-matrix-the-core-rule"><a class="header" href="#1-conflict--overlap-matrix-the-core-rule">1. Conflict & overlap matrix (the core rule)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expected</th><th>Server reality</th></tr></thead><tbody> <tr><td>QA-1</td><td>Same teacher, same day, overlapping time</td><td>409 "Schedule conflict detected"</td><td>✅ <code>$or</code> clause <code>{teacherId, dayOfWeek}</code> + <code>timeOverlaps</code> (<code>timetable.service.ts:17-22,62-69</code>)</td></tr> <tr><td>QA-2</td><td>Same room, same day, overlapping time (both entries have <code>roomId</code>)</td><td>409</td><td>✅ <code>{roomId, dayOfWeek}</code> clause (<code>timetable.service.ts:20</code>)</td></tr> <tr><td>QA-3</td><td>Same room, same day, overlapping — <strong>one entry has no <code>roomId</code></strong></td><td>No conflict (different rooms)</td><td>✅ <code>roomId: undefined</code> is stripped from the query → clause no-ops (<code>timetable.service.ts:20</code>) — verify with E2E</td></tr> <tr><td>QA-4</td><td>Same class, same day, overlapping time (two subjects)</td><td><strong>Client highlights only; server accepts</strong></td><td>❌ <strong>No classId check</strong> (<code>timetable.service.ts:17-22</code>) — <strong>[backend]</strong> confirm intent (OQ-1); UI must not block</td></tr> <tr><td>QA-5</td><td>Back-to-back: entry A ends <a href="timetable/09_User_Behaviour.html">09:00</a>, entry B starts <a href="timetable/09_User_Behaviour.html">09:00</a></td><td>Allowed</td><td>✅ <code>start1 < end2 && start2 < end1</code> → false (<code>timetable.service.ts:68</code>)</td></tr> <tr><td>QA-6</td><td>Same teacher, <strong>different day</strong>, same time</td><td>Allowed</td><td>✅ day filter (<code>timetable.service.ts:19-20</code>)</td></tr> <tr><td>QA-7</td><td>Boundary: <code>start1 == start2</code></td><td>Conflict</td><td>✅ overlap true</td></tr> <tr><td>QA-8</td><td>Contained: A <a href="timetable/08_Form_Specifications.html">08:00–10:00</a> vs B <a href="timetable/08_Form_Specifications.html">08:30–09:00</a></td><td>Conflict</td><td>✅ overlap true</td></tr> <tr><td>QA-9</td><td>Two admins race the same slot</td><td>Second gets 409 (no corruption)</td><td>✅ create-time check; client pre-flight is advisory</td></tr> <tr><td>QA-10</td><td>Non-zero-padded time (<code>"8:00"</code>) vs <code>"09:00"</code></td><td><strong>Broken math</strong> (lexical: <code>"8:00" > "09:00"</code>)</td><td>❌ <code>@IsString</code> only (<code>create-timetable-entry.dto.ts:27-33</code>) — <strong>[backend]</strong> add <code>HH:MM</code> pattern; client picker-only input <code>(proposed)</code></td></tr> </tbody></table> </div> <h2 id="2-time--day-validation"><a class="header" href="#2-time--day-validation">2. Time / day validation</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expected</th></tr></thead><tbody> <tr><td>QA-11</td><td><code>dayOfWeek: "sunday"</code></td><td>400 — <code>@IsEnum(DayOfWeek)</code> (<code>create-timetable-entry.dto.ts:23-25</code>); <code>saturday</code> legal, <code>sunday</code> <strong>not</strong> (<code>timetable.schema.ts:7-14</code>)</td></tr> <tr><td>QA-12</td><td><code>endTime <= startTime</code></td><td><strong>Server accepts</strong> (no order check) — client blocks pre-submit <code>(proposed)</code>; <strong>[backend]</strong> consider validation</td></tr> <tr><td>QA-13</td><td>Malformed time <code>"9:00 am"</code></td><td>Server accepts (<code>@IsString</code>) — client picker prevents; grid math would be wrong if sent</td></tr> <tr><td>QA-14</td><td>Unknown body key (e.g. <code>tenantId</code>)</td><td>400 — <code>forbidNonWhitelisted</code> (<code>main.ts:50-57</code>) — client never sends <code>tenantId</code></td></tr> </tbody></table> </div> <h2 id="3-read-paths--sorting"><a class="header" href="#3-read-paths--sorting">3. Read paths & sorting</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expected</th></tr></thead><tbody> <tr><td>QA-15</td><td><code>GET /timetable?classId=</code></td><td><code>[]</code> or entries sorted <code>dayOfWeek, startTime</code> (<code>timetable.service.ts:48-53</code>) — client renders as-is, no re-sort</td></tr> <tr><td>QA-16</td><td><code>GET /timetable?teacherId=</code></td><td>Same sort contract (<code>timetable.service.ts:55-60</code>)</td></tr> <tr><td>QA-17</td><td>Both <code>classId</code> + <code>teacherId</code></td><td><code>classId</code> wins (<code>timetable.controller.ts:26-27</code>) — client never sends both</td></tr> <tr><td>QA-18</td><td><strong>Bare</strong> <code>GET /timetable</code></td><td><code>[]</code> (<code>timetable.controller.ts:28</code>) — never present as "all entries"</td></tr> <tr><td>QA-19</td><td><code>?roomId=</code> filter</td><td><strong>404-ish/ignored — param doesn't exist</strong> (<code>timetable.controller.ts:22-25</code>) — room view is client composition; native filter <code>(planned)</code>; <strong>[backend]</strong></td></tr> <tr><td>QA-20</td><td>Sort with equal <code>dayOfWeek</code> + <code>startTime</code> (duplicate slots)</td><td>Insertion order — verify grid handles ties (no flicker)</td></tr> </tbody></table> </div> <h2 id="4-mutation-lifecycle-or-lack-of-it"><a class="header" href="#4-mutation-lifecycle-or-lack-of-it">4. Mutation lifecycle (or lack of it)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expected</th></tr></thead><tbody> <tr><td>QA-21</td><td><code>PATCH /timetable/:id</code></td><td><strong>No endpoint</strong> (<code>timetable.controller.ts:10</code>) — editor offers Duplicate+create only; edit UI <code>(planned)</code> — <strong>[backend]</strong> (OQ-2)</td></tr> <tr><td>QA-22</td><td><code>DELETE /timetable/:id</code></td><td><strong>No endpoint</strong> — soft-delete columns exist on <code>BaseSchema</code> but are unused by the module; delete <code>(planned)</code></td></tr> <tr><td>QA-23</td><td>Teacher soft-deleted with live timetable entries</td><td>Entries <strong>remain</strong> (no cascade — no delete path; teacher ref dangles) — grid shows "—" name; <strong>[backend]</strong> consider cascade/substitution (<code>IMPLEMENTATION_PLAN.md:226</code>)</td></tr> <tr><td>QA-24</td><td>Room deleted with live entries</td><td>Entries remain; room chip drops (<code>roomId</code> ref unresolved → "—")</td></tr> <tr><td>QA-25</td><td>Entry created for soft-deleted teacher/class/room</td><td><strong>Server performs no existence checks</strong> on create — only the conflict query runs (<code>timetable.service.ts:16-30</code>) — pickers must only offer live records; <strong>[backend]</strong></td></tr> <tr><td>QA-26</td><td>Duplicate identical entry (same everything, same class)</td><td><strong>Accepted</strong> (no unique index, no dup check) — by design for classes (OQ-1); verify UX intent</td></tr> </tbody></table> </div> <h2 id="5-tenant-isolation--security-1"><a class="header" href="#5-tenant-isolation--security-1">5. Tenant isolation & security</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expected</th></tr></thead><tbody> <tr><td>QA-27</td><td>Cross-tenant entry id in create/read</td><td>404/scope-excluded (<code>base.repository.ts:20-30</code>) — never leak existence (<a href="timetable/../00-shared/07_API_Conventions.html">00-shared/07 §3</a>)</td></tr> <tr><td>QA-28</td><td>No <code>tenantId</code> in bodies</td><td>Client never sends it (<a href="timetable/07_Component_Library.html">07 §6</a>)</td></tr> <tr><td>QA-29</td><td>Unauthenticated call</td><td>401 → refresh flow</td></tr> <tr><td>QA-30</td><td>RBAC: endpoints carry <strong>no</strong> <code>timetable.read/create</code> metadata despite perms existing</td><td><code>permissions.constants.ts:44-45</code> vs <code>timetable.controller.ts:9</code> — client-side gate only; <strong>[backend]</strong> add metadata</td></tr> </tbody></table> </div> <h2 id="6-eventside-effect-correctness-backend-1"><a class="header" href="#6-eventside-effect-correctness-backend-1">6. Event/side-effect correctness <strong>[backend]</strong></a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Item</th><th>Evidence</th></tr></thead><tbody> <tr><td>QA-31</td><td><code>TimetableEntryCreated</code> emitted but <strong>no queue mapping found</strong> (<code>event-queue-map.ts</code>)</td><td><code>timetable.service.ts:33-44</code> — confirm intended consumers (notification/audit/search) or drop emission</td></tr> </tbody></table> </div> <h2 id="7-client-qa-per-00-shared10-1"><a class="header" href="#7-client-qa-per-00-shared10-1">7. Client QA (per <a href="timetable/../00-shared/10_QA_Baseline.html">00-shared/10</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Item</th></tr></thead><tbody> <tr><td>QA-32</td><td>Grid perf: 6×~10 grid with ≥ 40 slot cards ≤ 60 fps on low-end; lazy row build + <code>RepaintBoundary</code> (<a href="timetable/15_Flutter_Implementation_Guide.html">15 §9</a>)</td></tr> <tr><td>QA-33</td><td>A11y: slot labels complete (subject/teacher/room/day/time), conflict announced live, Sunday column absent</td></tr> <tr><td>QA-34</td><td>Text scale 2×: slot cards don't clip (subject shortName truncation)</td></tr> <tr><td>QA-35</td><td>Dark mode parity: conflict tokens verified both modes</td></tr> <tr><td>QA-36</td><td>Offline: grid from cache + banner; editor blocked</td></tr> <tr><td>QA-37</td><td>Week nav: offset only re-filters (no server date) — <code>Today</code> resets without refetch</td></tr> <tr><td>QA-38</td><td>Desktop drag: valid/invalid drop targets, no optimistic persistence (<a href="timetable/10_Interaction_Specification.html">10 §2</a>)</td></tr> <tr><td>QA-39</td><td>Analytics <code>(proposed)</code>: <code>timetable.entry.{submit,conflict,success}</code>, <code>timetable.class.view</code> (<a href="timetable/05_Screen_Inventory.html">05</a>)</td></tr> <tr><td>QA-40</td><td>Golden tests: <code>TimetableSlotCard</code> ×2×2, <code>ConflictBanner</code>, <code>WeekNavigator</code></td></tr> </tbody></table> </div> <h2 id="8-release-gates-module-1"><a class="header" href="#8-release-gates-module-1">8. Release gates (module)</a></h2> <ul> <li><input disabled="" type="checkbox"/> 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 + <code>(planned)</code> markers)</li> <li><input disabled="" type="checkbox"/> All envelope error codes map to i18n keys (no raw 5xx text)</li> <li><input disabled="" type="checkbox"/> E2E: create entry → conflict 409 → fix → grid shows slot; cross-tenant 404</li> <li><input disabled="" type="checkbox"/> E2E: bare <code>GET /timetable</code> → <code>[]</code> path never triggered by UI</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-timetable-module"><a class="header" href="#15--flutter-implementation-guide-timetable-module">15 — Flutter Implementation Guide (Timetable Module)</a></h1> <ul> <li><a href="timetable/15_Flutter_Implementation_Guide.html#1-module-folder">1. Module folder</a></li> <li><a href="timetable/15_Flutter_Implementation_Guide.html#2-domain-models">2. Domain models</a></li> <li><a href="timetable/15_Flutter_Implementation_Guide.html#3-repository">3. Repository</a></li> <li><a href="timetable/15_Flutter_Implementation_Guide.html#4-cubits">4. Cubits</a></li> <li><a href="timetable/15_Flutter_Implementation_Guide.html#5-router">5. Router</a></li> <li><a href="timetable/15_Flutter_Implementation_Guide.html#6-theme--components">6. Theme & components</a></li> <li><a href="timetable/15_Flutter_Implementation_Guide.html#7-i18n-keys">7. i18n keys</a></li> <li><a href="timetable/15_Flutter_Implementation_Guide.html#8-drag--drop-desktop--long-press-mobile">8. Drag & drop (desktop) & long-press (mobile)</a></li> <li><a href="timetable/15_Flutter_Implementation_Guide.html#9-grid-table-performance-hero-component">9. Grid table performance (hero component)</a></li> <li><a href="timetable/15_Flutter_Implementation_Guide.html#10-tests">10. Tests</a></li> <li><a href="timetable/15_Flutter_Implementation_Guide.html#11-known-backend-gaps-to-coordinate-from-14-qa">11. Known backend gaps to coordinate (from 14-QA)</a></li> </ul> <blockquote> <p>Extends <a href="timetable/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a>. Forward-looking: no client repo exists (shared ledger A1). Everything below derives from <code>src/modules/timetable/**</code>, <code>src/modules/academics/**</code>, <code>src/modules/teachers/**</code>, <code>src/modules/rooms/**</code>, <code>src/modules/rbac/**</code> (perms at <code>permissions.constants.ts:44-45</code>).</p> </blockquote> <hr /> <h2 id="1-module-folder-1"><a class="header" href="#1-module-folder-1">1. Module folder</a></h2> <pre><code>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 </code></pre> <h2 id="2-domain-models-1"><a class="header" href="#2-domain-models-1">2. Domain models</a></h2> <pre><code class="language-dart">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; } </code></pre> <ul> <li>DTO→model: strict <code>fromJson</code>; times kept as <strong>strings</strong> (server contract — <code>DateTime</code> parse is display-only, never re-serialized; <code>08 F1</code>).</li> <li>Ref-name resolution: <code>TimetableService.joinNames(entry, catalogs)</code> — catalogs (classes/subjects/teachers/rooms/years) loaded once, cached 24 h (<code>13 §3</code>).</li> </ul> <h2 id="3-repository-3"><a class="header" href="#3-repository-3">3. Repository</a></h2> <pre><code class="language-dart">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 } </code></pre> <ul> <li>Errors: interceptor maps envelope → <code>ApiException(code, status, fieldDetails)</code> (<code>00-shared/11 §5</code>); 409 exposes a <code>conflict</code> flavor (server message is generic — map via pre-flight data, <code>06 §S4</code>).</li> <li>Always send exactly one query param (both → classId wins; none → <code>[]</code>; <code>timetable.controller.ts:26-28</code>).</li> </ul> <h2 id="4-cubits-4"><a class="header" href="#4-cubits-4">4. Cubits</a></h2> <ul> <li><code>TimetableGridCubit</code> — scope + weekOffset + entries; never re-sorts (server contract <code>dayOfWeek, startTime</code>, <code>timetable.service.ts:48-60</code>); derives <code>conflicts</code> list (same-grid teacher/room overlaps) for slot badges (OQ-1).</li> <li><code>EntryFormCubit</code> — mirrors <code>EntryDraftDto</code>; time-format guard (24 h <code>HH:MM</code>, <code>end > start</code>); pre-flight teacher/room clash check against cached grids; on 409 keeps form + sets <code>clash</code>.</li> <li><code>RoomGridCubit</code> <code>(planned)</code> — merges <code>byClass</code> responses per room usage.</li> <li><code>WeekNavCubit</code> — offset only (client concept; no server date dimension).</li> </ul> <h2 id="5-router-2"><a class="header" href="#5-router-2">5. Router</a></h2> <pre><code class="language-dart">// 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 </code></pre> <ul> <li>Guards: <code>permissionGuard('timetable.read')</code> / <code>('timetable.create')</code> client-side mirror of <code>permissions.constants.ts:44-45</code> (server is JWT-only today — <code>01 §5</code>).</li> <li>Deep link: <code>studylyon://timetable?classId=:id</code>.</li> </ul> <h2 id="6-theme--components-1"><a class="header" href="#6-theme--components-1">6. Theme & components</a></h2> <ul> <li>All tokens via <code>AppTheme</code> (<code>00-shared/04</code>); module components in <code>07</code>; conflict colors only inside <code>TimetableSlotCard</code> conflict state + <code>ConflictBanner</code> (<code>11 §7</code>).</li> <li>Times rendered <code>mono</code> + <code>tabularFigures</code> (<code>02 §2</code>) in gutter, cards, editor.</li> </ul> <h2 id="7-i18n-keys-1"><a class="header" href="#7-i18n-keys-1">7. i18n keys</a></h2> <pre><code>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 </code></pre> <h2 id="8-drag--drop-desktop--long-press-mobile"><a class="header" href="#8-drag--drop-desktop--long-press-mobile">8. Drag & drop (desktop) & long-press (mobile)</a></h2> <ul> <li>Desktop: <code>TimetableSlotCard</code> wrapped in <code>Draggable<TimetableEntry></code> (feedback = card at <code>e-4</code> + 0.95 scale); <code>GridCell</code> wraps <code>DragTarget<TimetableEntry></code>: <code>onAcceptWithDetails</code> → open <code>EntryEditorSheet</code> prefilled (day/time from target cell, subject/teacher/room from dragged entry) — <strong>create semantics, no PATCH</strong> (<code>10 §2</code>, <code>09 §5</code>; copy banner "This creates a new slot; original stays until delete <code>(planned)</code>").</li> <li>Mobile: no drag — long-press opens the slot popover (<code>10 §1</code>); <code>LongPressDraggable</code> is deliberately unused on touch platforms.</li> <li>Invalid target (occupied cell): reject + <code>errorContainer</code> outline; return animation <code>m-base</code> spring.</li> </ul> <h2 id="9-grid-table-performance-hero-component"><a class="header" href="#9-grid-table-performance-hero-component">9. Grid table performance (hero component)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Technique</th><th>Why</th></tr></thead><tbody> <tr><td>Lazy rows: <code>ListView.builder</code> (or <code>CustomScrollView</code>) over day columns; cells built on demand</td><td>6×N grid with ≥ 40 slots — no eager 60-widget build</td></tr> <tr><td>Slot cards wrapped in <code>RepaintBoundary</code></td><td>drag/hover repaints don't relayout the whole grid</td></tr> <tr><td><code>TimeGutter</code> + <code>DayHeader</code> pinned via sticky headers, single shared <code>ScrollController</code></td><td>constant header during horizontal/vertical scroll</td></tr> <tr><td>Cache-extent tuning + <code>const</code> where possible</td><td>jank-free 60 fps on low-end (<code>14 QA-32</code>)</td></tr> <tr><td><code>Semantics</code> merged per slot (one node)</td><td>a11y tree stays small</td></tr> <tr><td>Fonts: <code>mono</code> + <code>tabularFigures</code> times</td><td>no width jitter while scrolling</td></tr> </tbody></table> </div> <ul> <li>Grid assembly: <code>Map<DayOfWeek, List<TimetableEntry>></code> from the server-sorted list (O(n) pass); row keys = ascending union of <code>startTime</code>; slot height = <code>(end−start) / periodMinutes × rowHeight</code>, min 1 row.</li> </ul> <h2 id="10-tests"><a class="header" href="#10-tests">10. Tests</a></h2> <div class="table-wrapper"><table><thead><tr><th>Layer</th><th>Cases</th></tr></thead><tbody> <tr><td>Unit</td><td><code>TimetableGridCubit</code> scope/week/conflict-derivation; <code>EntryFormCubit</code> 409 mapping, time-format guard (<code>08 F1</code>), pre-flight clash; DTO↔model mappers (sunday → invalid)</td></tr> <tr><td>Widget</td><td>grid 3-state (skeleton/error/empty), S4 409-banner, <code>TimetableSlotCard</code> golden ×2×2 (normal/conflict × light/dark), drop-target accept/reject</td></tr> <tr><td>Integration</td><td>create → 409 → fix → grid shows slot; scope switch refetch; week nav no-refetch</td></tr> <tr><td>E2E</td><td>P0: full create/conflict journey + cross-tenant 404 + bare <code>GET /timetable</code> never sent (per <code>00-shared/10 §9</code>)</td></tr> </tbody></table> </div> <h2 id="11-known-backend-gaps-to-coordinate-from-14-qa"><a class="header" href="#11-known-backend-gaps-to-coordinate-from-14-qa">11. Known backend gaps to coordinate (from 14-QA)</a></h2> <ul> <li><input disabled="" type="checkbox"/> Class-overlap check or confirmed intent (QA-4/OQ-1); time <code>HH:MM</code> pattern + <code>end > start</code> validation (QA-10/12); room filter <code>?roomId=</code> (QA-19)</li> <li><input disabled="" type="checkbox"/> Update/delete endpoints (QA-21/22), cascade/substitution for deleted teachers (QA-23), existence checks on create (QA-25)</li> <li><input disabled="" type="checkbox"/> RBAC metadata on endpoints (QA-30); <code>TimetableEntryCreated</code> routing (QA-31)</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="01---product-overview-transport-module"><a class="header" href="#01---product-overview-transport-module">01 - Product Overview (Transport Module)</a></h1> <ul> <li><a href="transport/01_Product_Overview.html#1-purpose">1. Purpose</a></li> <li><a href="transport/01_Product_Overview.html#2-business-goals">2. Business goals</a></li> <li><a href="transport/01_Product_Overview.html#3-user-goals">3. User goals</a></li> <li><a href="transport/01_Product_Overview.html#4-scope">4. Scope</a> <ul> <li><a href="transport/01_Product_Overview.html#41-in-scope-implemented-backend">4.1 In scope (implemented backend)</a></li> <li><a href="transport/01_Product_Overview.html#42-planned-implementation_planmd229---live-tracking-bus-attendance-fee">4.2 Planned (IMPLEMENTATION_PLAN.md:229 - "Live tracking, bus attendance, fee</a></li> <li><a href="transport/01_Product_Overview.html#43-forward-looking-client-roadmap">4.3 Forward-looking (client roadmap)</a></li> <li><a href="transport/01_Product_Overview.html#44-proposed-analytics">4.4 Proposed (analytics)</a></li> </ul> </li> <li><a href="transport/01_Product_Overview.html#5-non-goals-this-version">5. Non-goals (this version)</a></li> </ul> <blockquote> <p>StudyLyon - multi-tenant ERP / School Management API. This package designs the <strong>Transport module client</strong> (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, domain events, permissions and wire contracts are derived directly from <code>src/modules/transport/**</code>, <code>src/modules/students/**</code>, <code>src/modules/rbac/permissions.constants.ts</code>, <code>docs/IMPLEMENTATION_PLAN.md</code> and <code>docs/user-flows/END_TO_END_USER_FLOWS.md</code>. No feature is invented - anything not present in source is flagged <code>(planned)</code> / <code>(proposed)</code> / <code>(forward-looking)</code>.</p> </blockquote> <p>Heads-up: per the PRD, the mobile client is <strong>out of Phase 1</strong> scope (<code>PRODUCT_REQUIREMENTS_DOCUMENT.md:144</code>, flagged in <a href="transport/../00-shared/12_Assumptions_&_Open_Questions.html">00-shared/12</a> A1); this package is the forward-looking spec the client will be built against later.</p> <hr /> <h2 id="1-purpose-27"><a class="header" href="#1-purpose-27">1. Purpose</a></h2> <p>Transport manages the school fleet and daily movement of students:</p> <ul> <li><strong>Vehicles</strong> - fleet register (plate, model, capacity, type, status).</li> <li><strong>Drivers</strong> - licensed staff register (license, phone, contact details).</li> <li><strong>Routes</strong> - fixed paths with ordered stops, assigned vehicle + driver.</li> <li><strong>Assignments</strong> - which student rides which route, on which shift, from which stop.</li> </ul> <p>Everything is tenant-scoped (<code>tenantId</code> on every document) and soft-delete capable; the repository layer injects both scopes into every query (<code>base.repository.ts:20-30</code>). All endpoints are JWT-guarded (<code>transport.controller.ts:23-26</code>).</p> <div class="table-wrapper"><table><thead><tr><th>Responsibility</th><th>Source</th></tr></thead><tbody> <tr><td>Vehicle CRUD + soft delete</td><td><code>transport.controller.ts:30-58</code>, <code>transport.service.ts:37-110</code></td></tr> <tr><td>Route CRUD + soft delete</td><td><code>transport.controller.ts:60-88</code>, <code>transport.service.ts:112-174</code></td></tr> <tr><td>Driver CRUD + soft delete</td><td><code>transport.controller.ts:90-118</code>, <code>transport.service.ts:176-249</code></td></tr> <tr><td>Student <-> route assignment</td><td><code>transport.controller.ts:120-136</code>, <code>transport.service.ts:251-291</code></td></tr> <tr><td>Duplicate guards (plate/license/phone/route name/assignment)</td><td><code>transport.service.ts:38-45, 113-116, 177-190, 252-258</code></td></tr> <tr><td>Delete guards (in-use vehicle/driver/route)</td><td><code>transport.service.ts:94-99, 168-171, 241-246</code></td></tr> <tr><td>Unique compound indexes</td><td><code>vehicle.schema.ts:50</code>, <code>driver.schema.ts:50-51</code>, <code>route.schema.ts:44</code>, <code>route-assignment.schema.ts:42-46</code></td></tr> <tr><td>Tenant scoping + soft-delete on every query</td><td><code>base.repository.ts:20-30</code></td></tr> <tr><td>Student transport requirement flag</td><td><code>student.schema.ts:53-54</code></td></tr> <tr><td>RBAC permissions</td><td><code>permissions.constants.ts:62-74</code></td></tr> </tbody></table> </div> <h2 id="2-business-goals-20"><a class="header" href="#2-business-goals-20">2. Business goals</a></h2> <div class="table-wrapper"><table><thead><tr><th>Goal</th><th>Measure</th></tr></thead><tbody> <tr><td>No duplicate vehicles</td><td>unique <code>{tenantId, plateNumber}</code> + service 409 (<code>vehicle.schema.ts:50</code>, <code>transport.service.ts:38-45</code>)</td></tr> <tr><td>No duplicate drivers</td><td>unique <code>{tenantId, licenseNumber}</code> and <code>{tenantId, phone}</code> + service 409 (<code>driver.schema.ts:50-51</code>, <code>transport.service.ts:177-190</code>)</td></tr> <tr><td>No duplicate routes</td><td>unique <code>{tenantId, name}</code> + service 409 (<code>route.schema.ts:44</code>, <code>transport.service.ts:113-116</code>)</td></tr> <tr><td>One assignment per (student, route)</td><td>unique <code>{tenantId, routeId, studentId}</code> + service 409 (<code>route-assignment.schema.ts:42-45</code>, <code>transport.service.ts:252-258</code>)</td></tr> <tr><td>Referential integrity on delete</td><td>in-use vehicle/driver/route cannot be deleted (409) (<code>transport.service.ts:94-99, 168-171, 241-246</code>)</td></tr> <tr><td>Cross-tenant isolation</td><td>every query tenant-scoped via <code>BaseRepository.scopedFilter</code> (<code>base.repository.ts:20-30</code>)</td></tr> <tr><td>Audit trail</td><td><code>VehicleCreated</code> / <code>VehicleDeleted</code> / <code>RouteCreated</code> / <code>StudentRouteAssigned</code> events (<code>transport.service.ts:47-57, 102-109, 121-128, 267-278</code>)</td></tr> </tbody></table> </div> <h2 id="3-user-goals-17"><a class="header" href="#3-user-goals-17">3. User goals</a></h2> <ul> <li><strong>Transport admin / school admin:</strong> 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.</li> <li><strong>Parent:</strong> (forward-looking) see the child's route assignment and stop (<code>docs/user-flows/END_TO_END_USER_FLOWS.md:392-406</code>).</li> <li><strong>Student:</strong> (forward-looking) know which bus/stop/shift to use.</li> </ul> <h2 id="4-scope-2"><a class="header" href="#4-scope-2">4. Scope</a></h2> <h3 id="41-in-scope-implemented-backend-2"><a class="header" href="#41-in-scope-implemented-backend-2">4.1 In scope (implemented backend)</a></h3> <p>Vehicle, driver and route CRUD with paginated lists (<code>?page=1&limit=20</code> defaults, <code>transport.controller.ts:38, 68, 98</code>); ordered stop arrays on routes; student-route assignment with shift (<code>morning</code>/<code>evening</code>/<code>both</code>), stop and notes; per-student assignment lookup with populated route; soft delete with conflict guards.</p> <h3 id="42-planned-implementation_planmd229---live-tracking-bus-attendance-fee"><a class="header" href="#42-planned-implementation_planmd229---live-tracking-bus-attendance-fee">4.2 Planned (IMPLEMENTATION_PLAN.md:229 - "Live tracking, bus attendance, fee</a></h3> <p>calc, emergency", 6 days)</p> <p>Live vehicle tracking, bus attendance, transport fee calculation, emergency handling. Documented only as roadmap items; no backend exists - marked <code>(planned)</code> throughout this package.</p> <h3 id="43-forward-looking-client-roadmap-2"><a class="header" href="#43-forward-looking-client-roadmap-2">4.3 Forward-looking (client roadmap)</a></h3> <p>Parent-facing "bus tracking" read of a child's route (<code>GET /transport/assignments/:studentId</code>, referenced in <code>docs/user-flows/END_TO_END_USER_FLOWS.md:406</code>), QR-based boarding <code>(forward-looking)</code>, push alerts (e.g. <code>transport.delay</code> notification type, <code>docs/user-flows/END_TO_END_USER_FLOWS.md:430</code>).</p> <h3 id="44-proposed-analytics-2"><a class="header" href="#44-proposed-analytics-2">4.4 Proposed (analytics)</a></h3> <p>Analytics events on screens (<code>transport.*.*</code>) per <a href="transport/../00-shared/10_QA_Baseline.html">00-shared/10</a> §8 - <code>(proposed)</code>.</p> <h2 id="5-non-goals-this-version-2"><a class="header" href="#5-non-goals-this-version-2">5. Non-goals (this version)</a></h2> <ul> <li>Capacity enforcement at assignment time (no check in <code>assignStudent</code>, <code>transport.service.ts:251-266</code>) - flagged as QA gap, <code>(planned)</code>.</li> <li>Search/filter beyond pagination (list queries filter <code>{}</code>, <code>transport.service.ts:69, 141, 210</code>).</li> <li>Driver-vehicle pairing as first-class entity (only via route <code>vehicleId</code>/<code>driverId</code>, <code>route.schema.ts:26-30</code>).</li> <li>Assignment status change API (status exists on schema <code>route-assignment.schema.ts:23-28</code>, no endpoint to flip it; only soft delete via <code>DELETE /assignments/:id</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="02---user-personas-transport-module"><a class="header" href="#02---user-personas-transport-module">02 - User Personas (Transport Module)</a></h1> <ul> <li><a href="transport/02_User_Personas.html#1-transport-admin-transport-full-access">1. Transport Admin (<code>transport.*</code> full access)</a></li> <li><a href="transport/02_User_Personas.html#2-school-admin--registrar">2. School Admin / Registrar</a></li> <li><a href="transport/02_User_Personas.html#3-parent-forward-looking">3. Parent (forward-looking)</a></li> <li><a href="transport/02_User_Personas.html#4-driver-planned">4. Driver (planned)</a></li> <li><a href="transport/02_User_Personas.html#5-student-planned">5. Student (planned)</a></li> <li><a href="transport/02_User_Personas.html#permission-matrix-source-of-truth-permissionsconstantsts62-74">Permission matrix (source of truth: <code>permissions.constants.ts:62-74</code>)</a></li> </ul> <blockquote> <p>Personas for the Transport module client, derived from the module's permission surface (<code>permissions.constants.ts:62-74</code>) and role matrix (<code>docs/user-flows/END_TO_END_USER_FLOWS.md:760-763</code>). All access is JWT-gated; RBAC guards on the actual endpoints are not yet implemented (see <code>AGENTS.md</code> "Not yet implemented").</p> </blockquote> <hr /> <h2 id="1-transport-admin-transport-full-access"><a class="header" href="#1-transport-admin-transport-full-access">1. Transport Admin (<code>transport.*</code> full access)</a></h2> <p><strong>Who:</strong> school operations staff who own the fleet and daily commute. <strong>Permissions:</strong> <code>transport.vehicle.*</code>, <code>transport.route.*</code>, <code>transport.driver.*</code>, <code>transport.assign</code> (read/create/update/delete + assign, <code>permissions.constants.ts:62-74</code>). <strong>Goals:</strong></p> <ul> <li>Keep the fleet register truthful: no duplicate plates, current vehicle status.</li> <li>Keep routes stable and drivers licensed.</li> <li>Answer "which route does this student ride?" in seconds (<code>GET /transport/assignments/:studentId</code>, <code>transport.controller.ts:126-130</code>). <strong>Pain points:</strong> duplicate-plate or duplicate-license entries silently created in other systems; deleting a vehicle that is still on a route (backend 409s, <code>transport.service.ts:94-99</code>); no search - paginated lists only (<code>transport.service.ts:69</code>). <strong>Frustration threshold:</strong> high - operates daily; bulk operations matter.</li> </ul> <h2 id="2-school-admin--registrar"><a class="header" href="#2-school-admin--registrar">2. School Admin / Registrar</a></h2> <p><strong>Who:</strong> owns student records including <code>transportRequired</code> flag (<code>student.schema.ts:53-54</code>). <strong>Goals:</strong> ensure students who need transport are assigned to a route and shift; hand off to transport admin when a student transfers. <strong>Permissions:</strong> same <code>transport.*</code> set (role matrix <code>docs/user-flows/END_TO_END_USER_FLOWS.md:760-763</code> grants transport to admin). <strong>Pain points:</strong> assignment conflicts (student already on the route - 409, <code>transport.service.ts:252-258</code>).</p> <h2 id="3-parent-forward-looking"><a class="header" href="#3-parent-forward-looking">3. Parent (forward-looking)</a></h2> <p><strong>Who:</strong> wants to know where the child's bus is and when it arrives. <strong>Goals:</strong> read the child's route assignment and stop (<code>GET /transport/assignments/:childId</code>, <code>docs/user-flows/END_TO_END_USER_FLOWS.md:392-406</code>). <strong>Permissions:</strong> read-own only; <code>transport.*</code> not granted to parents (<code>docs/user-flows/END_TO_END_USER_FLOWS.md:760-763</code>). No parent-facing transport endpoint exists - <code>(forward-looking)</code>.</p> <h2 id="4-driver-planned"><a class="header" href="#4-driver-planned">4. Driver (planned)</a></h2> <p><strong>Who:</strong> fleet driver; will see assigned route, vehicle, and stop list (<code>(planned)</code> - driver app surfaces from IMPLEMENTATION_PLAN.md:229 scope). <strong>Goals:</strong> know today's route, stops in order, students on board (<code>(planned)</code>), report delays/emergencies (<code>(planned)</code>).</p> <h2 id="5-student-planned"><a class="header" href="#5-student-planned">5. Student (planned)</a></h2> <p><strong>Who:</strong> rider; sees which bus/stop/shift applies. <code>(planned)</code>.</p> <hr /> <h2 id="permission-matrix-source-of-truth-permissionsconstantsts62-74"><a class="header" href="#permission-matrix-source-of-truth-permissionsconstantsts62-74">Permission matrix (source of truth: <code>permissions.constants.ts:62-74</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Permission</th><th>Admin</th><th>Registrar</th><th>Parent</th><th>Driver</th></tr></thead><tbody> <tr><td><code>transport.vehicle.read</code></td><td>yes</td><td>yes</td><td>no</td><td>no</td></tr> <tr><td><code>transport.vehicle.create/update/delete</code></td><td>yes</td><td>no</td><td>no</td><td>no</td></tr> <tr><td><code>transport.route.read</code></td><td>yes</td><td>yes</td><td>no</td><td>no</td></tr> <tr><td><code>transport.route.create/update/delete</code></td><td>yes</td><td>no</td><td>no</td><td>no</td></tr> <tr><td><code>transport.driver.read</code></td><td>yes</td><td>yes</td><td>no</td><td>no</td></tr> <tr><td><code>transport.driver.create/update/delete</code></td><td>yes</td><td>no</td><td>no</td><td>no</td></tr> <tr><td><code>transport.assign</code></td><td>yes</td><td>yes</td><td>no</td><td>no</td></tr> </tbody></table> </div> <p>Matrix follows <code>docs/user-flows/END_TO_END_USER_FLOWS.md:760-763</code> (<code>transport.*</code> = admin yes, parent read-own only, no driver/student row today).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="03---user-journeys-transport-module"><a class="header" href="#03---user-journeys-transport-module">03 - User Journeys (Transport Module)</a></h1> <ul> <li><a href="transport/03_User_Journey.html#1-fleet-onboarding-add-vehicle--driver-define-route-assign-students">1. Fleet onboarding: add vehicle + driver, define route, assign students</a></li> <li><a href="transport/03_User_Journey.html#2-daily-ops-check-fleet-and-route-health">2. Daily ops: check fleet and route health</a></li> <li><a href="transport/03_User_Journey.html#3-find-a-students-route">3. Find a student's route</a></li> <li><a href="transport/03_User_Journey.html#4-decommission-soft-delete-with-guards">4. Decommission (soft delete) with guards</a></li> <li><a href="transport/03_User_Journey.html#5-parent-bus-lookup-forward-looking">5. Parent bus lookup (forward-looking)</a></li> <li><a href="transport/03_User_Journey.html#6-live-tracking--bus-attendance--fee-calc-planned">6. Live tracking / bus attendance / fee calc (planned)</a></li> <li><a href="transport/03_User_Journey.html#journey-coverage-summary">Journey coverage summary</a></li> </ul> <blockquote> <p>Primary journeys for the Transport module, mapped to exact endpoints. Each step cites the source. States/loading/error handling follow <a href="transport/../00-shared/06_State_Management.html">00-shared/06</a> §3.</p> </blockquote> <hr /> <h2 id="1-fleet-onboarding-add-vehicle--driver-define-route-assign-students"><a class="header" href="#1-fleet-onboarding-add-vehicle--driver-define-route-assign-students">1. Fleet onboarding: add vehicle + driver, define route, assign students</a></h2> <p>Mirrors the setup flow in <code>docs/user-flows/END_TO_END_USER_FLOWS.md:205-222</code>.</p> <ol> <li><strong>Add vehicle</strong> - form (plate, model, capacity >= 1, type) -> <code>POST /api/v1/transport/vehicles</code> (<code>transport.controller.ts:30-34</code>). <ul> <li>409 duplicate plate -> inline error (<code>transport.service.ts:38-45</code>).</li> </ul> </li> <li><strong>Add driver</strong> - form (first/last name, licenseNumber, phone, optional email, licenseExpiry, address, emergencyContact, joinedAt, notes) -> <code>POST /api/v1/transport/drivers</code> (<code>transport.controller.ts:90-94</code>). <ul> <li>409 duplicate license OR duplicate phone (<code>transport.service.ts:177-190</code>).</li> </ul> </li> <li><strong>Create route</strong> - name, startPoint, endPoint, ordered stops, optional vehicleId + driverId, estimatedDuration, notes -> <code>POST /api/v1/transport/routes</code> (<code>transport.controller.ts:60-64</code>). <ul> <li>409 duplicate name (<code>transport.service.ts:113-116</code>).</li> </ul> </li> <li><strong>Assign students</strong> - pick student + route + shift (<code>morning</code>/<code>evening</code>/<code>both</code>) + optional stopName/notes -> <code>POST /api/v1/transport/assign</code> (<code>transport.controller.ts:120-124</code>). <ul> <li>409 already-assigned (<code>transport.service.ts:252-258</code>).</li> </ul> </li> </ol> <p>Error states at each step: 401 (token) -> re-login; 409 -> inline conflict message; 429/offline -> <code>AppOfflineBanner</code> + retry (see <a href="transport/14_QA_Checklist.html">14_QA_Checklist.md</a> §1).</p> <h2 id="2-daily-ops-check-fleet-and-route-health"><a class="header" href="#2-daily-ops-check-fleet-and-route-health">2. Daily ops: check fleet and route health</a></h2> <ol> <li>Open Transport Overview (<code>/transport/overview</code>) - aggregates counts by calling <code>GET /transport/vehicles</code>, <code>GET /transport/routes</code>, <code>GET /transport/drivers</code> (<code>transport.controller.ts:36-40, 66-70, 96-100</code>).</li> <li>Drill into vehicle detail (<code>GET /transport/vehicles/:id</code>, <code>transport.controller.ts:42-46</code>), driver detail (<code>GET /transport/drivers/:id</code>, <code>:102-106</code>), route detail with stops (<code>GET /transport/routes/:id</code>, <code>:72-76</code>).</li> </ol> <h2 id="3-find-a-students-route"><a class="header" href="#3-find-a-students-route">3. Find a student's route</a></h2> <ol> <li>Search/select student (students module) -> <code>GET /transport/assignments/:studentId</code> (<code>transport.controller.ts:126-130</code>) -> list of assignments with populated route (<code>route-assignment.repository.ts:20-24</code>).</li> <li>Show route name, shift, stopName, status; empty state "No route assigned".</li> <li>Remove an assignment -> confirm dialog -> <code>DELETE /transport/assignments/:id</code> (<code>transport.controller.ts:132-136</code>) -> item leaves list (optimistic, rollback on 404).</li> </ol> <h2 id="4-decommission-soft-delete-with-guards"><a class="header" href="#4-decommission-soft-delete-with-guards">4. Decommission (soft delete) with guards</a></h2> <ol> <li>Vehicle: delete -> if a route references it, 409 "Vehicle is assigned to a route." (<code>transport.service.ts:94-99</code>); else soft delete + <code>VehicleDeleted</code> event (<code>transport.service.ts:100-109</code>).</li> <li>Driver: same guard on route reference (<code>transport.service.ts:241-246</code>).</li> <li>Route: delete -> if students are assigned, 409 "Route has active student assignments." (<code>transport.service.ts:168-171</code>); else soft delete (<code>transport.service.ts:172-173</code>).</li> <li>UI consequence: 409 -> explain the dependency, offer navigation to the blocking route list/detail instead of a generic error.</li> </ol> <h2 id="5-parent-bus-lookup-forward-looking"><a class="header" href="#5-parent-bus-lookup-forward-looking">5. Parent bus lookup (forward-looking)</a></h2> <p>Parent opens child profile -> route assignment + stop (<code>GET /transport/assignments/:childId</code> per <code>docs/user-flows/END_TO_END_USER_FLOWS.md:392-406</code>); live bus position, arrival alerts (<code>transport.delay</code>, <code>:430</code>) and QR boarding <code>(forward-looking)</code>.</p> <h2 id="6-live-tracking--bus-attendance--fee-calc-planned"><a class="header" href="#6-live-tracking--bus-attendance--fee-calc-planned">6. Live tracking / bus attendance / fee calc (planned)</a></h2> <p>Per <code>IMPLEMENTATION_PLAN.md:229</code>: live tracking, bus attendance, fee calc, emergency. No backend surface today - all steps <code>(planned)</code>.</p> <h2 id="journey-coverage-summary"><a class="header" href="#journey-coverage-summary">Journey coverage summary</a></h2> <div class="table-wrapper"><table><thead><tr><th>Journey</th><th>Endpoints</th><th>Module pages</th></tr></thead><tbody> <tr><td>1 Fleet onboarding</td><td>vehicles/drivers/routes CRUD + assign</td><td><a href="transport/06_Screen_Specifications.html">06</a> §1-5, <a href="transport/08_Form_Specifications.html">08</a> all forms</td></tr> <tr><td>2 Daily ops</td><td>3 list + 3 detail GETs</td><td><a href="transport/06_Screen_Specifications.html">06</a> §1-3, <a href="transport/05_Screen_Inventory.html">05</a></td></tr> <tr><td>3 Student route lookup</td><td>assignments GET/DELETE</td><td><a href="transport/06_Screen_Specifications.html">06</a> §5, <a href="transport/13_State_Management.html">13</a> AssignmentCubit</td></tr> <tr><td>4 Decommission</td><td>DELETE x3 with 409 guards</td><td><a href="transport/06_Screen_Specifications.html">06</a> §1-3, <a href="transport/10_Interaction_Specification.html">10</a> §3</td></tr> <tr><td>5-6 Parent/live</td><td>none (planned)</td><td><a href="transport/06_Screen_Specifications.html">06</a> §6-7</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="04---information-architecture-transport-module"><a class="header" href="#04---information-architecture-transport-module">04 - Information Architecture (Transport Module)</a></h1> <ul> <li><a href="transport/04_Information_Architecture.html#1-sitemap">1. Sitemap</a></li> <li><a href="transport/04_Information_Architecture.html#2-navigation-rules">2. Navigation rules</a></li> <li><a href="transport/04_Information_Architecture.html#3-entity-model-source-derived">3. Entity model (source-derived)</a></li> <li><a href="transport/04_Information_Architecture.html#4-relationship-semantics">4. Relationship semantics</a></li> <li><a href="transport/04_Information_Architecture.html#5-ia-principles">5. IA principles</a></li> </ul> <blockquote> <p>IA of the Transport module client. Global shells/navigation per <a href="transport/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a>; this file maps module pages and their data dependencies.</p> </blockquote> <hr /> <h2 id="1-sitemap"><a class="header" href="#1-sitemap">1. Sitemap</a></h2> <pre><code>/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) </code></pre> <p>(planned) routes exist only as placeholders per <code>IMPLEMENTATION_PLAN.md:229</code>.</p> <h2 id="2-navigation-rules-2"><a class="header" href="#2-navigation-rules-2">2. Navigation rules</a></h2> <ul> <li>Overview is the hub: three summary tiles (vehicles, drivers, routes) + "Assign student" entry. Counts come from paginated list calls (<code>transport.controller.ts:36-40, 66-70, 96-100</code>) - <code>meta.totalItems</code> (<code>buildPaginationMeta</code>, <code>transport.service.ts:75, 146, 215</code>).</li> <li>List -> detail via row tap; detail -> edit via FAB/action; lists are the only read entry for entities (no global search endpoint exists; <code>00-shared/05</code> global search is out of transport scope).</li> <li>Route editor and assign flows open as bottom sheets on phone, centered dialogs on tablet/desktop (per <code>00-shared/03</code> <code>AppBottomSheet</code>).</li> </ul> <h2 id="3-entity-model-source-derived"><a class="header" href="#3-entity-model-source-derived">3. Entity model (source-derived)</a></h2> <pre><code>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 </code></pre> <ul> <li><code>tenantId</code>, <code>isDeleted</code>, timestamps, <code>version</code> come from <code>BaseSchema</code> and are never read from request bodies (<code>AGENTS.md</code> conventions; <code>base.repository.ts:20-36</code>).</li> </ul> <h2 id="4-relationship-semantics"><a class="header" href="#4-relationship-semantics">4. Relationship semantics</a></h2> <ul> <li>Route -> Vehicle: optional single reference (<code>route.schema.ts:26-27</code>); a vehicle can appear on multiple routes (no uniqueness on route.vehicleId) but cannot be deleted while referenced (<code>transport.service.ts:94-99</code>).</li> <li>Route -> Driver: same pattern (<code>route.schema.ts:29-30</code>; <code>transport.service.ts:241-246</code>).</li> <li>Student -> RouteAssignment: 1..N (a student can be on multiple routes via separate assignments; same pair blocked by unique index <code>route-assignment.schema.ts:42-45</code>).</li> <li>Assignment -> route populated on student lookup (<code>route-assignment.repository.ts:20-24</code>).</li> </ul> <h2 id="5-ia-principles"><a class="header" href="#5-ia-principles">5. IA principles</a></h2> <ul> <li>Entity-first IA: vehicle/driver/route lists are pure CRUD surfaces; assignment lives under students (student-centric) and is reachable from the overview.</li> <li>Deletion always routes through a confirm + dependency explanation (409 guards, <code>transport.service.ts:94-99, 168-171, 241-246</code>).</li> <li>Master-detail on tablet/desktop (<code>00-shared/05</code> §2).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05---screen-inventory-transport-module"><a class="header" href="#05---screen-inventory-transport-module">05 - Screen Inventory (Transport Module)</a></h1> <ul> <li><a href="transport/05_Screen_Inventory.html#legend">Legend</a></li> <li><a href="transport/05_Screen_Inventory.html#1-transport-overview-transport">1. Transport Overview (<code>/transport</code>)</a></li> <li><a href="transport/05_Screen_Inventory.html#2-vehicles---list-transportvehicles">2. Vehicles - List (<code>/transport/vehicles</code>)</a></li> <li><a href="transport/05_Screen_Inventory.html#3-vehicles---detail-transportvehiclesid">3. Vehicles - Detail (<code>/transport/vehicles/:id</code>)</a></li> <li><a href="transport/05_Screen_Inventory.html#4-drivers---list-transportdrivers">4. Drivers - List (<code>/transport/drivers</code>)</a></li> <li><a href="transport/05_Screen_Inventory.html#5-drivers---detail-transportdriversid">5. Drivers - Detail (<code>/transport/drivers/:id</code>)</a></li> <li><a href="transport/05_Screen_Inventory.html#6-routes---list-transportroutes">6. Routes - List (<code>/transport/routes</code>)</a></li> <li><a href="transport/05_Screen_Inventory.html#7-routes---detail-transportroutesid">7. Routes - Detail (<code>/transport/routes/:id</code>)</a></li> <li><a href="transport/05_Screen_Inventory.html#8-route-editor-w-stops-transportroutesidedit-transportroutesnew">8. Route Editor w/ stops (<code>/transport/routes/:id/edit</code>, <code>/transport/routes/new</code>)</a></li> <li><a href="transport/05_Screen_Inventory.html#9-assign-student-to-route-transportassign-bottom-sheet">9. Assign Student to Route (<code>/transport/assign</code>, bottom sheet)</a></li> <li><a href="transport/05_Screen_Inventory.html#10-student-route-assignments-transportstudentsstudentidtransport">10. Student Route Assignments (<code>/transport/students/:studentId/transport</code>)</a></li> <li><a href="transport/05_Screen_Inventory.html#11-live-tracking-transportlive-planned">11. Live Tracking (<code>/transport/live</code>) <code>(planned)</code></a></li> <li><a href="transport/05_Screen_Inventory.html#12-bus-attendance-transportattendance-planned">12. Bus Attendance (<code>/transport/attendance</code>) <code>(planned)</code></a></li> <li><a href="transport/05_Screen_Inventory.html#13-transport-fees-transportfees-planned">13. Transport Fees (<code>/transport/fees</code>) <code>(planned)</code></a></li> <li><a href="transport/05_Screen_Inventory.html#shared-components-used">Shared components used</a></li> <li><a href="transport/05_Screen_Inventory.html#analytics-events-proposed">Analytics events (proposed)</a></li> <li><a href="transport/05_Screen_Inventory.html#keyboard-landscape-tablet-desktop">Keyboard, landscape, tablet, desktop</a></li> </ul> <blockquote> <p>Every screen of the Transport module, its intent, route, composition, states and permissions. Authoritative components in <a href="transport/../00-shared/03_Component_Library.html">00-shared/03</a>; this file enumerates which ones each screen uses with module specifics. Format mirrors <a href="transport/../auth/05_Screen_Inventory.html">auth/05</a>.</p> </blockquote> <hr /> <h2 id="legend-19"><a class="header" href="#legend-19">Legend</a></h2> <p>States = idle / loading / success / empty / error(offline, 409 conflict, 404, 401) / disabled / permission. Analytics events follow <code>{module}.{screen}.{action}</code> (proposed; SDK open - <a href="transport/../00-shared/10_QA_Baseline.html">00-shared/10</a> §8).</p> <hr /> <h2 id="1-transport-overview-transport"><a class="header" href="#1-transport-overview-transport">1. Transport Overview (<code>/transport</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Hub: counts + quick entry to vehicles, drivers, routes, assignment</td></tr> <tr><td>Source</td><td><code>GET /transport/vehicles</code>, <code>GET /transport/routes</code>, <code>GET /transport/drivers</code> (page=1, limit=1 cheapest) - <code>transport.controller.ts:36-40, 66-70, 96-100</code>; totals from <code>meta.totalItems</code> (<code>transport.service.ts:75, 146, 215</code>)</td></tr> <tr><td>Widgets</td><td>3 <code>AppCard</code> stat tiles (vehicles / routes / drivers) + <code>AppListTile</code> shortcuts + FAB "Assign student"</td></tr> <tr><td>States</td><td>per-tile skeleton -> counts; error -> <code>AppErrorState</code> per tile</td></tr> <tr><td>Permissions</td><td><code>transport.vehicle.read</code> + <code>transport.route.read</code> + <code>transport.driver.read</code>; tile hidden when permission missing (<code>permissions.constants.ts:62-74</code>)</td></tr> <tr><td>Analytics</td><td><code>transport.overview.open</code> (proposed)</td></tr> </tbody></table> </div> <h2 id="2-vehicles---list-transportvehicles"><a class="header" href="#2-vehicles---list-transportvehicles">2. Vehicles - List (<code>/transport/vehicles</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Source</td><td><code>GET /transport/vehicles?page&limit</code> (defaults 1, 20), sorted by <code>plateNumber</code> asc (<code>transport.controller.ts:36-40</code>, <code>transport.service.ts:61-76</code>)</td></tr> <tr><td>Widgets</td><td><code>AppListTile</code> per vehicle (plateNumber, model, capacity, status <code>AppBadge</code>), pagination footer, FAB "New vehicle"</td></tr> <tr><td>Row action</td><td>tap -> detail; <code>AppMenu</code> -> Edit / Delete (confirm)</td></tr> <tr><td>Empty</td><td>"No vehicles yet" + CTA create</td></tr> <tr><td>Permissions</td><td>list = <code>transport.vehicle.read</code>; FAB/menu = <code>.create</code> / <code>.update</code> / <code>.delete</code></td></tr> <tr><td>Analytics</td><td><code>transport.vehicles.list</code> (proposed)</td></tr> </tbody></table> </div> <h2 id="3-vehicles---detail-transportvehiclesid"><a class="header" href="#3-vehicles---detail-transportvehiclesid">3. Vehicles - Detail (<code>/transport/vehicles/:id</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Source</td><td><code>GET /transport/vehicles/:id</code> (<code>transport.controller.ts:42-46</code>); 404 -> <code>AppErrorState</code></td></tr> <tr><td>Content</td><td>plateNumber, model, capacity, type (bus/van/car), status, year, color, insuranceExpiry, notes - <code>vehicle.schema.ts:21-46</code></td></tr> <tr><td>Actions</td><td>Edit (<code>.update</code>), Delete with dependency guard (<code>.delete</code>); if route references vehicle -> 409 message + navigate to routes (<code>transport.service.ts:94-99</code>)</td></tr> </tbody></table> </div> <h2 id="4-drivers---list-transportdrivers"><a class="header" href="#4-drivers---list-transportdrivers">4. Drivers - List (<code>/transport/drivers</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Source</td><td><code>GET /transport/drivers?page&limit</code>, sorted by <code>firstName</code> (<code>transport.controller.ts:96-100</code>, <code>transport.service.ts:201-216</code>)</td></tr> <tr><td>Widgets</td><td><code>AppListTile</code> (full name, licenseNumber, phone, status badge: active/inactive/on_leave), pagination, FAB "New driver"</td></tr> <tr><td>Row action</td><td>detail; menu -> Edit / Delete</td></tr> <tr><td>Empty</td><td>"No drivers yet"</td></tr> <tr><td>Permissions</td><td><code>transport.driver.*</code> (<code>permissions.constants.ts:70-73</code>)</td></tr> </tbody></table> </div> <h2 id="5-drivers---detail-transportdriversid"><a class="header" href="#5-drivers---detail-transportdriversid">5. Drivers - Detail (<code>/transport/drivers/:id</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Source</td><td><code>GET /transport/drivers/:id</code> (<code>transport.controller.ts:102-106</code>)</td></tr> <tr><td>Content</td><td>firstName, lastName, licenseNumber, phone, email, status, licenseExpiry, address, emergencyContact, joinedAt, notes - <code>driver.schema.ts:15-46</code></td></tr> <tr><td>Delete guard</td><td>409 when route references driver (<code>transport.service.ts:241-246</code>)</td></tr> </tbody></table> </div> <h2 id="6-routes---list-transportroutes"><a class="header" href="#6-routes---list-transportroutes">6. Routes - List (<code>/transport/routes</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Source</td><td><code>GET /transport/routes?page&limit</code>, sorted by <code>name</code> (<code>transport.controller.ts:66-70</code>, <code>transport.service.ts:132-147</code>)</td></tr> <tr><td>Widgets</td><td><code>AppListTile</code> (name, startPoint -> endPoint, stop count, status badge), pagination, FAB "New route"</td></tr> <tr><td>Row action</td><td>detail; menu -> Edit / Delete</td></tr> <tr><td>Empty</td><td>"No routes yet"</td></tr> <tr><td>Permissions</td><td><code>transport.route.*</code> (<code>permissions.constants.ts:66-69</code>)</td></tr> </tbody></table> </div> <h2 id="7-routes---detail-transportroutesid"><a class="header" href="#7-routes---detail-transportroutesid">7. Routes - Detail (<code>/transport/routes/:id</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Source</td><td><code>GET /transport/routes/:id</code> (<code>transport.controller.ts:72-76</code>)</td></tr> <tr><td>Content</td><td>name, startPoint, endPoint, ordered stops list, vehicle + driver refs (names when resolved; ids are raw ObjectIds in payload - <code>route.schema.ts:26-30</code>), status, estimatedDuration, notes</td></tr> <tr><td>Actions</td><td>Edit (route editor), Delete (409 if assignments exist, <code>transport.service.ts:168-171</code>), "Assign students" shortcut</td></tr> </tbody></table> </div> <h2 id="8-route-editor-w-stops-transportroutesidedit-transportroutesnew"><a class="header" href="#8-route-editor-w-stops-transportroutesidedit-transportroutesnew">8. Route Editor w/ stops (<code>/transport/routes/:id/edit</code>, <code>/transport/routes/new</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Create/update route incl. ordered stop list</td></tr> <tr><td>Form</td><td>name*, startPoint*, endPoint*, stops (reorderable <code>{name, order}</code> list), vehicleId picker, driverId picker, estimatedDuration, notes - <code>create-route.dto.ts:21-60</code></td></tr> <tr><td>Stops</td><td><code>ReorderableListView</code>; order field is client-set, sent as array of <code>{name, order}</code> (<code>route.schema.ts:23-24</code>)</td></tr> <tr><td>Conflicts</td><td>409 duplicate name on create (<code>transport.service.ts:113-116</code>); on update name change to existing -> 409 (unique index <code>route.schema.ts:44</code>)</td></tr> <tr><td>Permissions</td><td>create = <code>.route.create</code>, update = <code>.route.update</code></td></tr> </tbody></table> </div> <h2 id="9-assign-student-to-route-transportassign-bottom-sheet"><a class="header" href="#9-assign-student-to-route-transportassign-bottom-sheet">9. Assign Student to Route (<code>/transport/assign</code>, bottom sheet)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Create a route assignment for one student</td></tr> <tr><td>Source</td><td><code>POST /transport/assign</code> (<code>transport.controller.ts:120-124</code>) with <code>AssignRouteDto</code> (<code>assign-route.dto.ts:4-26</code>)</td></tr> <tr><td>Form</td><td>route picker, student picker, shift (morning/evening/both), optional stopName, notes</td></tr> <tr><td>Conflicts</td><td>409 "Student already assigned to this route" (<code>transport.service.ts:252-258</code>) - inline, offer "view existing"</td></tr> <tr><td>Note</td><td>student picker filtered to <code>transportRequired</code> students when available (<code>student.schema.ts:53-54</code>)</td></tr> </tbody></table> </div> <h2 id="10-student-route-assignments-transportstudentsstudentidtransport"><a class="header" href="#10-student-route-assignments-transportstudentsstudentidtransport">10. Student Route Assignments (<code>/transport/students/:studentId/transport</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Source</td><td><code>GET /transport/assignments/:studentId</code> - assignments with populated route (<code>transport.controller.ts:126-130</code>, <code>route-assignment.repository.ts:20-24</code>)</td></tr> <tr><td>Widgets</td><td><code>AppListTile</code> per assignment (route name, shift badge, stopName, status), delete via <code>DELETE /transport/assignments/:id</code> (<code>transport.controller.ts:132-136</code>)</td></tr> <tr><td>Empty</td><td>"No route assigned" + CTA assign</td></tr> <tr><td>Permissions</td><td><code>transport.assign</code> (read context) or <code>transport.route.read</code></td></tr> </tbody></table> </div> <h2 id="11-live-tracking-transportlive-planned"><a class="header" href="#11-live-tracking-transportlive-planned">11. Live Tracking (<code>/transport/live</code>) <code>(planned)</code></a></h2> <p>Map of vehicles with routes + live positions. No backend surface (<code>IMPLEMENTATION_PLAN.md:229</code>).</p> <h2 id="12-bus-attendance-transportattendance-planned"><a class="header" href="#12-bus-attendance-transportattendance-planned">12. Bus Attendance (<code>/transport/attendance</code>) <code>(planned)</code></a></h2> <p>Boarding list per route/shift with QR scan <code>(forward-looking)</code>.</p> <h2 id="13-transport-fees-transportfees-planned"><a class="header" href="#13-transport-fees-transportfees-planned">13. Transport Fees (<code>/transport/fees</code>) <code>(planned)</code></a></h2> <p>Fee calculation per route/student (<code>IMPLEMENTATION_PLAN.md:229</code>).</p> <hr /> <h2 id="shared-components-used-24"><a class="header" href="#shared-components-used-24">Shared components used</a></h2> <p><code>AppTextField</code>, <code>AppButton</code>, <code>AppSnackbar</code>, <code>AppCard</code>, <code>AppListTile</code>, <code>AppBottomSheet</code>, <code>AppDialog</code>, <code>AppMenu</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppOfflineBanner</code>, <code>AppFAB</code>, <code>AppChips</code>, <code>AppBadge</code>, <code>AppAvatar</code>, <code>AppErrorState</code>, <code>AppPagination</code>. Module-specific: <code>RouteStopEditor</code> (reorderable stops), <code>AssignmentShiftPicker</code>, <code>StatTile</code> - defined in <a href="transport/07_Component_Library.html">07_Component_Library.md</a>.</p> <h2 id="analytics-events-proposed-21"><a class="header" href="#analytics-events-proposed-21">Analytics events (proposed)</a></h2> <p><code>transport.overview.open</code>, <code>transport.vehicles.{list,open,create,update,delete}</code>, <code>transport.drivers.{list,open,create,update,delete}</code>, <code>transport.routes.{list,open,create,update,delete}</code>, <code>transport.assign.{submit,duplicate,success}</code>, <code>transport.assignments.{list,remove}</code> (all proposed).</p> <h2 id="keyboard-landscape-tablet-desktop-19"><a class="header" href="#keyboard-landscape-tablet-desktop-19">Keyboard, landscape, tablet, desktop</a></h2> <ul> <li>Forms: keyboard-avoidance + scroll; tablet/desktop constrained width (<a href="transport/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a> §2).</li> <li>Route stop editor: full-width on phone; two-column stop list + preview on tablet.</li> <li>Lists: master-detail on tablet; desktop hover row highlights.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="06---screen-specifications-transport-module"><a class="header" href="#06---screen-specifications-transport-module">06 - Screen Specifications (Transport Module)</a></h1> <ul> <li><a href="transport/06_Screen_Specifications.html#0-shared-states-every-screen">0. Shared states (every screen)</a></li> <li><a href="transport/06_Screen_Specifications.html#1-transport-overview-transport">1. Transport Overview (<code>/transport</code>)</a> <ul> <li><a href="transport/06_Screen_Specifications.html#layout">Layout</a></li> <li><a href="transport/06_Screen_Specifications.html#wire-contract">Wire contract</a></li> <li><a href="transport/06_Screen_Specifications.html#states">States</a></li> <li><a href="transport/06_Screen_Specifications.html#interactions">Interactions</a></li> <li><a href="transport/06_Screen_Specifications.html#a11y--motion">a11y / motion</a></li> <li><a href="transport/06_Screen_Specifications.html#analytics-proposed">Analytics (proposed)</a></li> </ul> </li> <li><a href="transport/06_Screen_Specifications.html#2-vehicle-list-transportvehicles">2. Vehicle List (<code>/transport/vehicles</code>)</a> <ul> <li><a href="transport/06_Screen_Specifications.html#layout-1">Layout</a></li> <li><a href="transport/06_Screen_Specifications.html#wire-contract-1">Wire contract</a></li> <li><a href="transport/06_Screen_Specifications.html#states-1">States</a></li> <li><a href="transport/06_Screen_Specifications.html#interactions-1">Interactions</a></li> <li><a href="transport/06_Screen_Specifications.html#a11y--motion-1">a11y / motion</a></li> </ul> </li> <li><a href="transport/06_Screen_Specifications.html#3-vehicle-detail-transportvehiclesid">3. Vehicle Detail (<code>/transport/vehicles/:id</code>)</a> <ul> <li><a href="transport/06_Screen_Specifications.html#layout-2">Layout</a></li> <li><a href="transport/06_Screen_Specifications.html#wire-contract-2">Wire contract</a></li> <li><a href="transport/06_Screen_Specifications.html#interactions-2">Interactions</a></li> </ul> </li> <li><a href="transport/06_Screen_Specifications.html#4-vehicle-create--update-form-transportvehiclesnew-or-sheet">4. Vehicle Create / Update Form (<code>/transport/vehicles/new</code> or sheet)</a> <ul> <li><a href="transport/06_Screen_Specifications.html#layout-3">Layout</a></li> <li><a href="transport/06_Screen_Specifications.html#wire-contract-3">Wire contract</a></li> <li><a href="transport/06_Screen_Specifications.html#notes">Notes</a></li> </ul> </li> <li><a href="transport/06_Screen_Specifications.html#5-driver-list-transportdrivers">5. Driver List (<code>/transport/drivers</code>)</a> <ul> <li><a href="transport/06_Screen_Specifications.html#layout-4">Layout</a></li> <li><a href="transport/06_Screen_Specifications.html#wire-contract-4">Wire contract</a></li> <li><a href="transport/06_Screen_Specifications.html#states--interactions">States / interactions</a></li> </ul> </li> <li><a href="transport/06_Screen_Specifications.html#6-driver-detail-transportdriversid">6. Driver Detail (<code>/transport/drivers/:id</code>)</a> <ul> <li><a href="transport/06_Screen_Specifications.html#layout-5">Layout</a></li> <li><a href="transport/06_Screen_Specifications.html#wire-contract-5">Wire contract</a></li> <li><a href="transport/06_Screen_Specifications.html#interactions-3">Interactions</a></li> </ul> </li> <li><a href="transport/06_Screen_Specifications.html#7-driver-create--update-form">7. Driver Create / Update Form</a> <ul> <li><a href="transport/06_Screen_Specifications.html#layout-6">Layout</a></li> <li><a href="transport/06_Screen_Specifications.html#wire-contract-6">Wire contract</a></li> </ul> </li> <li><a href="transport/06_Screen_Specifications.html#8-route-list-transportroutes">8. Route List (<code>/transport/routes</code>)</a> <ul> <li><a href="transport/06_Screen_Specifications.html#layout-7">Layout</a></li> <li><a href="transport/06_Screen_Specifications.html#wire-contract-7">Wire contract</a></li> <li><a href="transport/06_Screen_Specifications.html#interactions-4">Interactions</a></li> </ul> </li> <li><a href="transport/06_Screen_Specifications.html#9-route-detail-transportroutesid">9. Route Detail (<code>/transport/routes/:id</code>)</a> <ul> <li><a href="transport/06_Screen_Specifications.html#layout-8">Layout</a></li> <li><a href="transport/06_Screen_Specifications.html#wire-contract-8">Wire contract</a></li> <li><a href="transport/06_Screen_Specifications.html#interactions-5">Interactions</a></li> </ul> </li> <li><a href="transport/06_Screen_Specifications.html#10-route-editor-w-stops-create--update">10. Route Editor w/ stops (create & update)</a> <ul> <li><a href="transport/06_Screen_Specifications.html#layout-9">Layout</a></li> <li><a href="transport/06_Screen_Specifications.html#wire-contract--validation">Wire contract / validation</a></li> <li><a href="transport/06_Screen_Specifications.html#states-2">States</a></li> <li><a href="transport/06_Screen_Specifications.html#a11y">a11y</a></li> </ul> </li> <li><a href="transport/06_Screen_Specifications.html#11-assign-student-to-route-bottom-sheet--dialog">11. Assign Student to Route (bottom sheet / dialog)</a> <ul> <li><a href="transport/06_Screen_Specifications.html#layout-10">Layout</a></li> <li><a href="transport/06_Screen_Specifications.html#wire-contract-9">Wire contract</a></li> <li><a href="transport/06_Screen_Specifications.html#states--errors">States & errors</a></li> <li><a href="transport/06_Screen_Specifications.html#note-client-side-only">Note (client-side only)</a></li> </ul> </li> <li><a href="transport/06_Screen_Specifications.html#12-student-route-assignments-transportstudentsstudentidtransport">12. Student Route Assignments (<code>/transport/students/:studentId/transport</code>)</a> <ul> <li><a href="transport/06_Screen_Specifications.html#layout-11">Layout</a></li> <li><a href="transport/06_Screen_Specifications.html#wire-contract-10">Wire contract</a></li> <li><a href="transport/06_Screen_Specifications.html#interactions-6">Interactions</a></li> </ul> </li> <li><a href="transport/06_Screen_Specifications.html#13-live-tracking-transportlive-planned">13. Live Tracking (<code>/transport/live</code>) <code>(planned)</code></a></li> <li><a href="transport/06_Screen_Specifications.html#14-bus-attendance-transportattendance-planned">14. Bus Attendance (<code>/transport/attendance</code>) <code>(planned)</code></a></li> <li><a href="transport/06_Screen_Specifications.html#15-transport-fees-transportfees-planned">15. Transport Fees (<code>/transport/fees</code>) <code>(planned)</code></a></li> <li><a href="transport/06_Screen_Specifications.html#cross-screen-rules">Cross-screen rules</a></li> </ul> <blockquote> <p>Detailed specifications per screen: layout, wire contract, states, interactions, errors, permissions, a11y and analytics. Read alongside <a href="transport/05_Screen_Inventory.html">05_Screen_Inventory.md</a> and <a href="transport/../00-shared/03_Component_Library.html">00-shared/03</a> (components), <a href="transport/../00-shared/06_State_Management.html">00-shared/06</a> (state), <a href="transport/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a> (a11y).</p> </blockquote> <hr /> <h2 id="0-shared-states-every-screen"><a class="header" href="#0-shared-states-every-screen">0. Shared states (every screen)</a></h2> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Rendering</th><th>Source of truth</th></tr></thead><tbody> <tr><td>idle/loading</td><td><code>AppSkeleton</code> per block; list rows as skeleton tiles</td><td><a href="transport/../00-shared/06_State_Management.html">00-shared/06</a> §3.1</td></tr> <tr><td>error offline</td><td><code>AppOfflineBanner</code> + native retry; data shown stale if cached</td><td><a href="transport/../00-shared/10_QA_Baseline.html">00-shared/10</a> §2</td></tr> <tr><td>error API</td><td><code>AppErrorState(code, message, onRetry)</code>; 401 -> re-auth; 403 -> permission copy</td><td><a href="transport/../00-shared/03_Component_Library.html">00-shared/03</a></td></tr> <tr><td>empty</td><td><code>AppEmptyState</code> with CTA</td><td><a href="transport/../00-shared/03_Component_Library.html">00-shared/03</a></td></tr> <tr><td>409 conflict</td><td>inline field/dialog message with corrective action (see each screen)</td><td><code>transport.service.ts:38-45, 113-116, 177-190, 252-258</code></td></tr> <tr><td>404</td><td>detail screens -> <code>AppErrorState</code> + back</td><td><code>transport.service.ts:80, 151, 220</code></td></tr> </tbody></table> </div> <p>Error envelope: <code>{ statusCode, message, timestamp, path }</code> per <a href="transport/../00-shared/07_API_Conventions.html">00-shared/07</a> §3 (<code>HttpExceptionFilter</code>).</p> <hr /> <h2 id="1-transport-overview-transport-1"><a class="header" href="#1-transport-overview-transport-1">1. Transport Overview (<code>/transport</code>)</a></h2> <h3 id="layout-24"><a class="header" href="#layout-24">Layout</a></h3> <ul> <li>AppBar: title "Transport", global search icon disabled (no search endpoint).</li> <li>Body: <code>StatTile</code> grid (Vehicles / Routes / Drivers) - count from <code>meta.totalItems</code> of each list call with <code>limit=1</code> (<code>transport.service.ts:75, 146, 215</code>); each tile navigates to its list.</li> <li>Quick actions card: "Assign student to route", "Student assignments" (student search first), "Route editor" shortcut to route list.</li> <li>FAB: "Assign student" (requires <code>transport.assign</code>, <code>permissions.constants.ts:74</code>).</li> </ul> <h3 id="wire-contract"><a class="header" href="#wire-contract">Wire contract</a></h3> <ul> <li>3 parallel GETs: <code>GET /transport/vehicles?page=1&limit=1</code>, <code>GET /transport/routes?page=1&limit=1</code>, <code>GET /transport/drivers?page=1&limit=1</code> (<code>transport.controller.ts:36-40, 66-70, 96-100</code>).</li> <li>Envelope per <a href="transport/../00-shared/07_API_Conventions.html">00-shared/07</a> §2: <code>{ data: [...], meta: { page, limit, totalItems, totalPages, hasNext, hasPrevious } }</code>.</li> </ul> <h3 id="states-17"><a class="header" href="#states-17">States</a></h3> <ul> <li>Per-tile loading/error - one failed tile shows <code>AppErrorState</code> with retry for that tile only; the rest render.</li> <li>All three fail -> full-page error.</li> </ul> <h3 id="interactions-3"><a class="header" href="#interactions-3">Interactions</a></h3> <ul> <li>Tile tap -> list screen. FAB -> assign sheet (screen §7).</li> <li>Pull-to-refresh re-fires all three GETs.</li> </ul> <h3 id="a11y--motion-2"><a class="header" href="#a11y--motion-2">a11y / motion</a></h3> <ul> <li>StatTile semantics label "Vehicles: 12". Fade-in stagger <code>m-base</code> (<a href="transport/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>).</li> </ul> <h3 id="analytics-proposed-11"><a class="header" href="#analytics-proposed-11">Analytics (proposed)</a></h3> <p><code>transport.overview.open</code>, <code>transport.overview.tile.{vehicles,routes,drivers}</code>.</p> <hr /> <h2 id="2-vehicle-list-transportvehicles"><a class="header" href="#2-vehicle-list-transportvehicles">2. Vehicle List (<code>/transport/vehicles</code>)</a></h2> <h3 id="layout-25"><a class="header" href="#layout-25">Layout</a></h3> <ul> <li>AppBar: "Vehicles" + FAB "New vehicle" (<code>.create</code>).</li> <li>List of <code>AppListTile</code>: leading vehicle type icon (bus/van/car), title <code>plateNumber</code> (monospace), subtitle <code>model + capacity seats</code>, trailing <code>AppBadge</code> (status: active=success, maintenance=warning, inactive=neutral) + <code>AppMenu</code> (Edit / Delete).</li> <li><code>AppPagination</code> footer (page, limit from state; <code>meta</code> from envelope).</li> </ul> <h3 id="wire-contract-1"><a class="header" href="#wire-contract-1">Wire contract</a></h3> <ul> <li><code>GET /transport/vehicles?page=1&limit=20</code> (<code>transport.controller.ts:36-40</code>); server sort: <code>plateNumber</code> asc (<code>transport.service.ts:71</code>).</li> <li>Item shape: <code>{ _id, plateNumber, model, capacity, type, status, year?, color?, insuranceExpiry?, notes?, tenantId, isDeleted, version, createdAt, updatedAt }</code> (<code>vehicle.schema.ts:19-50</code>).</li> </ul> <h3 id="states-18"><a class="header" href="#states-18">States</a></h3> <ul> <li>loading: 6 skeleton tiles; empty: "No vehicles yet" + CTA; error per §0.</li> </ul> <h3 id="interactions-4"><a class="header" href="#interactions-4">Interactions</a></h3> <ul> <li>Tap row -> detail (§3). Delete via menu -> <code>AppDialog</code> confirm -> optimistic remove; on 409 ("Vehicle is assigned to a route.", <code>transport.service.ts:98</code>) show dialog with "View blocking routes" button -> route list.</li> <li>Infinite approach: pagination controls (page jumps) - not infinite scroll (<a href="transport/../00-shared/01_Product_Overview.html">00-shared/01</a> §"pagination, not infinite scroll alone").</li> </ul> <h3 id="a11y--motion-3"><a class="header" href="#a11y--motion-3">a11y / motion</a></h3> <ul> <li>Row delete confirmation focus trap; status badge semantics label.</li> </ul> <hr /> <h2 id="3-vehicle-detail-transportvehiclesid"><a class="header" href="#3-vehicle-detail-transportvehiclesid">3. Vehicle Detail (<code>/transport/vehicles/:id</code>)</a></h2> <h3 id="layout-26"><a class="header" href="#layout-26">Layout</a></h3> <ul> <li>AppBar: plateNumber + back; actions: Edit (<code>.update</code>), Delete (<code>.delete</code>).</li> <li><code>AppCard</code> "Fleet info": model, type, capacity, year, color, status badge, insuranceExpiry (localized date), notes.</li> <li>Audit footer: createdAt / updatedAt / version (read-only, <code>base.repository.ts:57-66</code>).</li> </ul> <h3 id="wire-contract-2"><a class="header" href="#wire-contract-2">Wire contract</a></h3> <ul> <li><code>GET /transport/vehicles/:id</code> (<code>transport.controller.ts:42-46</code>); 404 "Vehicle not found." -> <code>AppErrorState</code> + back (<code>transport.service.ts:80</code>).</li> </ul> <h3 id="interactions-5"><a class="header" href="#interactions-5">Interactions</a></h3> <ul> <li>Delete -> confirm dialog -> <code>DELETE /transport/vehicles/:id</code> (<code>transport.controller.ts:54-58</code>); 409 route reference -> explain dialog (<code>transport.service.ts:94-99</code>); success -> pop to list + snackbar; emits <code>VehicleDeleted</code> (<code>transport.service.ts:102-109</code>).</li> <li>Edit -> create/update form (§4) prefilled; PATCH returns updated doc.</li> </ul> <hr /> <h2 id="4-vehicle-create--update-form-transportvehiclesnew-or-sheet"><a class="header" href="#4-vehicle-create--update-form-transportvehiclesnew-or-sheet">4. Vehicle Create / Update Form (<code>/transport/vehicles/new</code> or sheet)</a></h2> <h3 id="layout-27"><a class="header" href="#layout-27">Layout</a></h3> <ul> <li>Fields: plateNumber*, model*, capacity* (number, min 1), type* (segmented: bus/van/car), year (number), color, insuranceExpiry (date), notes (multiline) - <code>create-vehicle.dto.ts:5-42</code>.</li> <li>Validation per DTO: required <code>IsString</code> for plateNumber/model/type; capacity <code>IsNumber @Min(1)</code>; year <code>IsNumber</code> optional (<code>create-vehicle.dto.ts:14-26</code>).</li> <li>Submit button disabled until valid; loading spinner on submit.</li> </ul> <h3 id="wire-contract-3"><a class="header" href="#wire-contract-3">Wire contract</a></h3> <ul> <li>Create: <code>POST /transport/vehicles</code> (<code>transport.controller.ts:30-34</code>).</li> <li>Update: <code>PATCH /transport/vehicles/:id</code> with <code>UpdateVehicleDto</code> (<code>PartialType</code>, <code>update-vehicle.dto.ts:4</code>; any subset allowed).</li> <li>409: "Vehicle "<plate>" already exists." inline under plateNumber (<code>transport.service.ts:42-44</code>).</li> <li>On success: emit <code>VehicleCreated</code> (<code>transport.service.ts:47-57</code>) -> navigate to detail + snackbar.</li> </ul> <h3 id="notes"><a class="header" href="#notes">Notes</a></h3> <ul> <li>plateNumber trimmed server-side (<code>vehicle.schema.ts:21-22</code>); normalize case client-side before submit to reduce false conflicts.</li> </ul> <hr /> <h2 id="5-driver-list-transportdrivers"><a class="header" href="#5-driver-list-transportdrivers">5. Driver List (<code>/transport/drivers</code>)</a></h2> <h3 id="layout-28"><a class="header" href="#layout-28">Layout</a></h3> <ul> <li>AppBar: "Drivers" + FAB "New driver".</li> <li><code>AppListTile</code>: <code>AppAvatar</code> initials, title "firstName lastName", subtitle <code>licenseNumber + phone</code>, trailing status badge (active/on_leave/inactive) + <code>AppMenu</code> (Edit / Delete).</li> <li><code>AppPagination</code> footer.</li> </ul> <h3 id="wire-contract-4"><a class="header" href="#wire-contract-4">Wire contract</a></h3> <ul> <li><code>GET /transport/drivers?page=1&limit=20</code> (<code>transport.controller.ts:96-100</code>); sort <code>firstName</code> asc (<code>transport.service.ts:211</code>).</li> <li>Item: <code>{ _id, firstName, lastName, licenseNumber, phone, email?, status, licenseExpiry?, address?, emergencyContact?, joinedAt?, notes?, ...base }</code> (<code>driver.schema.ts:15-46</code>).</li> </ul> <h3 id="states--interactions-2"><a class="header" href="#states--interactions-2">States / interactions</a></h3> <ul> <li>Same as §2. Delete 409: "Driver is assigned to a route." (<code>transport.service.ts:245</code>).</li> </ul> <hr /> <h2 id="6-driver-detail-transportdriversid"><a class="header" href="#6-driver-detail-transportdriversid">6. Driver Detail (<code>/transport/drivers/:id</code>)</a></h2> <h3 id="layout-29"><a class="header" href="#layout-29">Layout</a></h3> <ul> <li>AppBar: full name; actions Edit / Delete.</li> <li><code>AppCard</code> "License & contact": licenseNumber, phone, email, licenseExpiry (warn style when < 30 days - client heuristic; no server check), address, emergencyContact, joinedAt.</li> <li><code>AppCard</code> "Status": status badge + notes.</li> </ul> <h3 id="wire-contract-5"><a class="header" href="#wire-contract-5">Wire contract</a></h3> <ul> <li><code>GET /transport/drivers/:id</code> (<code>transport.controller.ts:102-106</code>); 404 "Driver not found." (<code>transport.service.ts:220</code>).</li> </ul> <h3 id="interactions-6"><a class="header" href="#interactions-6">Interactions</a></h3> <ul> <li>Edit -> form (§7); Delete -> confirm -> <code>DELETE /transport/drivers/:id</code> (<code>transport.controller.ts:114-118</code>) with 409 guard (<code>transport.service.ts:241-246</code>).</li> </ul> <hr /> <h2 id="7-driver-create--update-form"><a class="header" href="#7-driver-create--update-form">7. Driver Create / Update Form</a></h2> <h3 id="layout-30"><a class="header" href="#layout-30">Layout</a></h3> <ul> <li>Fields: firstName*, lastName*, licenseNumber*, phone*, email (email keyboard, <code>IsEmail</code> - <code>create-driver.dto.ts:23-24</code>), licenseExpiry (date, <code>IsDateString</code> - <code>:28-29</code>), address, emergencyContact, joinedAt (date, <code>:43-44</code>), notes.</li> <li>Update: <code>UpdateDriverDto</code> = PartialType (<code>update-driver.dto.ts:4</code>).</li> </ul> <h3 id="wire-contract-6"><a class="header" href="#wire-contract-6">Wire contract</a></h3> <ul> <li>Create: <code>POST /transport/drivers</code> (<code>transport.controller.ts:90-94</code>).</li> <li>Update: <code>PATCH /transport/drivers/:id</code> (<code>transport.controller.ts:108-112</code>).</li> <li>Dates converted server-side (<code>transport.service.ts:191-197, 228-234</code>).</li> <li>409 inline: "Driver with license "<n>" already exists." / "Driver with phone "<n>" already exists." (<code>transport.service.ts:181-183, 187-189</code>).</li> </ul> <hr /> <h2 id="8-route-list-transportroutes"><a class="header" href="#8-route-list-transportroutes">8. Route List (<code>/transport/routes</code>)</a></h2> <h3 id="layout-31"><a class="header" href="#layout-31">Layout</a></h3> <ul> <li>AppBar: "Routes" + FAB "New route".</li> <li><code>AppListTile</code>: title name, subtitle "startPoint -> endPoint", trailing "N stops" chip + status badge (active/inactive) + <code>AppMenu</code> (Edit / Delete).</li> <li><code>AppPagination</code> footer.</li> </ul> <h3 id="wire-contract-7"><a class="header" href="#wire-contract-7">Wire contract</a></h3> <ul> <li><code>GET /transport/routes?page=1&limit=20</code> (<code>transport.controller.ts:66-70</code>); sort <code>name</code> asc (<code>transport.service.ts:142</code>).</li> <li>Item: <code>{ _id, name, startPoint, endPoint, stops: [{name, order}], vehicleId?, driverId?, status, estimatedDuration?, notes?, ...base }</code> (<code>route.schema.ts:14-39</code>).</li> </ul> <h3 id="interactions-7"><a class="header" href="#interactions-7">Interactions</a></h3> <ul> <li>Delete 409: "Route has active student assignments." (<code>transport.service.ts:170</code>) -> dialog offering "View students" (assignments search by route is not exposed; offer student-side lookup instead) or dismiss.</li> </ul> <hr /> <h2 id="9-route-detail-transportroutesid"><a class="header" href="#9-route-detail-transportroutesid">9. Route Detail (<code>/transport/routes/:id</code>)</a></h2> <h3 id="layout-32"><a class="header" href="#layout-32">Layout</a></h3> <ul> <li>AppBar: route name; actions: Edit, Delete, "Assign students".</li> <li><code>AppCard</code> "Route": startPoint -> endPoint, status badge, estimatedDuration, notes.</li> <li><code>AppCard</code> "Stops": numbered list 1..N from <code>stops</code> ordered by <code>order</code> (<code>route.schema.ts:23-24</code>); start/end pinned as stop 0 / last for clarity (client presentation).</li> <li><code>AppCard</code> "Resources": assigned vehicle (plateNumber via ref lookup) and driver (name); ids arrive raw (<code>route.schema.ts:26-30</code>) - resolve client-side by fetching detail or batch list; unresolved -> "Not assigned".</li> </ul> <h3 id="wire-contract-8"><a class="header" href="#wire-contract-8">Wire contract</a></h3> <ul> <li><code>GET /transport/routes/:id</code> (<code>transport.controller.ts:72-76</code>); 404 "Route not found." (<code>transport.service.ts:151</code>).</li> <li>Note: no populated payload - <code>vehicleId</code>/<code>driverId</code> are ObjectIds; resolution is a client concern (2 extra GETs or list cache).</li> </ul> <h3 id="interactions-8"><a class="header" href="#interactions-8">Interactions</a></h3> <ul> <li>Edit -> editor (§10). Delete -> confirm -> <code>DELETE /transport/routes/:id</code> (<code>transport.controller.ts:84-88</code>).</li> </ul> <hr /> <h2 id="10-route-editor-w-stops-create--update"><a class="header" href="#10-route-editor-w-stops-create--update">10. Route Editor w/ stops (create & update)</a></h2> <h3 id="layout-33"><a class="header" href="#layout-33">Layout</a></h3> <ul> <li>Section A "Basics": name*, startPoint*, endPoint*, estimatedDuration (minutes, number), notes.</li> <li>Section B "Stops": <code>RouteStopEditor</code> - rows "Stop name" + up/down arrows and drag handles; add-stop field; stops carry <code>order</code> 1..N client-maintained (<code>route.schema.ts:23-24</code>); max ~20 stops client guard (no server limit).</li> <li>Section C "Resources": vehicle picker (searchable dropdown from vehicle list cache), driver picker (same); "Clear" sets field absent on submit.</li> <li>Save button: Create -> <code>POST /transport/routes</code> (<code>transport.controller.ts:60-64</code>); Edit -> <code>PATCH /transport/routes/:id</code> (<code>transport.controller.ts:78-82</code>).</li> </ul> <h3 id="wire-contract--validation"><a class="header" href="#wire-contract--validation">Wire contract / validation</a></h3> <ul> <li>DTO: <code>stops</code> array of <code>{name: string, order: number}</code> via <code>ValidateNested</code> (<code>create-route.dto.ts:11-19, 34-39</code>); <code>vehicleId</code>/<code>driverId</code> plain strings, converted to ObjectId server-side (<code>transport.service.ts:117-119, 159-161</code>).</li> <li>409 duplicate name on create: "Route "<name>" already exists." (<code>transport.service.ts:115</code>). On update: name conflict surfaces as 409 from unique index <code>{tenantId, name}</code> (<code>route.schema.ts:44</code>).</li> </ul> <h3 id="states-19"><a class="header" href="#states-19">States</a></h3> <ul> <li>Draft persisted locally (prefs) while editor open; reorder animation <code>m-base</code> (<a href="transport/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>).</li> </ul> <h3 id="a11y-11"><a class="header" href="#a11y-11">a11y</a></h3> <ul> <li>Reorder actions exposed as explicit up/down buttons (drag optional); every stop row semantics label "Stop 2: Market Road".</li> </ul> <hr /> <h2 id="11-assign-student-to-route-bottom-sheet--dialog"><a class="header" href="#11-assign-student-to-route-bottom-sheet--dialog">11. Assign Student to Route (bottom sheet / dialog)</a></h2> <h3 id="layout-34"><a class="header" href="#layout-34">Layout</a></h3> <ul> <li>Header "Assign student to route".</li> <li>Step 1: route picker (dropdown; label shows name + stop count).</li> <li>Step 2: student picker (search-as-you-type; shows admissionNumber; filter by <code>transportRequired</code> toggle default on - <code>student.schema.ts:53-54</code>).</li> <li>Step 3: shift segmented control morning / evening / both* (<code>assign-route.dto.ts:13-15</code>; <code>IsEnum(['morning','evening','both'])</code>).</li> <li>Optional: stopName (text, or pick from route stops when route selected), notes.</li> <li>Submit "Assign".</li> </ul> <h3 id="wire-contract-9"><a class="header" href="#wire-contract-9">Wire contract</a></h3> <ul> <li><code>POST /transport/assign</code> body <code>{ routeId, studentId, shift, stopName?, notes? }</code> (<code>assign-route.dto.ts:4-26</code>; <code>transport.controller.ts:120-124</code>).</li> <li>Server sets <code>assignedAt: new Date()</code> and default status <code>active</code> (<code>transport.service.ts:259-266</code>, <code>route-assignment.schema.ts:23-28</code>).</li> </ul> <h3 id="states--errors"><a class="header" href="#states--errors">States & errors</a></h3> <ul> <li>409 "Student already assigned to this route." (<code>transport.service.ts:257</code>) -> inline warning + "View existing" opens the student's assignment screen.</li> <li>Success -> <code>StudentRouteAssigned</code> event (<code>transport.service.ts:267-278</code>), snackbar, sheet closes.</li> </ul> <h3 id="note-client-side-only"><a class="header" href="#note-client-side-only">Note (client-side only)</a></h3> <ul> <li>Capacity overflow guard is NOT server-enforced (<code>transport.service.ts:251-266</code> does no capacity check) - warn client-side when route's assigned-count (derived from student-side lookups) reaches vehicle capacity (<code>vehicle.schema.ts:27-28</code>); documented as QA item (<a href="transport/14_QA_Checklist.html">14</a> §3).</li> </ul> <hr /> <h2 id="12-student-route-assignments-transportstudentsstudentidtransport"><a class="header" href="#12-student-route-assignments-transportstudentsstudentidtransport">12. Student Route Assignments (<code>/transport/students/:studentId/transport</code>)</a></h2> <h3 id="layout-35"><a class="header" href="#layout-35">Layout</a></h3> <ul> <li>AppBar: student name; action "Assign" (opens §11 prefilled with student).</li> <li>List: <code>AppListTile</code> per assignment - title route name (populated), subtitle "shift + stopName", status badge, menu Remove.</li> <li><code>AppEmptyState</code>: "No route assigned" + CTA.</li> </ul> <h3 id="wire-contract-10"><a class="header" href="#wire-contract-10">Wire contract</a></h3> <ul> <li><code>GET /transport/assignments/:studentId</code> -> array of assignments with <code>routeId</code> populated (<code>transport.controller.ts:126-130</code>, <code>route-assignment.repository.ts:20-24</code>).</li> <li>Remove: <code>DELETE /transport/assignments/:id</code> (<code>transport.controller.ts:132-136</code>); 404 "Assignment not found." -> row already gone, refetch (<code>transport.service.ts:289-290</code>).</li> </ul> <h3 id="interactions-9"><a class="header" href="#interactions-9">Interactions</a></h3> <ul> <li>Remove -> confirm dialog (shows route + shift) -> optimistic removal with rollback on error.</li> </ul> <hr /> <h2 id="13-live-tracking-transportlive-planned"><a class="header" href="#13-live-tracking-transportlive-planned">13. Live Tracking (<code>/transport/live</code>) <code>(planned)</code></a></h2> <ul> <li>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.</li> </ul> <h2 id="14-bus-attendance-transportattendance-planned"><a class="header" href="#14-bus-attendance-transportattendance-planned">14. Bus Attendance (<code>/transport/attendance</code>) <code>(planned)</code></a></h2> <ul> <li>Route/shift picker -> boarding list; QR boarding <code>(forward-looking)</code>.</li> </ul> <h2 id="15-transport-fees-transportfees-planned"><a class="header" href="#15-transport-fees-transportfees-planned">15. Transport Fees (<code>/transport/fees</code>) <code>(planned)</code></a></h2> <ul> <li>Route fee definition + per-student calc (IMPLEMENTATION_PLAN.md:229); fee line item exists in fee structures (see <code>design-docs/fees</code>).</li> </ul> <hr /> <h2 id="cross-screen-rules-1"><a class="header" href="#cross-screen-rules-1">Cross-screen rules</a></h2> <ul> <li>All destructive actions: <code>AppDialog</code> confirm, mention consequence (409 guards in <code>transport.service.ts:94-99, 168-171, 241-246</code>).</li> <li>All create actions emit domain events (<code>transport.service.ts:47-57, 121-128, 267-278</code>) - client treats event as completion signal, response is the doc.</li> <li>Pagination: page/limit state per list cubit; <code>meta</code> from envelope (<a href="transport/../00-shared/06_State_Management.html">00-shared/06</a> §3.2; <code>buildPaginationMeta</code> - <code>transport.service.ts:75</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="07---component-library-transport-module"><a class="header" href="#07---component-library-transport-module">07 - Component Library (Transport Module)</a></h1> <ul> <li><a href="transport/07_Component_Library.html#1-stattile">1. StatTile</a></li> <li><a href="transport/07_Component_Library.html#2-entitystatusbadge">2. EntityStatusBadge</a></li> <li><a href="transport/07_Component_Library.html#3-shiftpicker">3. ShiftPicker</a></li> <li><a href="transport/07_Component_Library.html#4-routestopeditor">4. RouteStopEditor</a></li> <li><a href="transport/07_Component_Library.html#5-resourcepicker-vehicle--driver">5. ResourcePicker (vehicle / driver)</a></li> <li><a href="transport/07_Component_Library.html#6-assignmenttile">6. AssignmentTile</a></li> <li><a href="transport/07_Component_Library.html#7-dependencyguarddialog">7. DependencyGuardDialog</a></li> <li><a href="transport/07_Component_Library.html#8-conflictfielderror">8. ConflictFieldError</a></li> <li><a href="transport/07_Component_Library.html#8b-notfoundstate">8b. NotFoundState</a></li> <li><a href="transport/07_Component_Library.html#composition-map-screen---components">Composition map (screen -> components)</a></li> </ul> <blockquote> <p>Module-specific components. Shared primitives (<code>AppTextField</code>, <code>AppButton</code>, <code>AppSnackbar</code>, <code>AppCard</code>, <code>AppListTile</code>, <code>AppBottomSheet</code>, <code>AppDialog</code>, <code>AppMenu</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppOfflineBanner</code>, <code>AppFAB</code>, <code>AppChips</code>, <code>AppBadge</code>, <code>AppAvatar</code>, <code>AppErrorState</code>, <code>AppPagination</code>) come from <a href="transport/../00-shared/03_Component_Library.html">00-shared/03</a> and are NOT reproduced here.</p> </blockquote> <hr /> <h2 id="1-stattile"><a class="header" href="#1-stattile">1. StatTile</a></h2> <p>Overview hub tile: count + label + navigate. Props: <code>label</code>, <code>count</code>, <code>icon</code>, <code>onTap</code>, <code>loading</code>, <code>error</code>, <code>onRetry</code>. Count source: <code>meta.totalItems</code> from list calls (<code>transport.service.ts:75, 146, 215</code>). Loading -> <code>AppSkeleton</code> block; error -> inline retry (per-tile independence, <a href="transport/06_Screen_Specifications.html">06</a> §1).</p> <h2 id="2-entitystatusbadge"><a class="header" href="#2-entitystatusbadge">2. EntityStatusBadge</a></h2> <p>Badge mapping per entity (source: schema enums):</p> <div class="table-wrapper"><table><thead><tr><th>Entity</th><th>Status</th><th>Badge tone</th></tr></thead><tbody> <tr><td>Vehicle (<code>vehicle.schema.ts:13-17</code>)</td><td>active</td><td>success</td></tr> <tr><td></td><td>maintenance</td><td>warning</td></tr> <tr><td></td><td>inactive</td><td>neutral</td></tr> <tr><td>Driver (<code>driver.schema.ts:7-11</code>)</td><td>active</td><td>success</td></tr> <tr><td></td><td>on_leave</td><td>warning</td></tr> <tr><td></td><td>inactive</td><td>neutral</td></tr> <tr><td>Route (<code>route.schema.ts:7-10</code>)</td><td>active</td><td>success</td></tr> <tr><td></td><td>inactive</td><td>neutral</td></tr> <tr><td>Assignment (<code>route-assignment.schema.ts:7-10</code>)</td><td>active</td><td>success</td></tr> <tr><td></td><td>inactive</td><td>neutral</td></tr> </tbody></table> </div> <h2 id="3-shiftpicker"><a class="header" href="#3-shiftpicker">3. ShiftPicker</a></h2> <p>Segmented control for <code>morning | evening | both</code> - hard <code>IsEnum</code> values from <code>assign-route.dto.ts:13-15</code>. Displays selected shift chip on assignment tiles ("AM", "PM", "AM+PM").</p> <h2 id="4-routestopeditor"><a class="header" href="#4-routestopeditor">4. RouteStopEditor</a></h2> <p>Ordered stop list editor used in route create/update (<a href="transport/06_Screen_Specifications.html">06</a> §10):</p> <ul> <li>Rows: drag handle + text field (stop name) + up/down buttons + delete.</li> <li>Ordering: client maintains <code>order: 1..N</code>; submitted as array of <code>{name, order}</code> (<code>create-route.dto.ts:11-19</code>, <code>route.schema.ts:23-24</code>).</li> <li>API: <code>onChanged(List<RouteStopDraft>)</code>; dirty state triggers save-enable.</li> <li>a11y: explicit up/down buttons; drag is enhancement.</li> <li>Cap: 20 stops client-side (no server limit).</li> </ul> <h2 id="5-resourcepicker-vehicle--driver"><a class="header" href="#5-resourcepicker-vehicle--driver">5. ResourcePicker (vehicle / driver)</a></h2> <p>Searchable dropdown over cached list data (vehicle/driver list from <code>GET /transport/vehicles|drivers</code>, <code>transport.controller.ts:36-40, 96-100</code>):</p> <ul> <li>Displays <code>plateNumber</code> / <code>firstName lastName</code> (+ subtitle <code>model</code> / <code>licenseNumber</code>).</li> <li>Emits selected <code>id</code> or null (clear); on submit, null -> field omitted (<code>create-route.dto.ts:41-49</code>).</li> <li>Empty cache -> fetches page 1; refresh action.</li> </ul> <h2 id="6-assignmenttile"><a class="header" href="#6-assignmenttile">6. AssignmentTile</a></h2> <p><code>AppListTile</code> wrapper for an assignment (used in <a href="transport/06_Screen_Specifications.html">06</a> §12): title = populated route name, subtitle = <code>ShiftPicker</code> chip + stopName, trailing status badge + <code>AppMenu</code> (Remove). Data: <code>RouteAssignment</code> with <code>routeId</code> populated (<code>route-assignment.repository.ts:20-24</code>).</p> <h2 id="7-dependencyguarddialog"><a class="header" href="#7-dependencyguarddialog">7. DependencyGuardDialog</a></h2> <p>Delete-confirmation variant for 409 conflicts (<code>transport.service.ts:94-99, 168-171, 241-246</code>): 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).</p> <h2 id="8-conflictfielderror"><a class="header" href="#8-conflictfielderror">8. ConflictFieldError</a></h2> <p>Inline field error for duplicate checks, per source:</p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Source guard</th><th>Server message</th></tr></thead><tbody> <tr><td>vehicle.plateNumber</td><td><code>transport.service.ts:38-45</code></td><td><code>Vehicle "<plate>" already exists.</code></td></tr> <tr><td>route.name</td><td><code>transport.service.ts:113-116</code></td><td><code>Route "<name>" already exists.</code></td></tr> <tr><td>driver.licenseNumber</td><td><code>transport.service.ts:177-184</code></td><td><code>Driver with license "<n>" already exists.</code></td></tr> <tr><td>driver.phone</td><td><code>transport.service.ts:185-190</code></td><td><code>Driver with phone "<p>" already exists.</code></td></tr> <tr><td>assign (route+student)</td><td><code>transport.service.ts:252-258</code></td><td><code>Student already assigned to this route.</code></td></tr> </tbody></table> </div> <p>Props: <code>field</code>, <code>message</code>, <code>action?</code> (e.g. "View existing" for assignment).</p> <h2 id="8b-notfoundstate"><a class="header" href="#8b-notfoundstate">8b. NotFoundState</a></h2> <p>Detail-screen 404 rendering (<code>transport.service.ts:80, 151, 220</code>): icon, "Not found", back button; distinct from generic <code>AppErrorState</code> copy.</p> <hr /> <h2 id="composition-map-screen---components"><a class="header" href="#composition-map-screen---components">Composition map (screen -> components)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen (<a href="transport/05_Screen_Inventory.html">05</a>)</th><th>Components</th></tr></thead><tbody> <tr><td>1 Overview</td><td>StatTile x3, AppCard, AppListTile, AppFAB</td></tr> <tr><td>2-3 Vehicle list/detail</td><td>EntityStatusBadge, DependencyGuardDialog, AppPagination</td></tr> <tr><td>4 Vehicle form</td><td>ConflictFieldError, AppTextField/DateField</td></tr> <tr><td>5-6 Driver list/detail</td><td>EntityStatusBadge, AppAvatar, DependencyGuardDialog</td></tr> <tr><td>7 Driver form</td><td>ConflictFieldError, DateField</td></tr> <tr><td>8-9 Route list/detail</td><td>EntityStatusBadge, ResourcePicker (read)</td></tr> <tr><td>10 Route editor</td><td>RouteStopEditor, ResourcePicker, ConflictFieldError</td></tr> <tr><td>11 Assign sheet</td><td>ShiftPicker, ResourcePicker (route), StudentPicker (students module), ConflictFieldError</td></tr> <tr><td>12 Student assignments</td><td>AssignmentTile, ShiftPicker, AppEmptyState</td></tr> <tr><td>13-15 planned</td><td>map/attendance/fee components <code>(planned)</code></td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="08---form-specifications-transport-module"><a class="header" href="#08---form-specifications-transport-module">08 - Form Specifications (Transport Module)</a></h1> <ul> <li><a href="transport/08_Form_Specifications.html#1-vehicle-create-form-create-vehicledtots5-42">1. Vehicle Create Form (<code>create-vehicle.dto.ts:5-42</code>)</a></li> <li><a href="transport/08_Form_Specifications.html#2-driver-create-form-create-driverdtots4-50">2. Driver Create Form (<code>create-driver.dto.ts:4-50</code>)</a></li> <li><a href="transport/08_Form_Specifications.html#3-route-create-form-create-routedtots21-60">3. Route Create Form (<code>create-route.dto.ts:21-60</code>)</a></li> <li><a href="transport/08_Form_Specifications.html#4-assign-student-to-route-assign-routedtots4-26">4. Assign Student to Route (<code>assign-route.dto.ts:4-26</code>)</a></li> <li><a href="transport/08_Form_Specifications.html#5-form-behaviour-shared">5. Form behaviour (shared)</a></li> </ul> <blockquote> <p>All create/update/assign forms. Validation mirrors DTOs exactly (<code>create-vehicle.dto.ts</code>, <code>create-driver.dto.ts</code>, <code>create-route.dto.ts</code>, <code>assign-route.dto.ts</code>; updates are PartialTypes of the creates - <code>update-*.dto.ts</code>). Server is the source of truth; client validates for latency, server for correctness.</p> </blockquote> <p>Legend: <code>*</code> required. All forms: keyboard-avoidance, autofill where noted, submit button disabled until valid, loading state on submit.</p> <hr /> <h2 id="1-vehicle-create-form-create-vehicledtots5-42"><a class="header" href="#1-vehicle-create-form-create-vehicledtots5-42">1. Vehicle Create Form (<code>create-vehicle.dto.ts:5-42</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type</th><th>Required</th><th>Validation (DTO)</th><th>Notes</th></tr></thead><tbody> <tr><td>plateNumber</td><td>text</td><td>*</td><td><code>IsString</code> (<code>:7-8</code>)</td><td>trim; unique/tenant (<code>vehicle.schema.ts:50</code>); 409 inline (<code>transport.service.ts:38-45</code>)</td></tr> <tr><td>model</td><td>text</td><td>*</td><td><code>IsString</code> (<code>:11-12</code>)</td><td></td></tr> <tr><td>capacity</td><td>number</td><td>*</td><td><code>IsNumber @Min(1)</code> (<code>:15-17</code>)</td><td>step 1; warn if > 80 (client heuristic)</td></tr> <tr><td>type</td><td>segmented</td><td>*</td><td><code>IsEnum(VehicleType)</code> (<code>:19-21</code>)</td><td>bus / van / car (<code>vehicle.schema.ts:7-11</code>)</td></tr> <tr><td>year</td><td>number</td><td>-</td><td><code>IsNumber @IsOptional</code> (<code>:24-26</code>)</td><td></td></tr> <tr><td>color</td><td>text</td><td>-</td><td><code>IsString @IsOptional</code> (<code>:28-31</code>)</td><td></td></tr> <tr><td>insuranceExpiry</td><td>date</td><td>-</td><td>string <code>@IsOptional</code> (<code>:33-36</code>)</td><td>note: schema prop declared <code>String</code>, typed <code>Date</code> (<code>vehicle.schema.ts:42-43</code>) - send ISO string</td></tr> <tr><td>notes</td><td>multiline</td><td>-</td><td><code>IsString @IsOptional</code> (<code>:38-41</code>)</td><td></td></tr> </tbody></table> </div> <p>Submit: <code>POST /transport/vehicles</code> (<code>transport.controller.ts:30-34</code>). Update: same fields all optional (<code>update-vehicle.dto.ts:4</code>), <code>PATCH /transport/vehicles/:id</code> (<code>transport.controller.ts:48-52</code>).</p> <h2 id="2-driver-create-form-create-driverdtots4-50"><a class="header" href="#2-driver-create-form-create-driverdtots4-50">2. Driver Create Form (<code>create-driver.dto.ts:4-50</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type</th><th>Required</th><th>Validation (DTO)</th><th>Notes</th></tr></thead><tbody> <tr><td>firstName</td><td>text</td><td>*</td><td><code>IsString</code> (<code>:6-8</code>)</td><td>autofill <code>given-name</code></td></tr> <tr><td>lastName</td><td>text</td><td>*</td><td><code>IsString</code> (<code>:10-12</code>)</td><td>autofill <code>family-name</code></td></tr> <tr><td>licenseNumber</td><td>text</td><td>*</td><td><code>IsString</code> (<code>:14-16</code>)</td><td>unique/tenant (<code>driver.schema.ts:50</code>); 409 inline (<code>transport.service.ts:177-184</code>)</td></tr> <tr><td>phone</td><td>tel</td><td>*</td><td><code>IsString</code> (<code>:18-20</code>)</td><td>unique/tenant (<code>driver.schema.ts:51</code>); 409 inline (<code>:185-190</code>)</td></tr> <tr><td>email</td><td>email</td><td>-</td><td><code>IsEmail @IsOptional</code> (<code>:22-24</code>)</td><td>autofill <code>email</code></td></tr> <tr><td>licenseExpiry</td><td>date</td><td>-</td><td><code>IsDateString @IsOptional</code> (<code>:27-29</code>)</td><td>parsed to Date server-side (<code>transport.service.ts:193-196</code>)</td></tr> <tr><td>address</td><td>text</td><td>-</td><td><code>IsString @IsOptional</code> (<code>:31-34</code>)</td><td>autofill <code>street-address</code></td></tr> <tr><td>emergencyContact</td><td>text</td><td>-</td><td><code>IsString @IsOptional</code> (<code>:36-39</code>)</td><td>tel keyboard</td></tr> <tr><td>joinedAt</td><td>date</td><td>-</td><td><code>IsDateString @IsOptional</code> (<code>:41-44</code>)</td><td>parsed to Date (<code>transport.service.ts:192-193</code>)</td></tr> <tr><td>notes</td><td>multiline</td><td>-</td><td><code>IsString @IsOptional</code> (<code>:46-49</code>)</td><td></td></tr> </tbody></table> </div> <p>Submit: <code>POST /transport/drivers</code> (<code>transport.controller.ts:90-94</code>). Update: <code>PATCH /transport/drivers/:id</code> (<code>transport.controller.ts:108-112</code>), <code>update-driver.dto.ts:4</code>.</p> <h2 id="3-route-create-form-create-routedtots21-60"><a class="header" href="#3-route-create-form-create-routedtots21-60">3. Route Create Form (<code>create-route.dto.ts:21-60</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type</th><th>Required</th><th>Validation (DTO)</th><th>Notes</th></tr></thead><tbody> <tr><td>name</td><td>text</td><td>*</td><td><code>IsString</code> (<code>:23-25</code>)</td><td>unique/tenant (<code>route.schema.ts:44</code>); 409 inline (<code>transport.service.ts:113-116</code>)</td></tr> <tr><td>startPoint</td><td>text</td><td>*</td><td><code>IsString</code> (<code>:27-29</code>)</td><td></td></tr> <tr><td>endPoint</td><td>text</td><td>*</td><td><code>IsString</code> (<code>:31-33</code>)</td><td></td></tr> <tr><td>stops</td><td><code>RouteStopEditor</code></td><td>-</td><td><code>IsArray @ValidateNested({each}) @Type(RouteStopDto)</code> (<code>:34-39</code>)</td><td>items <code>{name: IsString, order: IsNumber}</code> (<code>:11-19</code>)</td></tr> <tr><td>vehicleId</td><td>ResourcePicker</td><td>-</td><td><code>IsString @IsOptional</code> (<code>:41-44</code>)</td><td>converted to ObjectId (<code>transport.service.ts:117-119</code>)</td></tr> <tr><td>driverId</td><td>ResourcePicker</td><td>-</td><td><code>IsString @IsOptional</code> (<code>:46-49</code>)</td><td>converted to ObjectId (<code>transport.service.ts:119</code>)</td></tr> <tr><td>estimatedDuration</td><td>number</td><td>-</td><td><code>IsNumber @IsOptional</code> (<code>:51-54</code>)</td><td>minutes</td></tr> <tr><td>notes</td><td>multiline</td><td>-</td><td><code>IsString @IsOptional</code> (<code>:56-59</code>)</td><td></td></tr> </tbody></table> </div> <p>Submit: <code>POST /transport/routes</code> (<code>transport.controller.ts:60-64</code>). Update: <code>PATCH /transport/routes/:id</code> (<code>transport.controller.ts:78-82</code>), <code>update-route.dto.ts:4</code>; ObjectId conversion also on update (<code>transport.service.ts:159-161</code>).</p> <h2 id="4-assign-student-to-route-assign-routedtots4-26"><a class="header" href="#4-assign-student-to-route-assign-routedtots4-26">4. Assign Student to Route (<code>assign-route.dto.ts:4-26</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type</th><th>Required</th><th>Validation (DTO)</th><th>Notes</th></tr></thead><tbody> <tr><td>routeId</td><td>ResourcePicker</td><td>*</td><td><code>IsString</code> (<code>:6-8</code>)</td><td></td></tr> <tr><td>studentId</td><td>StudentPicker</td><td>*</td><td><code>IsString</code> (<code>:10-12</code>)</td><td>from students module; filter <code>transportRequired</code> toggle (<code>student.schema.ts:53-54</code>)</td></tr> <tr><td>shift</td><td>ShiftPicker</td><td>*</td><td><code>IsEnum(['morning','evening','both'] as const)</code> (<code>:13-15</code>)</td><td></td></tr> <tr><td>stopName</td><td>text</td><td>-</td><td><code>IsString @IsOptional</code> (<code>:17-20</code>)</td><td>suggest from route stops</td></tr> <tr><td>notes</td><td>multiline</td><td>-</td><td><code>IsString @IsOptional</code> (<code>:22-25</code>)</td><td></td></tr> </tbody></table> </div> <p>Submit: <code>POST /transport/assign</code> (<code>transport.controller.ts:120-124</code>). Server sets <code>assignedAt = now</code>, status default <code>active</code> (<code>transport.service.ts:259-266</code>, <code>route-assignment.schema.ts:23-28</code>). 409 duplicate (route, student) inline (<code>transport.service.ts:252-258</code>).</p> <h2 id="5-form-behaviour-shared"><a class="header" href="#5-form-behaviour-shared">5. Form behaviour (shared)</a></h2> <ul> <li><strong>409 conflict handling</strong>: <code>ConflictFieldError</code> under the offending field with server message verbatim (<a href="transport/07_Component_Library.html">07</a> §8); never silently retry.</li> <li><strong>Debounce</strong>: search-as-you-type fields (student picker) 300 ms; single-flight requests (<a href="transport/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a>).</li> <li><strong>Dirty tracking</strong>: updates send only touched fields (PartialType semantics, <code>update-*.dto.ts</code>); unchanged optional dates re-sent as-is to avoid accidental clearing (<code>transport.service.ts:159-161, 228-234</code> re-parse only when present).</li> <li><strong>Offline</strong>: submit blocked with <code>AppOfflineBanner</code>; no queue (no offline mutation contract).</li> <li><strong>Success</strong>: navigate to detail + snackbar; events <code>VehicleCreated</code>, <code>RouteCreated</code> fire server-side (<code>transport.service.ts:47-57, 121-128</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="09---user-behaviour-transport-module"><a class="header" href="#09---user-behaviour-transport-module">09 - User Behaviour (Transport Module)</a></h1> <ul> <li><a href="transport/09_User_Behaviour.html#1-defaults">1. Defaults</a></li> <li><a href="transport/09_User_Behaviour.html#2-read-behaviour">2. Read behaviour</a></li> <li><a href="transport/09_User_Behaviour.html#3-conflict-behaviour-users-hit-these-constantly">3. Conflict behaviour (users hit these constantly)</a></li> <li><a href="transport/09_User_Behaviour.html#4-deletion-mental-model">4. Deletion mental model</a></li> <li><a href="transport/09_User_Behaviour.html#5-status-lifecycle-expectations">5. Status lifecycle expectations</a></li> <li><a href="transport/09_User_Behaviour.html#6-search-expectations-gap">6. Search expectations (gap)</a></li> <li><a href="transport/09_User_Behaviour.html#7-multi-tenant-behaviour">7. Multi-tenant behaviour</a></li> <li><a href="transport/09_User_Behaviour.html#8-permission-aware-behaviour">8. Permission-aware behaviour</a></li> </ul> <blockquote> <p>Behaviour patterns, defaults and expectations for transport users. Wherever server behaviour constrains UX, the source is cited and the client adapts.</p> </blockquote> <hr /> <h2 id="1-defaults"><a class="header" href="#1-defaults">1. Defaults</a></h2> <div class="table-wrapper"><table><thead><tr><th>Context</th><th>Default</th><th>Source</th></tr></thead><tbody> <tr><td>List page size</td><td>20, page 1</td><td><code>transport.controller.ts:38, 68, 98</code></td></tr> <tr><td>List sort</td><td>vehicles <code>plateNumber</code> asc; routes <code>name</code> asc; drivers <code>firstName</code> asc</td><td><code>transport.service.ts:71, 142, 211</code></td></tr> <tr><td>Vehicle status on create</td><td><code>active</code></td><td><code>vehicle.schema.ts:33-34</code></td></tr> <tr><td>Driver status on create</td><td><code>active</code></td><td><code>driver.schema.ts:30-31</code></td></tr> <tr><td>Route status on create</td><td><code>active</code></td><td><code>route.schema.ts:32-33</code></td></tr> <tr><td>Assignment status</td><td><code>active</code>; <code>assignedAt = now</code></td><td><code>route-assignment.schema.ts:23-28</code>, <code>transport.service.ts:264</code></td></tr> <tr><td>Assignment stops</td><td><code>[]</code></td><td><code>route.schema.ts:23-24</code></td></tr> <tr><td>New-entity nav</td><td>-> detail screen</td><td>conventional</td></tr> </tbody></table> </div> <h2 id="2-read-behaviour"><a class="header" href="#2-read-behaviour">2. Read behaviour</a></h2> <ul> <li><strong>List -> detail</strong>: taps on rows; users expect full info on detail. ObjectId refs (<code>route.schema.ts:26-30</code>) 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).</li> <li><strong>Pagination</strong>: users page rather than scroll infinitely (<a href="transport/../00-shared/01_Product_Overview.html">00-shared/01</a>); footer shows <code>meta</code> position (<code>transport.service.ts:75</code>).</li> <li><strong>Refresh</strong>: pull-to-refresh re-fetches; sorted server-side - client never re-sorts.</li> </ul> <h2 id="3-conflict-behaviour-users-hit-these-constantly"><a class="header" href="#3-conflict-behaviour-users-hit-these-constantly">3. Conflict behaviour (users hit these constantly)</a></h2> <ol> <li><strong>Duplicate plate on vehicle create</strong> - user corrects plate; expects inline error, not a dialog (<code>transport.service.ts:38-45</code>).</li> <li><strong>Duplicate license/phone on driver create</strong> - two independent uniqueness rules (<code>driver.schema.ts:50-51</code>); error must say WHICH field conflicted (<code>transport.service.ts:177-190</code>).</li> <li><strong>Duplicate route name</strong> - rename or accept 409 (<code>transport.service.ts:113-116</code>).</li> <li><strong>Student already on route</strong> - user intent is usually "change shift or stop" (schema allows a second assignment for a different route - uniqueness is per route+student, <code>route-assignment.schema.ts:42-45</code>); the assign sheet must offer "View existing / edit instead" (<a href="transport/06_Screen_Specifications.html">06</a> §11).</li> <li><strong>Delete blocked by reference</strong> - users understand dependencies; dialog explains "vehicle is assigned to a route" and navigates to the blocker (<code>transport.service.ts:94-99, 168-171, 241-246</code>).</li> </ol> <h2 id="4-deletion-mental-model"><a class="header" href="#4-deletion-mental-model">4. Deletion mental model</a></h2> <ul> <li>Deletes are <strong>soft</strong> (<code>base.repository.ts:68-74</code>) - nothing is permanently gone; list queries exclude deleted (<code>isDeleted: false</code>, <code>base.repository.ts:20-30</code>). UI copy: "Remove" not "Delete forever".</li> <li>A removed vehicle/driver/route reappears only via re-create (no restore endpoint exists - gap).</li> </ul> <h2 id="5-status-lifecycle-expectations"><a class="header" href="#5-status-lifecycle-expectations">5. Status lifecycle expectations</a></h2> <ul> <li>Vehicle: <code>active -> maintenance -> active</code> is the common loop (<code>vehicle.schema.ts:13-17</code>); maintenance vehicles should be prevented from new route assignment client-side (no server check - QA item <a href="transport/14_QA_Checklist.html">14</a> §3).</li> <li>Driver: <code>active -> on_leave -> active</code> (<code>driver.schema.ts:7-11</code>); expired license warning is client-side heuristic (no server validation of <code>licenseExpiry</code>).</li> <li>Assignment: no status-flip endpoint exists (<code>route-assignment.schema.ts:23-28</code> has the field; <code>DELETE /assignments/:id</code> is the only mutation) - UI offers Remove only; "mark inactive" is <code>(planned)</code>.</li> </ul> <h2 id="6-search-expectations-gap"><a class="header" href="#6-search-expectations-gap">6. Search expectations (gap)</a></h2> <ul> <li>Users will type to filter lists (plate, name, phone). Backend lists accept only <code>page</code>/<code>limit</code> (<code>transport.controller.ts:36-40, 66-70, 96-100</code>). Client: local filter of loaded pages + clear hint; server-side search is <code>(planned)</code> (flagged in <a href="transport/01_Product_Overview.html">01</a> §5).</li> </ul> <h2 id="7-multi-tenant-behaviour"><a class="header" href="#7-multi-tenant-behaviour">7. Multi-tenant behaviour</a></h2> <ul> <li>Every document is tenant-scoped and never user-controlled (<code>base.repository.ts:20-36</code>; <code>AGENTS.md</code> conventions) - no tenant picker, no cross-tenant results, ever.</li> </ul> <h2 id="8-permission-aware-behaviour"><a class="header" href="#8-permission-aware-behaviour">8. Permission-aware behaviour</a></h2> <ul> <li>Surfaces gate on <code>transport.vehicle.*</code>, <code>transport.route.*</code>, <code>transport.driver.*</code>, <code>transport.assign</code> (<code>permissions.constants.ts:62-74</code>).</li> <li>Read-only roles see lists/detail, no FAB/menu actions; 403 -> permission copy, never "error" (<a href="transport/06_Screen_Specifications.html">06</a> §0).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="10---interaction-specification-transport-module"><a class="header" href="#10---interaction-specification-transport-module">10 - Interaction Specification (Transport Module)</a></h1> <ul> <li><a href="transport/10_Interaction_Specification.html#1-navigation">1. Navigation</a></li> <li><a href="transport/10_Interaction_Specification.html#2-list-interactions">2. List interactions</a></li> <li><a href="transport/10_Interaction_Specification.html#3-delete-flow-all-three-entities--assignment">3. Delete flow (all three entities + assignment)</a></li> <li><a href="transport/10_Interaction_Specification.html#4-form-interactions">4. Form interactions</a></li> <li><a href="transport/10_Interaction_Specification.html#5-routestopeditor-interactions">5. RouteStopEditor interactions</a></li> <li><a href="transport/10_Interaction_Specification.html#6-resource-pickers-vehicledriverroute">6. Resource pickers (vehicle/driver/route)</a></li> <li><a href="transport/10_Interaction_Specification.html#7-assignment-interactions">7. Assignment interactions</a></li> <li><a href="transport/10_Interaction_Specification.html#8-offline-behaviour">8. Offline behaviour</a></li> <li><a href="transport/10_Interaction_Specification.html#9-motion-summary">9. Motion summary</a></li> <li><a href="transport/10_Interaction_Specification.html#10-keyboard--platform">10. Keyboard & platform</a></li> </ul> <blockquote> <p>Precise interaction rules per screen flow. Motion tokens per <a href="transport/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a> (<code>m-fast</code> = 150 ms, <code>m-base</code> = 250 ms, <code>m-slow</code> = 400 ms).</p> </blockquote> <hr /> <h2 id="1-navigation-1"><a class="header" href="#1-navigation-1">1. Navigation</a></h2> <div class="table-wrapper"><table><thead><tr><th>From</th><th>To</th><th>Trigger</th><th>Transition</th></tr></thead><tbody> <tr><td>Overview tile</td><td>list screens</td><td>tap</td><td><code>m-base</code> slide</td></tr> <tr><td>List row</td><td>detail</td><td>tap</td><td><code>m-base</code> slide (hero: row to detail header)</td></tr> <tr><td>Detail</td><td>edit form</td><td>FAB/action</td><td><code>m-base</code> slide</td></tr> <tr><td>List</td><td>create form</td><td>FAB</td><td>sheet: <code>m-base</code> bottom sheet slide</td></tr> <tr><td>Route detail</td><td>assign sheet</td><td>"Assign students"</td><td>sheet slide</td></tr> <tr><td>Student assignments</td><td>assign sheet</td><td>"Assign"</td><td>sheet slide, studentId prefilled</td></tr> </tbody></table> </div> <p>Back always pops; unsaved form guard dialog (discard / keep editing).</p> <h2 id="2-list-interactions"><a class="header" href="#2-list-interactions">2. List interactions</a></h2> <ul> <li><strong>Row tap</strong> -> detail; <strong>long-press</strong> on desktop opens <code>AppMenu</code> directly.</li> <li><strong>Pull-to-refresh</strong>: re-fires current page; skeleton preserved, content swap when response arrives; error keeps stale data + banner.</li> <li><strong>Pagination footer</strong>: Previous/Next + page number + "of N" from <code>meta</code> (<code>buildPaginationMeta</code> - <code>transport.service.ts:75</code>); disabled states at bounds (<code>hasNext/hasPrevious</code>).</li> <li><strong>Menu actions</strong>: Edit (<code>.update</code>), Delete (<code>.delete</code>) - shown per permission (<code>permissions.constants.ts:62-74</code>).</li> </ul> <h2 id="3-delete-flow-all-three-entities--assignment"><a class="header" href="#3-delete-flow-all-three-entities--assignment">3. Delete flow (all three entities + assignment)</a></h2> <ol> <li>Menu -> Delete.</li> <li><code>AppDialog</code> confirm: "Remove <plateNumber / name>? This is reversible for admins." (soft delete, <code>base.repository.ts:68-74</code>).</li> <li>Optimistic: row removed + <code>AppSnackbar</code> "Removed".</li> <li>Rollback on error: <ul> <li>404 -> refetch list (<code>transport.service.ts:101, 173, 248, 290</code>).</li> <li>409 -> restore row + <code>DependencyGuardDialog</code> (blocking dependency message, <code>transport.service.ts:94-99, 168-171, 241-246</code>) with "View" action.</li> </ul> </li> <li>Events fire server-side (<code>VehicleDeleted</code>, <code>transport.service.ts:102-109</code>).</li> </ol> <h2 id="4-form-interactions"><a class="header" href="#4-form-interactions">4. Form interactions</a></h2> <ul> <li><strong>Validation timing</strong>: on-field-blur for required/format; live for conflicts after a submitted 409 (<a href="transport/08_Form_Specifications.html">08</a> §5).</li> <li><strong>Submit</strong>: button loading spinner; disable double-submit; success -> pop with snackbar; 409 -> <code>ConflictFieldError</code> + focus first invalid (<a href="transport/07_Component_Library.html">07</a> §8; <code>transport.service.ts:38-45, 113-116, 177-190, 252-258</code>).</li> <li><strong>Optional dates</strong> (<code>insuranceExpiry</code>, <code>licenseExpiry</code>, <code>joinedAt</code>): date picker; empty sends absent field (PartialType semantics, <code>update-*.dto.ts:4</code>); server re-parses only when present (<code>transport.service.ts:159-161, 228-234</code>).</li> </ul> <h2 id="5-routestopeditor-interactions"><a class="header" href="#5-routestopeditor-interactions">5. RouteStopEditor interactions</a></h2> <ul> <li>Add stop: trailing field + add button -> appended with next <code>order</code>; row animates in <code>m-fast</code>.</li> <li>Reorder: drag (mobile) or up/down buttons (a11y) -> <code>m-base</code> reorder animation; <code>order</code> values re-sequenced 1..N on every change (<code>route.schema.ts:23-24</code>).</li> <li>Delete stop: row swipe (mobile) or menu; confirm not needed (undo snackbar 4 s).</li> <li>Persistence: route update PATCHes whole <code>stops</code> array (<code>create-route.dto.ts:34-39</code>).</li> </ul> <h2 id="6-resource-pickers-vehicledriverroute"><a class="header" href="#6-resource-pickers-vehicledriverroute">6. Resource pickers (vehicle/driver/route)</a></h2> <ul> <li>Open: cached list first (<code>GET /transport/vehicles|drivers|routes</code> page 1, <code>transport.controller.ts:36-40, 96-100, 66-70</code>), fetch-on-focus.</li> <li>Filter: local case-insensitive match on <code>plateNumber</code>/<code>name</code>/<code>firstName</code>; single-flight; empty result -> "No match - check list scope".</li> <li>Select: chip + clear affordance; clearing omits the field on submit (<code>create-route.dto.ts:41-49</code>).</li> </ul> <h2 id="7-assignment-interactions"><a class="header" href="#7-assignment-interactions">7. Assignment interactions</a></h2> <ul> <li>Shift change after selection: allowed (new assignment only; editing an existing assignment = remove + re-assign - no PATCH endpoint for assignments, <code>transport.controller.ts:120-136</code>).</li> <li>stopName auto-suggest: from selected route's stops (<code>route.schema.ts:23-24</code>).</li> <li>409 duplicate: warning + "View existing assignment" -> student assignment screen (<code>transport.service.ts:252-258</code>).</li> <li>Capacity pre-check (client heuristic, <a href="transport/06_Screen_Specifications.html">06</a> §11): warn "route appears full" before submit; server does not enforce - QA <a href="transport/14_QA_Checklist.html">14</a> §3.</li> </ul> <h2 id="8-offline-behaviour"><a class="header" href="#8-offline-behaviour">8. Offline behaviour</a></h2> <ul> <li>Lists: cached render + <code>AppOfflineBanner</code>; mutations blocked with banner (<a href="transport/../00-shared/10_QA_Baseline.html">00-shared/10</a> §2, §3.3).</li> <li>Detail screens: cache-keyed by id; stale allowed with "updated x ago".</li> </ul> <h2 id="9-motion-summary-1"><a class="header" href="#9-motion-summary-1">9. Motion summary</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Token</th></tr></thead><tbody> <tr><td>Screen transitions</td><td><code>m-base</code> slide</td></tr> <tr><td>Bottom sheets / dialogs</td><td><code>m-base</code> (sheet), <code>m-fast</code> (dialog)</td></tr> <tr><td>Row reorder / add-stop</td><td><code>m-base</code> / <code>m-fast</code></td></tr> <tr><td>Delete rollback (409)</td><td>shake <code>m-fast</code> + dialog</td></tr> <tr><td>Error focus jump</td><td><code>m-fast</code></td></tr> </tbody></table> </div> <h2 id="10-keyboard--platform"><a class="header" href="#10-keyboard--platform">10. Keyboard & platform</a></h2> <ul> <li>Forms: <code>resizeToAvoidBottomInset</code> + scroll; phone portrait-first.</li> <li>Desktop: hover row highlight, long-press menu = context menu.</li> <li>a11y per <a href="transport/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>: live-region on snackbars, semantics on badges, explicit focus order in dialogs.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="11---design-system-mapping-transport-module"><a class="header" href="#11---design-system-mapping-transport-module">11 - Design System Mapping (Transport Module)</a></h1> <ul> <li><a href="transport/11_Design_System_Mapping.html#1-tokens-used-00-shared02">1. Tokens used (00-shared/02)</a></li> <li><a href="transport/11_Design_System_Mapping.html#2-component---token-mapping">2. Component -> token mapping</a></li> <li><a href="transport/11_Design_System_Mapping.html#3-status-colour-mapping-semantic">3. Status colour mapping (semantic)</a></li> <li><a href="transport/11_Design_System_Mapping.html#4-density--layout">4. Density & layout</a></li> <li><a href="transport/11_Design_System_Mapping.html#5-type--voice">5. Type & voice</a></li> <li><a href="transport/11_Design_System_Mapping.html#6-module-exceptions-flag-to-shared-team">6. Module exceptions (flag to shared team)</a></li> </ul> <blockquote> <p>Maps transport screens to the shared design system (<a href="transport/../00-shared/02_Design_Tokens.html">00-shared/02</a> tokens, <a href="transport/../00-shared/04_Design_System_Mapping.html">00-shared/04</a> mapping, <a href="transport/../00-shared/03_Component_Library.html">00-shared/03</a> components). No new tokens introduced; module-only patterns are listed as exceptions.</p> </blockquote> <hr /> <h2 id="1-tokens-used-00-shared02"><a class="header" href="#1-tokens-used-00-shared02">1. Tokens used (<a href="transport/../00-shared/02_Design_Tokens.html">00-shared/02</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Token</th><th>Where</th></tr></thead><tbody> <tr><td><code>color-success</code> / <code>color-warning</code> / <code>color-neutral</code></td><td>EntityStatusBadge tones (<a href="transport/07_Component_Library.html">07</a> §2)</td></tr> <tr><td><code>color-danger</code></td><td>delete actions, conflict field errors</td></tr> <tr><td><code>color-primary</code></td><td>FABs, primary CTAs, active chips</td></tr> <tr><td><code>typography-title-medium</code></td><td>list tile titles, stat counts</td></tr> <tr><td><code>typography-body-medium</code></td><td>subtitles (model, license, phone)</td></tr> <tr><td><code>typography-label-large</code></td><td>section headers ("Stops", "Resources")</td></tr> <tr><td><code>spacing-*</code> (4 px grid)</td><td>card/list padding, stop editor rows</td></tr> <tr><td><code>radius-medium</code></td><td>cards, tiles, badges</td></tr> <tr><td><code>elevation-low</code></td><td>overview tiles, detail cards</td></tr> <tr><td><code>motion-m-fast/base</code></td><td>transitions per <a href="transport/10_Interaction_Specification.html">10</a> §9</td></tr> </tbody></table> </div> <h2 id="2-component---token-mapping"><a class="header" href="#2-component---token-mapping">2. Component -> token mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Component (<a href="transport/07_Component_Library.html">07</a>)</th><th>Container</th><th>Fill</th><th>Border</th><th>Text</th></tr></thead><tbody> <tr><td>StatTile</td><td><code>AppCard</code></td><td>surface</td><td><code>color-outline-variant</code></td><td>count = <code>typography-headline</code></td></tr> <tr><td>EntityStatusBadge</td><td><code>AppBadge</code></td><td>status tone 10 %</td><td>tone</td><td>tone text</td></tr> <tr><td>ShiftPicker</td><td>segmented chips</td><td>primary 10 %</td><td>primary</td><td>primary text</td></tr> <tr><td>RouteStopEditor</td><td><code>AppCard</code> list</td><td>surface</td><td>outline-variant</td><td>body-medium</td></tr> <tr><td>ResourcePicker</td><td><code>AppTextField</code> + chips</td><td>surface</td><td>outline</td><td>body-medium</td></tr> <tr><td>AssignmentTile</td><td><code>AppListTile</code></td><td>surface</td><td>none</td><td>title = title-medium</td></tr> <tr><td>DependencyGuardDialog</td><td><code>AppDialog</code></td><td>surface-container</td><td>outline</td><td>danger title</td></tr> </tbody></table> </div> <h2 id="3-status-colour-mapping-semantic"><a class="header" href="#3-status-colour-mapping-semantic">3. Status colour mapping (semantic)</a></h2> <p>Server enums -> colour, single source for the module (<code>vehicle.schema.ts:13-17</code>, <code>driver.schema.ts:7-11</code>, <code>route.schema.ts:7-10</code>, <code>route-assignment.schema.ts:7-10</code>):</p> <div class="table-wrapper"><table><thead><tr><th>Status</th><th>Colour</th><th>Icon</th></tr></thead><tbody> <tr><td>active</td><td>success</td><td>check-circle</td></tr> <tr><td>maintenance</td><td>warning</td><td>wrench</td></tr> <tr><td>on_leave</td><td>warning</td><td>pause-circle</td></tr> <tr><td>inactive</td><td>neutral</td><td>minus-circle</td></tr> </tbody></table> </div> <h2 id="4-density--layout"><a class="header" href="#4-density--layout">4. Density & layout</a></h2> <ul> <li>Lists: standard density (<code>AppListTile</code> default 56 dp); detail cards use <code>spacing-4</code>.</li> <li>Master-detail (tablet/desktop, <a href="transport/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a> §2): left list 320 dp min, right detail pane.</li> <li>Overview: StatTile grid - 2 columns phone, 3-4 tablet/desktop.</li> </ul> <h2 id="5-type--voice"><a class="header" href="#5-type--voice">5. Type & voice</a></h2> <ul> <li>Titles: entity names verbatim (plateNumber, route name, driver full name).</li> <li>Empty states: action-oriented ("No vehicles yet - add your first vehicle").</li> <li>Conflict messages: server message verbatim (<a href="transport/07_Component_Library.html">07</a> §8), never paraphrased - they are exact 409 bodies (<code>transport.service.ts:42-44, 115, 181-183, 187-189, 257</code>).</li> </ul> <h2 id="6-module-exceptions-flag-to-shared-team"><a class="header" href="#6-module-exceptions-flag-to-shared-team">6. Module exceptions (flag to shared team)</a></h2> <ul> <li><code>plateNumber</code> always monospace (<code>typography</code> font feature <code>tnum</code>).</li> <li>Capacity displayed with unit "seats".</li> <li>Stops numbering uses ordinal prefix ("Stop 1", "Stop 2") rather than bullets.</li> <li>Planned screens (live map, attendance, fees) will need <code>map</code> palette + location markers - deferred to <code>(planned)</code> implementation (<code>IMPLEMENTATION_PLAN.md:229</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="12---api-mapping-transport-module"><a class="header" href="#12---api-mapping-transport-module">12 - API Mapping (Transport Module)</a></h1> <ul> <li><a href="transport/12_API_Mapping.html#1-vehicles-transportcontrollerts30-58">1. Vehicles (<code>transport.controller.ts:30-58</code>)</a></li> <li><a href="transport/12_API_Mapping.html#2-routes-transportcontrollerts60-88">2. Routes (<code>transport.controller.ts:60-88</code>)</a></li> <li><a href="transport/12_API_Mapping.html#3-drivers-transportcontrollerts90-118">3. Drivers (<code>transport.controller.ts:90-118</code>)</a></li> <li><a href="transport/12_API_Mapping.html#4-assignments-transportcontrollerts120-136">4. Assignments (<code>transport.controller.ts:120-136</code>)</a></li> <li><a href="transport/12_API_Mapping.html#5-request-examples">5. Request examples</a></li> <li><a href="transport/12_API_Mapping.html#6-response-shapes">6. Response shapes</a></li> <li><a href="transport/12_API_Mapping.html#7-error-map">7. Error map</a></li> <li><a href="transport/12_API_Mapping.html#8-planned--not-yet-in-source">8. Planned / not yet in source</a></li> </ul> <blockquote> <p>Exact wire contracts for the Transport module. Base path <code>/api/v1</code> (URI versioning, <code>main.ts</code>); all endpoints JWT-guarded (<code>transport.controller.ts:23-26</code>), tenant-scoped (<code>base.repository.ts:20-30</code>). Envelopes per <a href="transport/../00-shared/07_API_Conventions.html">00-shared/07</a> §2-3. Permissions from <code>permissions.constants.ts:62-74</code> (guards on endpoints not yet wired - <code>AGENTS.md</code>).</p> </blockquote> <hr /> <h2 id="1-vehicles-transportcontrollerts30-58"><a class="header" href="#1-vehicles-transportcontrollerts30-58">1. Vehicles (<code>transport.controller.ts:30-58</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Method</th><th>Path</th><th>Permission</th><th>DTO / source</th><th>Description</th></tr></thead><tbody> <tr><td>POST</td><td><code>/transport/vehicles</code></td><td><code>transport.vehicle.create</code></td><td><code>CreateVehicleDto</code> (<code>create-vehicle.dto.ts:5-42</code>)</td><td>Add a vehicle; 409 dup plate (<code>transport.service.ts:38-45</code>); emits <code>VehicleCreated</code> (<code>:47-57</code>)</td></tr> <tr><td>GET</td><td><code>/transport/vehicles?page=1&limit=20</code></td><td><code>transport.vehicle.read</code></td><td>-</td><td>List, sort <code>plateNumber</code> asc, paginated meta (<code>transport.service.ts:61-76</code>; <code>transport.controller.ts:36-40</code>)</td></tr> <tr><td>GET</td><td><code>/transport/vehicles/:id</code></td><td><code>transport.vehicle.read</code></td><td>-</td><td>Get by ID; 404 (<code>transport.service.ts:78-82</code>)</td></tr> <tr><td>PATCH</td><td><code>/transport/vehicles/:id</code></td><td><code>transport.vehicle.update</code></td><td><code>UpdateVehicleDto</code> (PartialType, <code>update-vehicle.dto.ts:4</code>)</td><td>Update; 404 (<code>transport.service.ts:84-91</code>)</td></tr> <tr><td>DELETE</td><td><code>/transport/vehicles/:id</code></td><td><code>transport.vehicle.delete</code></td><td>-</td><td>Soft delete; 409 if on a route (<code>transport.service.ts:93-110</code>); emits <code>VehicleDeleted</code></td></tr> </tbody></table> </div> <h2 id="2-routes-transportcontrollerts60-88"><a class="header" href="#2-routes-transportcontrollerts60-88">2. Routes (<code>transport.controller.ts:60-88</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Method</th><th>Path</th><th>Permission</th><th>DTO / source</th><th>Description</th></tr></thead><tbody> <tr><td>POST</td><td><code>/transport/routes</code></td><td><code>transport.route.create</code></td><td><code>CreateRouteDto</code> (<code>create-route.dto.ts:21-60</code>)</td><td>Create; 409 dup name (<code>transport.service.ts:112-130</code>); emits <code>RouteCreated</code></td></tr> <tr><td>GET</td><td><code>/transport/routes?page=1&limit=20</code></td><td><code>transport.route.read</code></td><td>-</td><td>List, sort <code>name</code> asc (<code>transport.service.ts:132-147</code>)</td></tr> <tr><td>GET</td><td><code>/transport/routes/:id</code></td><td><code>transport.route.read</code></td><td>-</td><td>404 (<code>transport.service.ts:149-153</code>)</td></tr> <tr><td>PATCH</td><td><code>/transport/routes/:id</code></td><td><code>transport.route.update</code></td><td><code>UpdateRouteDto</code> (PartialType)</td><td>Update incl. stops/vehicleId/driverId; ObjectId conversion (<code>transport.service.ts:155-165</code>)</td></tr> <tr><td>DELETE</td><td><code>/transport/routes/:id</code></td><td><code>transport.route.delete</code></td><td>-</td><td>Soft delete; 409 if active assignments (<code>transport.service.ts:167-174</code>)</td></tr> </tbody></table> </div> <h2 id="3-drivers-transportcontrollerts90-118"><a class="header" href="#3-drivers-transportcontrollerts90-118">3. Drivers (<code>transport.controller.ts:90-118</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Method</th><th>Path</th><th>Permission</th><th>DTO / source</th><th>Description</th></tr></thead><tbody> <tr><td>POST</td><td><code>/transport/drivers</code></td><td><code>transport.driver.create</code></td><td><code>CreateDriverDto</code> (<code>create-driver.dto.ts:4-50</code>)</td><td>Add; 409 dup license OR phone (<code>transport.service.ts:176-199</code>)</td></tr> <tr><td>GET</td><td><code>/transport/drivers?page=1&limit=20</code></td><td><code>transport.driver.read</code></td><td>-</td><td>List, sort <code>firstName</code> asc (<code>transport.service.ts:201-216</code>)</td></tr> <tr><td>GET</td><td><code>/transport/drivers/:id</code></td><td><code>transport.driver.read</code></td><td>-</td><td>404 (<code>transport.service.ts:218-222</code>)</td></tr> <tr><td>PATCH</td><td><code>/transport/drivers/:id</code></td><td><code>transport.driver.update</code></td><td><code>UpdateDriverDto</code> (PartialType)</td><td>Update; date re-parse (<code>transport.service.ts:224-238</code>)</td></tr> <tr><td>DELETE</td><td><code>/transport/drivers/:id</code></td><td><code>transport.driver.delete</code></td><td>-</td><td>Soft delete; 409 if on a route (<code>transport.service.ts:240-249</code>)</td></tr> </tbody></table> </div> <h2 id="4-assignments-transportcontrollerts120-136"><a class="header" href="#4-assignments-transportcontrollerts120-136">4. Assignments (<code>transport.controller.ts:120-136</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Method</th><th>Path</th><th>Permission</th><th>DTO / source</th><th>Description</th></tr></thead><tbody> <tr><td>POST</td><td><code>/transport/assign</code></td><td><code>transport.assign</code></td><td><code>AssignRouteDto</code> (<code>assign-route.dto.ts:4-26</code>)</td><td>Assign student to route; 409 dup (route, student) (<code>transport.service.ts:251-280</code>); emits <code>StudentRouteAssigned</code></td></tr> <tr><td>GET</td><td><code>/transport/assignments/:studentId</code></td><td><code>transport.assign</code> (or <code>route.read</code> context)</td><td>-</td><td>Student's assignments, route populated (<code>transport.service.ts:282-286</code>, <code>route-assignment.repository.ts:20-24</code>)</td></tr> <tr><td>DELETE</td><td><code>/transport/assignments/:id</code></td><td><code>transport.assign</code></td><td>-</td><td>Remove assignment (soft delete); 404 (<code>transport.service.ts:288-291</code>)</td></tr> </tbody></table> </div> <hr /> <h2 id="5-request-examples"><a class="header" href="#5-request-examples">5. Request examples</a></h2> <pre><code class="language-json">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" } </code></pre> <h2 id="6-response-shapes"><a class="header" href="#6-response-shapes">6. Response shapes</a></h2> <ul> <li>List: <code>{ data: VehicleDoc[], meta: { page, limit, totalItems, totalPages, hasNext, hasPrevious } }</code> (<code>buildPaginationMeta</code>, <code>transport.service.ts:75</code>).</li> <li>Single/created/updated: <code>{ data: <Doc> }</code> (envelope interceptor, <code>00-shared/07</code>).</li> <li>Student assignments: <code>{ data: RouteAssignment[] }</code> with <code>routeId</code> populated (<code>route-assignment.repository.ts:20-24</code>).</li> <li>Delete: 200/204 envelope, no body.</li> </ul> <h2 id="7-error-map"><a class="header" href="#7-error-map">7. Error map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Code</th><th>Meaning</th><th>Source</th></tr></thead><tbody> <tr><td>401</td><td>unauthenticated</td><td><code>JwtAuthGuard</code> (<code>transport.controller.ts:25</code>)</td></tr> <tr><td>404</td><td>entity missing</td><td><code>transport.service.ts:80, 101, 151, 173, 220, 237, 248, 290</code></td></tr> <tr><td>409</td><td>dup plate / route name / license / phone / assignment</td><td><code>transport.service.ts:42-44, 115, 181-183, 187-189, 257</code></td></tr> <tr><td>409</td><td>dependency blocks delete</td><td><code>transport.service.ts:98, 170, 245</code></td></tr> <tr><td>400</td><td>DTO validation (class-validator)</td><td><code>create-*.dto.ts</code></td></tr> </tbody></table> </div> <h2 id="8-planned--not-yet-in-source"><a class="header" href="#8-planned--not-yet-in-source">8. Planned / not yet in source</a></h2> <div class="table-wrapper"><table><thead><tr><th>Path</th><th>Status</th><th>Source</th></tr></thead><tbody> <tr><td>Live tracking, bus attendance, fee calc, emergency endpoints</td><td><code>(planned)</code></td><td><code>IMPLEMENTATION_PLAN.md:229</code></td></tr> <tr><td>Assignment status PATCH (active/inactive)</td><td><code>(planned)</code> - schema field exists, no endpoint (<code>route-assignment.schema.ts:23-28</code>)</td><td>gap</td></tr> <tr><td>Search/filter query params (q, status, type)</td><td><code>(planned)</code> - lists filter <code>{}</code> (<code>transport.service.ts:69, 141, 210</code>)</td><td>gap</td></tr> <tr><td>Parent bus-tracking read</td><td><code>(forward-looking)</code></td><td><code>docs/user-flows/END_TO_END_USER_FLOWS.md:392-406</code></td></tr> <tr><td>Notifications (e.g. <code>transport.delay</code>)</td><td><code>(planned)</code></td><td><code>docs/user-flows/END_TO_END_USER_FLOWS.md:430</code></td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="13---state-management-transport-module"><a class="header" href="#13---state-management-transport-module">13 - State Management (Transport Module)</a></h1> <ul> <li><a href="transport/13_State_Management.html#1-cubit-map">1. Cubit map</a></li> <li><a href="transport/13_State_Management.html#2-vehiclelistcubit">2. VehicleListCubit</a></li> <li><a href="transport/13_State_Management.html#3-detail--form-cubits-vehicledriverroute-share-shape">3. Detail + Form cubits (vehicle/driver/route share shape)</a></li> <li><a href="transport/13_State_Management.html#4-assignmentcubit-assign-sheet--student-assignments">4. AssignmentCubit (assign sheet + student assignments)</a></li> <li><a href="transport/13_State_Management.html#5-transportoverviewcubit">5. TransportOverviewCubit</a></li> <li><a href="transport/13_State_Management.html#6-cross-cutting">6. Cross-cutting</a></li> </ul> <blockquote> <p>Per-screen Cubit/Bloc design on top of <a href="transport/../00-shared/06_State_Management.html">00-shared/06</a> conventions (stack: <code>flutter_bloc</code> + <code>get_it</code>; server state via dio repository; <code>LoadState</code> = Initial/Loading/Success/Error(ApiException)). Mermaid diagrams included.</p> </blockquote> <hr /> <h2 id="1-cubit-map-8"><a class="header" href="#1-cubit-map-8">1. Cubit map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Cubit</th><th>Screen(s) (<a href="transport/05_Screen_Inventory.html">05</a>)</th><th>Data</th></tr></thead><tbody> <tr><td><code>VehicleListCubit</code></td><td>2</td><td>page, limit, <code>List<Vehicle></code>, <code>PaginationMeta</code></td></tr> <tr><td><code>VehicleDetailCubit</code></td><td>3</td><td><code>Vehicle</code>, form state</td></tr> <tr><td><code>VehicleFormCubit</code></td><td>4</td><td>form model, field errors, submit</td></tr> <tr><td><code>DriverListCubit</code></td><td>5</td><td>page, limit, <code>List<Driver></code>, meta</td></tr> <tr><td><code>DriverDetailCubit</code></td><td>6</td><td><code>Driver</code></td></tr> <tr><td><code>DriverFormCubit</code></td><td>7</td><td>form model, errors, submit</td></tr> <tr><td><code>RouteListCubit</code></td><td>8</td><td>page, limit, <code>List<Route></code>, meta</td></tr> <tr><td><code>RouteDetailCubit</code></td><td>9</td><td><code>Route</code>, resolved vehicle/driver refs</td></tr> <tr><td><code>RouteFormCubit</code></td><td>10</td><td>form model, stops order, resource picks, submit</td></tr> <tr><td><code>AssignmentCubit</code></td><td>11/12</td><td>assign sheet + student's assignment list</td></tr> <tr><td><code>TransportOverviewCubit</code></td><td>1</td><td>3 counts (vehicles/routes/drivers)</td></tr> <tr><td><code>LiveTrackingCubit</code></td><td>13</td><td><code>(planned)</code></td></tr> <tr><td><code>AttendanceCubit</code> / <code>FeesCubit</code></td><td>14/15</td><td><code>(planned)</code></td></tr> </tbody></table> </div> <p>Repositories (<code>VehicleRepository</code>, <code>DriverRepository</code>, <code>RouteRepository</code>, <code>AssignmentRepository</code> in <code>features/transport/data/</code>) are the only layer touching HTTP; they map envelopes to models and throw <code>ApiException(status, message)</code> (<a href="transport/../00-shared/06_State_Management.html">00-shared/06</a> §2, §3).</p> <h2 id="2-vehiclelistcubit"><a class="header" href="#2-vehiclelistcubit">2. VehicleListCubit</a></h2> <pre class="mermaid">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) </pre> <ul> <li>Fetch: <code>GET /transport/vehicles?page&limit</code> (<code>transport.controller.ts:36-40</code>); <code>meta</code> drives pagination (<code>buildPaginationMeta</code>, <code>transport.service.ts:75</code>).</li> <li>Delete flow: optimistic -> <code>DELETE /transport/vehicles/:id</code> (<code>transport.controller.ts:54-58</code>); on 409/404 restore row + emit <code>DeleteBlocked(ApiException)</code> / <code>NotFound</code> (<code>transport.service.ts:94-101</code>).</li> </ul> <h2 id="3-detail--form-cubits-vehicledriverroute-share-shape"><a class="header" href="#3-detail--form-cubits-vehicledriverroute-share-shape">3. Detail + Form cubits (vehicle/driver/route share shape)</a></h2> <pre class="mermaid">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) </pre> <ul> <li>Form state fields: <code>saved: bool</code>, <code>submitting: bool</code>, <code>conflicts: Map<String, String></code> (server 409 verbatim, <code>transport.service.ts:38-45, 113-116, 177-190</code>).</li> <li>RouteFormCubit extras: <code>stops: List<RouteStopDraft></code> re-sequenced on reorder (<code>route.schema.ts:23-24</code>), <code>vehicleId</code>/<code>driverId</code> optional strings (<code>create-route.dto.ts:41-49</code>).</li> </ul> <h2 id="4-assignmentcubit-assign-sheet--student-assignments"><a class="header" href="#4-assignmentcubit-assign-sheet--student-assignments">4. AssignmentCubit (assign sheet + student assignments)</a></h2> <pre class="mermaid">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) </pre> <ul> <li>Submit: <code>POST /transport/assign</code> body from <code>AssignRouteDto</code> (<code>assign-route.dto.ts:4-26</code>; <code>transport.controller.ts:120-124</code>).</li> <li>409 -> <code>Conflict</code> with message "Student already assigned to this route." (<code>transport.service.ts:257</code>) + "View existing" action -> student assignment screen (<code>05</code> §10).</li> <li>Remove: <code>DELETE /transport/assignments/:id</code> (<code>transport.controller.ts:132-136</code>).</li> </ul> <h2 id="5-transportoverviewcubit"><a class="header" href="#5-transportoverviewcubit">5. TransportOverviewCubit</a></h2> <p>Three independent fetches (<code>limit=1</code> for counts); per-tile <code>LoadState</code> (<code>06</code> §1) so one failure never blanks the hub. Data: <code>meta.totalItems</code> (<code>transport.service.ts:75, 146, 215</code>). Refetch all on pull-to-refresh.</p> <h2 id="6-cross-cutting-1"><a class="header" href="#6-cross-cutting-1">6. Cross-cutting</a></h2> <ul> <li><strong>Cache</strong>: entity lists cached in memory (<code>Hive</code> optional) per tenant; detail screens read cache-first then refresh (offline tolerance, <code>00-shared/10</code> §2).</li> <li><strong>Ref resolution</strong> (route detail): vehicle/driver ids are raw ObjectIds (<code>route.schema.ts:26-30</code>); RouteDetailCubit resolves via cached lists or <code>GET /transport/vehicles|drivers/:id</code> in parallel; unresolved -> null.</li> <li><strong>Events as hints</strong>: <code>VehicleCreated</code> / <code>RouteCreated</code> / <code>StudentRouteAssigned</code> (<code>transport.service.ts:47-57, 121-128, 267-278</code>) are server-side signals; client refresh is response-driven, not event-driven.</li> <li><strong>Permission gating</strong>: cubits expose <code>canCreate/canUpdate/canDelete</code> from RBAC (<code>permissions.constants.ts:62-74</code>); UI hides actions accordingly.</li> <li><strong>Planned cubits</strong>: LiveTracking/Attendance/Fees <code>(planned)</code> per <code>IMPLEMENTATION_PLAN.md:229</code> - no data contract yet.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14---qa-checklist-transport-module"><a class="header" href="#14---qa-checklist-transport-module">14 - QA Checklist (Transport Module)</a></h1> <ul> <li><a href="transport/14_QA_Checklist.html#1-core-flows-must-pass">1. Core flows (must pass)</a></li> <li><a href="transport/14_QA_Checklist.html#2-validation--conflicts-negative-paths">2. Validation & conflicts (negative paths)</a></li> <li><a href="transport/14_QA_Checklist.html#3-integrity-gaps-flag-to-backend---not-server-enforced">3. Integrity gaps (flag to backend - NOT server-enforced)</a></li> <li><a href="transport/14_QA_Checklist.html#4-soft-delete--reference-integrity">4. Soft-delete & reference integrity</a></li> <li><a href="transport/14_QA_Checklist.html#5-multi-tenancy">5. Multi-tenancy</a></li> <li><a href="transport/14_QA_Checklist.html#6-uiux-manual--golden">6. UI/UX (manual + golden)</a></li> <li><a href="transport/14_QA_Checklist.html#7-e2e-regression-seeds-manual-fixtures">7. E2E regression seeds (manual fixtures)</a></li> </ul> <blockquote> <p>Module-specific quality gates on top of <a href="transport/../00-shared/10_QA_Baseline.html">00-shared/10</a> (QA baseline: performance budgets, offline matrix, a11y, motion, security). Every item is testable and maps to source.</p> </blockquote> <hr /> <h2 id="1-core-flows-must-pass-1"><a class="header" href="#1-core-flows-must-pass-1">1. Core flows (must pass)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expectation</th><th>Source</th></tr></thead><tbody> <tr><td>1.1</td><td>Create vehicle -> appears in list</td><td>POST 201, doc in GET list, sorted by plateNumber</td><td><code>transport.controller.ts:30-40</code>, <code>transport.service.ts:71</code></td></tr> <tr><td>1.2</td><td>Create route with 3 stops -> detail shows order 1,2,3</td><td>stops array round-trips <code>{name, order}</code></td><td><code>create-route.dto.ts:34-39</code>, <code>route.schema.ts:23-24</code></td></tr> <tr><td>1.3</td><td>Assign student -> student assignments shows populated route</td><td>POST + GET <code>/assignments/:studentId</code></td><td><code>transport.controller.ts:120-130</code>, <code>route-assignment.repository.ts:20-24</code></td></tr> <tr><td>1.4</td><td>Soft delete vehicle -> list excludes it</td><td><code>isDeleted: true</code>, filter <code>base.repository.ts:20-30, 68-74</code></td><td><code>transport.service.ts:100</code></td></tr> <tr><td>1.5</td><td>Update driver -> PATCH applies partial fields only</td><td>untouched fields unchanged</td><td><code>update-driver.dto.ts:4</code>, <code>transport.service.ts:224-238</code></td></tr> </tbody></table> </div> <h2 id="2-validation--conflicts-negative-paths-1"><a class="header" href="#2-validation--conflicts-negative-paths-1">2. Validation & conflicts (negative paths)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expectation</th><th>Source</th></tr></thead><tbody> <tr><td>2.1</td><td>Create vehicle with duplicate plateNumber</td><td>409, message verbatim <code>Vehicle "<plate>" already exists.</code></td><td><code>transport.service.ts:38-45</code>; index <code>vehicle.schema.ts:50</code></td></tr> <tr><td>2.2</td><td>Create route with duplicate name</td><td>409 <code>Route "<name>" already exists.</code></td><td><code>transport.service.ts:113-116</code>; index <code>route.schema.ts:44</code></td></tr> <tr><td>2.3</td><td>Create driver with duplicate licenseNumber</td><td>409 <code>Driver with license ...</code></td><td><code>transport.service.ts:177-184</code>; index <code>driver.schema.ts:50</code></td></tr> <tr><td>2.4</td><td>Create driver with duplicate phone</td><td>409 <code>Driver with phone ...</code></td><td><code>transport.service.ts:185-190</code>; index <code>driver.schema.ts:51</code></td></tr> <tr><td>2.5</td><td>Assign same (route, student) twice</td><td>409 <code>Student already assigned to this route.</code></td><td><code>transport.service.ts:252-258</code>; index <code>route-assignment.schema.ts:42-45</code></td></tr> <tr><td>2.6</td><td>capacity = 0 or negative</td><td>400 (DTO <code>@Min(1)</code>)</td><td><code>create-vehicle.dto.ts:14-17</code></td></tr> <tr><td>2.7</td><td>shift not in morning/evening/both</td><td>400 (<code>IsEnum</code>)</td><td><code>assign-route.dto.ts:13-15</code></td></tr> <tr><td>2.8</td><td>PATCH with invalid type value</td><td>400 (enum)</td><td><code>update-vehicle.dto.ts:4</code> -> <code>create-vehicle.dto.ts:19-21</code></td></tr> <tr><td>2.9</td><td>Unauthenticated call</td><td>401</td><td><code>transport.controller.ts:23-26</code></td></tr> </tbody></table> </div> <h2 id="3-integrity-gaps-flag-to-backend---not-server-enforced"><a class="header" href="#3-integrity-gaps-flag-to-backend---not-server-enforced">3. Integrity gaps (flag to backend - NOT server-enforced)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Gap</th><th>Client mitigation</th><th>Source</th></tr></thead><tbody> <tr><td>3.1</td><td><strong>Capacity overflow</strong>: assignStudent never checks route vehicle capacity vs. count of students</td><td>client pre-check warn + QA manual test; server check <code>(planned)</code></td><td><code>transport.service.ts:251-266</code>, <code>vehicle.schema.ts:27-28</code></td></tr> <tr><td>3.2</td><td>Assign to <strong>inactive route / maintenance vehicle / inactive driver</strong> not blocked</td><td>client filters inactive options; server <code>(planned)</code></td><td><code>route.schema.ts:32-33</code>, <code>vehicle.schema.ts:33-34</code>, <code>driver.schema.ts:30-31</code></td></tr> <tr><td>3.3</td><td><strong>Duplicate plate race</strong> (no DB unique index? index exists: <code>{tenantId, plateNumber}</code> unique - race window only via concurrent create)</td><td>rely on 409; retry UX</td><td><code>vehicle.schema.ts:50</code></td></tr> <tr><td>3.4</td><td>Updating a vehicle's <code>plateNumber</code> to an existing one -> 409 only via unique index (service check is create-only)</td><td>expect 409 from index; surface verbatim</td><td><code>transport.service.ts:84-91</code>, <code>vehicle.schema.ts:50</code></td></tr> <tr><td>3.5</td><td>Driver licenseExpiry in the past accepted</td><td>client warn; server <code>(planned)</code></td><td><code>create-driver.dto.ts:27-29</code></td></tr> </tbody></table> </div> <h2 id="4-soft-delete--reference-integrity"><a class="header" href="#4-soft-delete--reference-integrity">4. Soft-delete & reference integrity</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expectation</th><th>Source</th></tr></thead><tbody> <tr><td>4.1</td><td>Delete vehicle referenced by a route</td><td>409 <code>Vehicle is assigned to a route.</code></td><td><code>transport.service.ts:94-99</code></td></tr> <tr><td>4.2</td><td>Delete driver referenced by a route</td><td>409 <code>Driver is assigned to a route.</code></td><td><code>transport.service.ts:241-246</code></td></tr> <tr><td>4.3</td><td>Delete route with active assignments</td><td>409 <code>Route has active student assignments.</code></td><td><code>transport.service.ts:168-171</code></td></tr> <tr><td>4.4</td><td>Delete already-deleted entity</td><td>404 (scoped filter excludes deleted)</td><td><code>transport.service.ts:101, 173, 248, 290</code>, <code>base.repository.ts:20-30</code></td></tr> <tr><td>4.5</td><td>Delete assignment -> removed from student list</td><td>GET reflects</td><td><code>transport.service.ts:288-291</code></td></tr> </tbody></table> </div> <h2 id="5-multi-tenancy-1"><a class="header" href="#5-multi-tenancy-1">5. Multi-tenancy</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Scenario</th><th>Expectation</th><th>Source</th></tr></thead><tbody> <tr><td>5.1</td><td>Tenant B queries vehicles</td><td>sees only B's rows</td><td><code>base.repository.ts:20-30</code></td></tr> <tr><td>5.2</td><td>Cross-tenant duplicate plate allowed</td><td>yes - uniqueness is per tenant</td><td><code>vehicle.schema.ts:50</code></td></tr> <tr><td>5.3</td><td>tenantId from request body ignored</td><td>server never reads it from body</td><td><code>base.repository.ts:32-36</code>, <code>AGENTS.md</code></td></tr> </tbody></table> </div> <h2 id="6-uiux-manual--golden-1"><a class="header" href="#6-uiux-manual--golden-1">6. UI/UX (manual + golden)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th></tr></thead><tbody> <tr><td>6.1</td><td>Pagination boundaries: page 1 Prev disabled; last page Next disabled (meta <code>hasNext/hasPrevious</code>)</td></tr> <tr><td>6.2</td><td>409 inline errors focus the offending field</td></tr> <tr><td>6.3</td><td>Delete 409 dialog offers "View related" navigation</td></tr> <tr><td>6.4</td><td>Stop reorder persists after save + reload</td></tr> <tr><td>6.5</td><td>Offline: lists render cached + banner; mutations blocked (<a href="transport/../00-shared/10_QA_Baseline.html">00-shared/10</a> §2)</td></tr> <tr><td>6.6</td><td>Empty states for all 4 lists (vehicles/drivers/routes/assignments)</td></tr> <tr><td>6.7</td><td>Permission gating: read-only role sees no FAB/menu; 403 copy on direct route</td></tr> <tr><td>6.8</td><td>a11y: badges have semantics labels; dialogs trap focus (<a href="transport/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>)</td></tr> <tr><td>6.9</td><td>Skeleton < 200 ms, content < 2 s on network (<a href="transport/../00-shared/10_QA_Baseline.html">00-shared/10</a> §1)</td></tr> </tbody></table> </div> <h2 id="7-e2e-regression-seeds-manual-fixtures-1"><a class="header" href="#7-e2e-regression-seeds-manual-fixtures-1">7. E2E regression seeds (manual fixtures)</a></h2> <ul> <li>2 tenants with identical plate numbers (proves isolation, 5.2).</li> <li>Route with 20 stops (editor cap), 3 assignments on one route.</li> <li>One vehicle in <code>maintenance</code>, one driver <code>on_leave</code>, one route <code>inactive</code>.</li> <li>Deleted vehicle referenced by a soft-deleted route (delete guard must still 409 while route visible to admin).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="15---flutter-implementation-guide-transport-module"><a class="header" href="#15---flutter-implementation-guide-transport-module">15 - Flutter Implementation Guide (Transport Module)</a></h1> <ul> <li><a href="transport/15_Flutter_Implementation_Guide.html#1-feature-folder">1. Feature folder</a></li> <li><a href="transport/15_Flutter_Implementation_Guide.html#2-models-mirror-schemas-exactly">2. Models (mirror schemas exactly)</a></li> <li><a href="transport/15_Flutter_Implementation_Guide.html#3-repositories-dio">3. Repositories (dio)</a></li> <li><a href="transport/15_Flutter_Implementation_Guide.html#4-cubits">4. Cubits</a></li> <li><a href="transport/15_Flutter_Implementation_Guide.html#5-screens">5. Screens</a></li> <li><a href="transport/15_Flutter_Implementation_Guide.html#6-validation-client-mirror-of-dtos">6. Validation (client mirror of DTOs)</a></li> <li><a href="transport/15_Flutter_Implementation_Guide.html#7-navigation-go_router">7. Navigation (go_router)</a></li> <li><a href="transport/15_Flutter_Implementation_Guide.html#8-di-registration-get_it">8. DI registration (get_it)</a></li> <li><a href="transport/15_Flutter_Implementation_Guide.html#9-permissions--auth">9. Permissions & auth</a></li> <li><a href="transport/15_Flutter_Implementation_Guide.html#10-testing">10. Testing</a></li> <li><a href="transport/15_Flutter_Implementation_Guide.html#11-forward-looking-hooks">11. Forward-looking hooks</a></li> </ul> <blockquote> <p>Module-specific build guide on top of <a href="transport/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a> (Flutter architecture, get_it DI, dio, go_router, flutter_bloc) and <a href="transport/../00-shared/06_State_Management.html">00-shared/06</a> (state patterns). Order = recommended implementation sequence; each step maps to source files.</p> </blockquote> <hr /> <h2 id="1-feature-folder-3"><a class="header" href="#1-feature-folder-3">1. Feature folder</a></h2> <pre><code>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 </code></pre> <p>Widgets per <code>07_Component_Library.md</code>; screens per <code>05</code>/<code>06</code>.</p> <h2 id="2-models-mirror-schemas-exactly"><a class="header" href="#2-models-mirror-schemas-exactly">2. Models (mirror schemas exactly)</a></h2> <ul> <li><code>Vehicle</code> - <code>vehicle.schema.ts:19-50</code>: plateNumber, model, capacity (int), type (bus/van/car enum), status (active/maintenance/inactive), year?, color?, insuranceExpiry? (DateTime, server sends string - parse defensively), notes?.</li> <li><code>Driver</code> - <code>driver.schema.ts:13-47</code>: firstName, lastName, licenseNumber, phone, email?, status (active/inactive/on_leave), licenseExpiry?, address?, emergencyContact?, joinedAt? (DateTime), notes?.</li> <li><code>Route</code> - <code>route.schema.ts:12-40</code>: name, startPoint, endPoint, stops <code>List<RouteStop{name, order}></code> (keep sorted by order client-side), vehicleId?, driverId? (String ObjectIds), status, estimatedDuration?, notes?.</li> <li><code>RouteAssignment</code> - <code>route-assignment.schema.ts:12-38</code>: routeId (populated -> Route), studentId, shift (<code>morning</code>/<code>evening</code>/<code>both</code>), status, stopName?, assignedAt? (DateTime), notes?.</li> </ul> <p>Immutable classes + <code>fromJson</code>/<code>toJson</code> (repository maps envelope <code>data</code>). Never send <code>tenantId</code>/<code>isDeleted</code> (server-owned, <code>base.repository.ts:32-36</code>).</p> <h2 id="3-repositories-dio"><a class="header" href="#3-repositories-dio">3. Repositories (dio)</a></h2> <ul> <li>Endpoints per <code>12_API_Mapping.md</code>; base <code>/api/v1</code> + <code>ApiBearerAuth</code> (<code>transport.controller.ts:23-26</code>).</li> <li>Paginated calls: parse <code>data</code> + <code>meta {page, limit, totalItems, totalPages, hasNext, hasPrevious}</code> (<code>buildPaginationMeta</code>, <code>transport.service.ts:75</code>).</li> <li>Typed errors: <code>ApiException(409, message)</code> for conflicts (message verbatim - used directly in <code>ConflictFieldError</code>); <code>ApiException(404)</code>.</li> <li>Assignment repo: <code>findByStudent(studentId)</code> expects <code>routeId</code> populated (<code>route-assignment.repository.ts:20-24</code>) - map nested route.</li> <li>Single-flight + envelope mapping per <code>00-shared/11</code> §"AppDio".</li> </ul> <h2 id="4-cubits-5"><a class="header" href="#4-cubits-5">4. Cubits</a></h2> <p>Per <code>13_State_Management.md</code>: implement <code>VehicleListCubit</code> first (pattern for the other two lists), then form cubits (with <code>conflicts</code> map), then <code>AssignmentCubit</code> (assign + student list), then <code>TransportOverviewCubit</code>. Use the shared <code>LoadState</code> sealed class and pagination mixin (<code>00-shared/06</code> §3.1-3.2).</p> <h2 id="5-screens-2"><a class="header" href="#5-screens-2">5. Screens</a></h2> <div class="table-wrapper"><table><thead><tr><th>Order</th><th>Screen</th><th>Key widgets</th><th>Source</th></tr></thead><tbody> <tr><td>1</td><td>Vehicles list/detail/form</td><td>EntityStatusBadge, ConflictFieldError, AppPagination</td><td><code>06</code> §2-4</td></tr> <tr><td>2</td><td>Drivers list/detail/form</td><td>same + AppAvatar</td><td><code>06</code> §5-7</td></tr> <tr><td>3</td><td>Routes list/detail</td><td>ResourcePicker (read), stop list</td><td><code>06</code> §8-9</td></tr> <tr><td>4</td><td>Route editor</td><td>RouteStopEditor (ReorderableListView + order resequence), ResourcePicker</td><td><code>06</code> §10</td></tr> <tr><td>5</td><td>Assign sheet + student assignments</td><td>ShiftPicker, StudentPicker, AssignmentTile</td><td><code>06</code> §11-12</td></tr> <tr><td>6</td><td>Overview</td><td>StatTile x3</td><td><code>06</code> §1</td></tr> </tbody></table> </div> <h2 id="6-validation-client-mirror-of-dtos-1"><a class="header" href="#6-validation-client-mirror-of-dtos-1">6. Validation (client mirror of DTOs)</a></h2> <ul> <li>Vehicle form: capacity <code>>= 1</code> (<code>create-vehicle.dto.ts:14-17</code>); type from enum (<code>:19-21</code>).</li> <li>Driver form: email <code>IsEmail</code>-style regex (<code>:22-24</code>); dates <code>IsDateString</code> -> send ISO strings (<code>:27-29, 41-44</code>).</li> <li>Route form: stops <code>{name, order}</code> (<code>create-route.dto.ts:11-19</code>); optional vehicleId/driverId strings.</li> <li>Assign: shift enum (<code>assign-route.dto.ts:13-15</code>).</li> <li>Conflict messages: render server text verbatim (<code>07</code> §8).</li> </ul> <h2 id="7-navigation-go_router-2"><a class="header" href="#7-navigation-go_router-2">7. Navigation (go_router)</a></h2> <pre><code>/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 </code></pre> <p>Create forms = bottom sheets (phone) / dialogs (tablet), not routes (<code>00-shared/03</code>). Planned: <code>/transport/live</code>, <code>/transport/attendance</code>, <code>/transport/fees</code> placeholders <code>(planned)</code>.</p> <h2 id="8-di-registration-get_it-1"><a class="header" href="#8-di-registration-get_it-1">8. DI registration (get_it)</a></h2> <pre><code class="language-dart">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) </code></pre> <h2 id="9-permissions--auth-1"><a class="header" href="#9-permissions--auth-1">9. Permissions & auth</a></h2> <ul> <li>Gate FAB/menu on RBAC (<code>permissions.constants.ts:62-74</code>); 403 handling per <code>00-shared/07</code>; JWT via <code>AppDio</code> interceptor (<code>00-shared/11</code>).</li> <li>Note: RBAC guards on endpoints are not yet wired in the backend (<code>AGENTS.md</code> "Not yet implemented") - client still gates UI.</li> </ul> <h2 id="10-testing-8"><a class="header" href="#10-testing-8">10. Testing</a></h2> <ul> <li>Unit: cubit state transitions incl. 409 -> <code>Conflict</code>, optimistic delete rollback (<code>13</code> §2-4); model <code>fromJson</code> for populated assignment.</li> <li>Widget: golden per screen (<code>00-shared/10</code> §6); stop editor reorder test.</li> <li>Integration: mock dio with fixture envelopes; pagination boundary tests (<code>meta.hasNext</code>).</li> <li>E2E seeds per <code>14</code> §7.</li> </ul> <h2 id="11-forward-looking-hooks-1"><a class="header" href="#11-forward-looking-hooks-1">11. Forward-looking hooks</a></h2> <ul> <li>Live tracking/attendance/fees screens scaffolded behind feature flags, wired when backend lands (<code>IMPLEMENTATION_PLAN.md:229</code>).</li> <li>QR boarding <code>(forward-looking)</code>: <code>mobile_scanner</code> dependency flagged in <code>00-shared/11</code> §1.</li> <li>Analytics events <code>transport.*</code> <code>(proposed)</code> (<code>05</code> Analytics section).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-users-module"><a class="header" href="#01--product-overview-users-module">01 — Product Overview (Users Module)</a></h1> <ul> <li><a href="users/01_Product_Overview.html#1-what-the-module-is">1. What the module is</a></li> <li><a href="users/01_Product_Overview.html#2-scope-in--scope-out">2. Scope in / scope out</a></li> <li><a href="users/01_Product_Overview.html#3-status--lifecycle-model">3. Status / lifecycle model</a> <ul> <li><a href="users/01_Product_Overview.html#31-user-status-userschemats7-12">3.1 User status (<code>user.schema.ts:7-12</code>)</a></li> <li><a href="users/01_Product_Overview.html#32-soft-delete-lifecycle">3.2 Soft-delete lifecycle</a></li> <li><a href="users/01_Product_Overview.html#33-invited-status--what-exists-vs-what-is-missing">3.3 "Invited" status — what exists vs what is missing</a></li> </ul> </li> <li><a href="users/01_Product_Overview.html#4-bulk-import--two-real-paths">4. Bulk import — two real paths</a></li> <li><a href="users/01_Product_Overview.html#5-invites-roles-and-the-rbac-bridge">5. Invites, roles, and the RBAC bridge</a></li> <li><a href="users/01_Product_Overview.html#6-gdpr--data-retention-posture">6. GDPR & data-retention posture</a></li> <li><a href="users/01_Product_Overview.html#7-platform--client-scope-notes">7. Platform & client scope notes</a></li> <li><a href="users/01_Product_Overview.html#8-goals-product">8. Goals (product)</a></li> <li><a href="users/01_Product_Overview.html#9-non-goals-per-source">9. Non-goals (per source)</a></li> </ul> <blockquote> <p>Canonical identity and profile management. Derived from <code>studylyon-blueprint/04-Modules/Users.md</code>, <code>src/modules/users/**</code>, the auth module (<code>auth-account.schema.ts</code>, <code>auth.service.ts</code>), RBAC (<code>organization-member.schema.ts</code>), the bulk module (<code>bulk-import.service.ts</code>), <code>email.worker.ts</code>, <code>event-queue-map.ts</code>, <code>PLAN.md</code>, and <code>studylyon-blueprint/03-Database/COLLECTIONS.md</code>. Nothing in this doc is invented; plan-only capability is marked <code>(planned)</code>, client-only or roadmap-only capability <code>(forward-looking)</code>, analytics <code>(proposed)</code>.</p> </blockquote> <hr /> <h2 id="1-what-the-module-is-5"><a class="header" href="#1-what-the-module-is-5">1. What the module is</a></h2> <p>The Users module owns <strong>canonical identity</strong> for every person in a tenant: <code>users</code> stores profile data (name, contact, avatar, gender, DOB, language, timezone), notification/theme <strong>preferences</strong>, and a <strong>lifecycle status</strong>. It does <strong>not</strong> own authentication credentials (those live in <code>auth_accounts</code>), does <strong>not</strong> own business profiles (teacher/student/parent/staff profile rows reference <code>users._id</code>), and does <strong>not</strong> own role assignments (those live on <code>organization_members</code>).</p> <blockquote> <p>"<code>users</code> stores identity only — never passwords, attendance, or academic data." (<code>studylyon-blueprint/04-Modules/Users.md:63</code>)</p> </blockquote> <p>Key facts from source:</p> <ul> <li><code>users</code> schema: <code>user.schema.ts:14-79</code> — status enum <code>active | inactive | suspended | invited</code> (<code>user.schema.ts:7-12</code>, default <code>active</code> <code>:46-47</code>); unique <code>(tenantId, email)</code> (<code>:83</code>) and unique partial <code>(tenantId, phone)</code> (<code>:84-90</code>); indexes on <code>(tenantId, status)</code> and <code>(tenantId, displayName)</code> (<code>:91-92</code>).</li> <li>Every business document (including users) carries <code>tenantId</code>, soft-delete flags <code>isDeleted/deletedAt/deletedBy</code>, audit authors, and an optimistic-lock <code>version</code> (<code>base.schema.ts:9-31</code>). All queries are auto-scoped (<code>base.repository.ts:20-30</code>).</li> <li>Events: <code>UserCreated</code>, <code>UserUpdated</code>, <code>UserDeleted</code> (<code>events/user-events.ts:1-27</code>); routing <code>event-queue-map.ts:10-12</code> (<code>UserCreated</code> → in-app notification; <code>UserUpdated</code>/<code>UserDeleted</code> → audit-write).</li> <li>Permissions exist in <code>permissions.constants.ts:6-10</code>: <code>user.read</code>, <code>user.create</code>, <code>user.update</code>, <code>user.delete</code>, <code>user.import</code> (plus <code>rbac.member.*</code> <code>:15-18</code> for membership management).</li> </ul> <hr /> <h2 id="2-scope-in--scope-out-2"><a class="header" href="#2-scope-in--scope-out-2">2. Scope in / scope out</a></h2> <div class="table-wrapper"><table><thead><tr><th>In scope (implemented)</th><th>Out of scope (owned elsewhere)</th></tr></thead><tbody> <tr><td>User CRUD (create, list, get, patch, soft-delete)</td><td>Passwords / auth credentials → <code>auth_accounts</code> (<code>auth-account.schema.ts:10-56</code>)</td></tr> <tr><td>Preferences (notifications email/push/sms, theme light/dark/system + language)</td><td>Roles & memberships → <code>organization_members</code> (<code>organization-member.schema.ts:13-44</code>)</td></tr> <tr><td>Avatar upload via StorageProvider</td><td>Teacher/student/staff/parent profile data → their modules (<code>RELATIONSHIPS.md:13,22-32</code>)</td></tr> <tr><td>GDPR erasure endpoint + scheduled hard purge</td><td>Multi-channel notifications → Notifications module</td></tr> <tr><td>Bulk CSV import (inline <code>/users/import</code>)</td><td>Invite acceptance / set-password flow → <code>(planned)</code> (no endpoint in code)</td></tr> <tr><td>RBAC member management (used as the "invite" wiring)</td><td>Analytics → <code>(proposed)</code> (no SDK chosen, <a href="users/../00-shared/12_Assumptions_&_Open_Questions.html">00-shared/12 A4</a>)</td></tr> </tbody></table> </div> <hr /> <h2 id="3-status--lifecycle-model"><a class="header" href="#3-status--lifecycle-model">3. Status / lifecycle model</a></h2> <h3 id="31-user-status-userschemats7-12"><a class="header" href="#31-user-status-userschemats7-12">3.1 User status (<code>user.schema.ts:7-12</code>)</a></h3> <div class="table-wrapper"><table><thead><tr><th>Status</th><th>Meaning</th><th>Set by</th></tr></thead><tbody> <tr><td><code>active</code></td><td>Default on create (<code>user.schema.ts:46-47</code>; also <code>CreateUserDto</code> default <code>create-user.dto.ts:44-47</code>)</td><td>create / PATCH <code>status</code></td></tr> <tr><td><code>inactive</code></td><td>Deactivated, can be re-activated via PATCH</td><td>PATCH <code>status</code></td></tr> <tr><td><code>suspended</code></td><td>Suspended (e.g. discipline); re-activatable</td><td>PATCH <code>status</code></td></tr> <tr><td><code>invited</code></td><td>Awaiting acceptance — <strong>enum exists, no endpoint sets it today</strong></td><td><code>(planned)</code> invite flow</td></tr> </tbody></table> </div> <h3 id="32-soft-delete-lifecycle"><a class="header" href="#32-soft-delete-lifecycle">3.2 Soft-delete lifecycle</a></h3> <ul> <li><code>DELETE /users/:id</code> → <code>softDelete()</code> sets <code>isDeleted:true, deletedAt, deletedBy</code> (<code>base.repository.ts:68-74</code>; <code>users.service.ts:173-184</code>) and emits <code>UserDeleted</code> → <code>audit-write</code> queue (<code>event-queue-map.ts:12</code>).</li> <li>Soft-deleted users are <strong>invisible</strong> to every scoped query (<code>base.repository.ts:20-30</code>) and <strong>cannot log in</strong> — login uses <code>findByEmailUnscoped</code> which filters <code>isDeleted:false</code> (<code>users.repository.ts:21-25</code>, <code>auth.service.ts:124</code>).</li> <li>Hard purge: <code>TENANT_PURGE</code> worker deletes all soft-deleted docs older than <strong>30 days</strong> (<code>tenant-purge.worker.ts:15,32-43</code>) — idempotent (only docs already <code>isDeleted</code> before the cutoff).</li> <li><strong>GDPR erasure</strong> (<code>POST /users/:id/erasure</code>, <code>users.controller.ts:69-76</code>): anonymizes PII in place (<code>firstName=Erased</code>, <code>lastName=User</code>, <code>displayName=Erased User</code>, <code>email=erased-<id>@anonymized.invalid</code>, <code>isDeleted=true</code> — <code>users.service.ts:187-197</code>) and enqueues a <code>gdpr-erasure</code> job on the <code>tenant-purge</code> queue (<code>attempts: 3</code>, exponential backoff 5000 ms — <code>users.service.ts:200-209</code>); the worker hard-deletes the user doc (<code>tenant-purge.worker.ts:49-56</code>).</li> </ul> <h3 id="33-invited-status--what-exists-vs-what-is-missing"><a class="header" href="#33-invited-status--what-exists-vs-what-is-missing">3.3 "Invited" status — what exists vs what is missing</a></h3> <p>Exists in code:</p> <ul> <li><code>UserStatus.INVITED</code> enum (<code>user.schema.ts:11</code>).</li> <li><code>MemberStatus.INVITED</code> + <code>invitedBy</code> + <code>acceptedAt</code> on <code>organization_members</code> (<code>organization-member.schema.ts:7-11,33-38</code>).</li> <li><code>auth_accounts.emailVerificationToken</code> + <code>resend-verification</code> endpoint (<code>auth-account.schema.ts:39-40</code>; <code>auth.controller.ts:83-89</code>) — the closest live mechanism: a created user gets a verification email with a token (<code>email.worker.ts:26-32</code>), but only on <strong>self-registration</strong> (<code>auth.service.ts:101-118</code>), not on admin-created users.</li> </ul> <p>Missing in code (hence <code>(planned)</code>):</p> <ul> <li>No endpoint sets <code>status=invited</code>; <code>UsersService.create</code> always leaves the DTO default (<code>create-user.dto.ts:44-47</code>).</li> <li>No invite email (email worker handles only <code>UserRegistered</code> and <code>PasswordResetRequested</code> — <code>email.worker.ts:25-42</code>; no <code>UserInvited</code> handler, no <code>event-queue-map</code> route).</li> <li>No accept-invite / set-password endpoint.</li> </ul> <p><strong>Product conclusion:</strong> the "invite" journey is designed below as the create-user + <code>POST /rbac/members</code> combination, with the acceptance e-mail explicitly <code>(planned)</code>; UI must not promise an email that the backend does not send yet (see <a href="users/12_API_Mapping.html">12_API_Mapping.md</a> E5 note and <a href="users/14_QA_Checklist.html">14_QA_Checklist.md</a>).</p> <hr /> <h2 id="4-bulk-import--two-real-paths"><a class="header" href="#4-bulk-import--two-real-paths">4. Bulk import — two real paths</a></h2> <p>There are <strong>two distinct CSV import implementations</strong> in source. Both are synchronous; there is <strong>no async job and no result-polling endpoint today</strong>:</p> <div class="table-wrapper"><table><thead><tr><th>Path</th><th>Endpoint</th><th>Parser</th><th>Report shape</th><th>Errors shape</th></tr></thead><tbody> <tr><td>A. Users inline</td><td><code>POST /api/v1/users/import</code> (multipart field <code>file</code>) — <code>users.controller.ts:104-110</code></td><td>naive <code>split(',')</code> line parser (<code>users.service.ts:236-252</code>)</td><td><code>{imported, errors}</code></td><td>flat <code>string[]</code> — <code>Row N: message</code> (<code>users.service.ts:281</code>)</td></tr> <tr><td>B. Bulk module adapter</td><td><code>POST /api/v1/bulk/import/:entity</code> — <code>bulk.controller.ts:35-48</code></td><td><code>csv-parse/sync</code> (quoted fields, trim, skip empty) — <code>bulk-import.service.ts:26-30</code></td><td><code>ImportReport {entity, totalRows, imported, failed, errors}</code></td><td><code>{rowNumber, errors[]}</code> (<code>import-adapter.interface.ts:14-25</code>)</td></tr> </tbody></table> </div> <ul> <li>Path B today ships <strong>one adapter: <code>students</code></strong> (<code>students-import.adapter.ts:15</code>), which internally <strong>creates users</strong> via <code>UsersService.create</code> for each row (<code>students-import.adapter.ts:66-71</code>). There is <strong>no <code>users</code> adapter</strong> — the service throws <code>No import adapter for entity "users"</code> (<code>bulk-import.service.ts:17-20</code>). <code>(planned)</code>: register a users adapter in the bulk module (the service explicitly notes the registry is deferred: <code>bulk-import.service.ts:15-16</code>).</li> <li>Path A rules (exact, <code>users.service.ts:250-279</code>): header row required + ≥ 1 data row (<code>:238-243</code>); headers lowercased/trimmed (<code>:244-247</code>); per row — missing email → error; email already exists → error; create with <code>firstName=firstname|first_name|'Unknown'</code>, <code>lastName=lastname|last_name|'Unknown'</code>, <code>phone</code>, <code>gender</code>, <code>language</code> (default <code>en</code>), <code>timezone</code> (default <code>UTC</code>).</li> <li>Path A imports <strong>do not emit <code>UserCreated</code> events</strong> (rows call <code>repo.create</code> directly — <code>users.service.ts:264-272</code>), unlike single create. <code>PLAN.md 2.7</code> plans a queue/worker path (<code>POST /api/v1/files/upload-csv</code> → <code>UserCreated</code> per row) — <code>(planned)</code>.</li> </ul> <hr /> <h2 id="5-invites-roles-and-the-rbac-bridge"><a class="header" href="#5-invites-roles-and-the-rbac-bridge">5. Invites, roles, and the RBAC bridge</a></h2> <p>"Inviting" someone into the tenant is a two-document operation today:</p> <ol> <li><code>POST /api/v1/users</code> creates the identity (status <code>active</code> by default).</li> <li><code>POST /api/v1/rbac/members</code> <code>{userId, roles[]}</code> creates the membership (<code>rbac.controller.ts:63-67</code>; <code>rbac.service.ts:113-127</code> — status hard-set <code>ACTIVE</code>, <code>joinedAt</code> now; <code>MemberStatus.INVITED</code> exists in schema but is not used by the service).</li> </ol> <p>Membership is what grants roles; roles carry permissions (<code>rbac.service.ts:59-65</code>; permission cache in Redis TTL 300 s <code>:44-73</code>). The <strong>users screen must therefore be built as a user + membership composite</strong> — roles shown on user rows come from <code>GET /rbac/members</code> (<code>rbac.controller.ts:57-61</code>), not from <code>GET /users</code>.</p> <hr /> <h2 id="6-gdpr--data-retention-posture"><a class="header" href="#6-gdpr--data-retention-posture">6. GDPR & data-retention posture</a></h2> <ul> <li>PII (email, phone, avatar) lives in <code>users</code> (<code>studylyon-blueprint/03-Database/DATA_RETENTION.md:69</code>); retention: life of tenancy, soft-delete (<code>DATA_RETENTION.md:22</code>).</li> <li>Erasure: anonymize + soft-delete immediately, hard-delete via purge job (<code>users.service.ts:186-210</code>).</li> <li>Blueprint: "PII masked in audit snapshots" (<code>Users.md:65</code>) and "deleting a user cascades (async) to dependent profiles" (<code>Users.md:67</code>) — cascade is <code>(planned)</code> (no cascade code; the purge worker deletes the user doc only, <code>tenant-purge.worker.ts:49-56</code>).</li> </ul> <hr /> <h2 id="7-platform--client-scope-notes"><a class="header" href="#7-platform--client-scope-notes">7. Platform & client scope notes</a></h2> <ul> <li><strong>Mobile client is a forward-looking spec.</strong> The PRD explicitly excludes native mobile apps from Phase 1 ("Native mobile apps (web-first)", <code>PRODUCT_REQUIREMENTS_DOCUMENT.md:144</code>); the shared ledger flags the whole Flutter design set as forward-looking (<a href="users/../00-shared/12_Assumptions_&_Open_Questions.html">00-shared/12 A1</a>). All screens, routes, and Flutter implementation guidance in this doc set target the web-first responsive client (web/desktop/tablet/phone layouts) and are <code>(forward-looking)</code> by extension.</li> <li>API is <code>v1</code>, Bearer JWT, tenant from token only (<a href="users/../00-shared/07_API_Conventions.html">00-shared/07 §1,§6</a>); wire contract and error codes per <a href="users/../00-shared/07_API_Conventions.html">00-shared/07 §2-§3</a> (success <code>{success:true,message:"OK",data,meta?,timestamp,requestId}</code>; codes 400/401/ 403/404/409/422/429/5xx).</li> </ul> <hr /> <h2 id="8-goals-product"><a class="header" href="#8-goals-product">8. Goals (product)</a></h2> <ol> <li><strong>Admin can see and manage every person in the tenant</strong> — searchable, paginated list; detail; edit; deactivate; erase (GDPR).</li> <li><strong>Bulk onboarding</strong> — 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.</li> <li><strong>Self-service profile & preferences</strong> — user edits own display data, notification toggles, theme.</li> <li><strong>Status lifecycle clarity</strong> — <code>active / inactive / suspended / invited</code> surfaced with unambiguous copy; soft-delete as the safe default removal.</li> <li><strong>Invite path</strong> — create identity + membership, then invite acceptance <code>(planned)</code>.</li> </ol> <h2 id="9-non-goals-per-source"><a class="header" href="#9-non-goals-per-source">9. Non-goals (per source)</a></h2> <ul> <li>No passwords / auth flows in this module (auth module owns them).</li> <li>No profile photos stored in MongoDB — delegated to <code>StorageProvider</code> (<code>users.service.ts:217-222</code>).</li> <li>No role/permission editing here — RBAC module.</li> <li>No async import job yet — import is request-synchronous.</li> <li>No analytics instrumentation in this module <code>(proposed)</code>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-users-module"><a class="header" href="#02--user-personas-users-module">02 — User Personas (Users Module)</a></h1> <ul> <li><a href="users/02_User_Personas.html#1-tenant-personas-acting-on-users">1. Tenant personas acting on users</a> <ul> <li><a href="users/02_User_Personas.html#p1--org-admin-the-user-manager">P1 — Org Admin (the user manager)</a></li> <li><a href="users/02_User_Personas.html#p2--hr--operations-staff-user-manager-delegate">P2 — HR / Operations Staff (user manager delegate)</a></li> <li><a href="users/02_User_Personas.html#p3--teacher--staff--parent-as-managed-users">P3 — Teacher / Staff / Parent as managed users</a></li> <li><a href="users/02_User_Personas.html#p4--self-service-user-any-role">P4 — Self-service user (any role)</a></li> </ul> </li> <li><a href="users/02_User_Personas.html#2-platform-persona">2. Platform persona</a> <ul> <li><a href="users/02_User_Personas.html#p5--super-admin-platform">P5 — Super Admin (platform)</a></li> </ul> </li> <li><a href="users/02_User_Personas.html#3-system-personas">3. System personas</a> <ul> <li><a href="users/02_User_Personas.html#p6--email-engine-worker">P6 — Email Engine (worker)</a></li> <li><a href="users/02_User_Personas.html#p7--purge-worker-gdpr">P7 — Purge Worker (GDPR)</a></li> <li><a href="users/02_User_Personas.html#p8--audit-logger">P8 — Audit Logger</a></li> <li><a href="users/02_User_Personas.html#p9--in-app-notifications">P9 — In-app Notifications</a></li> </ul> </li> <li><a href="users/02_User_Personas.html#4-persona--screen-map">4. Persona → screen map</a></li> </ul> <blockquote> <p>Persona base from <code>studylyon-blueprint/01-Product/USER_PERSONAS.md</code>, refined for the Users module. Each persona lists module-specific goals, pains, and the exact capabilities the backend gives them (with <code>file:line</code>).</p> </blockquote> <hr /> <h2 id="1-tenant-personas-acting-on-users"><a class="header" href="#1-tenant-personas-acting-on-users">1. Tenant personas acting on users</a></h2> <h3 id="p1--org-admin-the-user-manager"><a class="header" href="#p1--org-admin-the-user-manager">P1 — Org Admin (the user manager)</a></h3> <blockquote> <p>"Manages a single institution (tenant)... Goals: configure branches, invite staff, oversee operations." (<code>USER_PERSONAS.md:22-25</code>)</p> </blockquote> <ul> <li><strong>Role in this module:</strong> primary operator of every Users screen.</li> <li><strong>Permissions granted by code:</strong> <code>user.read/create/update/delete/import</code> + <code>rbac.member.*</code> exist in the permission catalogue (<code>permissions.constants.ts:6-10,15-18</code>); register seeds the <code>org_admin</code> role (<code>auth.service.ts:84-99</code>).</li> <li><strong>Goals</strong> <ul> <li>Create a user (staff, teacher, parent) in seconds — <code>POST /users</code> (<code>users.controller.ts:36-40</code>).</li> <li>Find anyone: <code>GET /users?q=</code> regex over first/last name, email, displayName, case-insensitive (<code>users.service.ts:92-99</code>).</li> <li>Bulk-import a term-start spreadsheet: <code>POST /users/import</code> (<code>users.controller.ts:104-110</code>) and get a per-row error list.</li> <li>Grant/change roles: <code>POST/PATCH /rbac/members</code> (<code>rbac.controller.ts:63-73</code>).</li> <li>Deactivate or erase (GDPR): <code>DELETE /users/:id</code>, <code>POST /users/:id/erasure</code>.</li> </ul> </li> <li><strong>Pains</strong> <ul> <li>Duplicate emails are hard-rejected (409 — <code>users.service.ts:50-54,120-126</code>); a typo blocks the whole row in import (<code>Row N: email "x" already exists</code> — <code>users.service.ts:260-263</code>).</li> <li>No status/role filter on <code>GET /users</code> (only <code>q</code>, <code>sort</code>, pagination — <code>users.service.ts:90-115</code>) → filters are <code>(planned)</code>.</li> <li>Import runs synchronously; a 1,000-row file blocks the request (<code>users.service.ts:233-282</code>) — see <a href="users/14_QA_Checklist.html">14_QA_Checklist.md</a> scenario C1.</li> </ul> </li> <li><strong>Success metric:</strong> add a staff member in < 60 s; import 500 rows with all errors locatable to a row number.</li> </ul> <h3 id="p2--hr--operations-staff-user-manager-delegate"><a class="header" href="#p2--hr--operations-staff-user-manager-delegate">P2 — HR / Operations Staff (user manager delegate)</a></h3> <blockquote> <p>Staff persona: "operations, reception, coordinators... often read-heavy" (<code>USER_PERSONAS.md:34-39</code>).</p> </blockquote> <ul> <li><strong>Role:</strong> day-to-day user-list operator — new joiner intake, record cleanup, status upkeep. Same screens as P1 but narrower permissions (e.g. <code>user.read</code> + <code>user.create</code>, no <code>user.delete</code>).</li> <li><strong>Goals:</strong> search (name/email/displayName), view detail, update contact info (<code>PATCH /users/:id</code>), run the CSV import for new batches.</li> <li><strong>Pains:</strong> needs row-level role context on the users list (membership is a separate API — <code>GET /rbac/members</code>, <code>rbac.controller.ts:57-61</code>); without the join the list shows identities only.</li> <li><strong>Constraints enforced by code:</strong> soft-delete hides users from every list (<code>base.repository.ts:20-30</code>); if HR "removes" someone, they vanish — the UI must offer erasure vs. status <code>inactive</code> as distinct, labeled choices.</li> </ul> <h3 id="p3--teacher--staff--parent-as-managed-users"><a class="header" href="#p3--teacher--staff--parent-as-managed-users">P3 — Teacher / Staff / Parent as <strong>managed users</strong></a></h3> <blockquote> <p>Teacher: "wants minimal clicks; mobile-friendly" (<code>USER_PERSONAS.md:29-32</code>); Parent: "wants proactive, timely communication" (<code>USER_PERSONAS.md:47-50</code>).</p> </blockquote> <ul> <li><strong>Role:</strong> subject of admin management; consume self-service profile.</li> <li><strong>What they can do today (self-service):</strong> <ul> <li>Update their own profile data — <code>PATCH /users/:id</code> accepts name/email/ phone/gender/DOB/language/timezone/status/metadata (<code>update-user.dto.ts:5-69</code>). <em>(Note: the controller does not restrict <code>:id</code> to self — client must enforce; server-side self-guard <code>(planned)</code>, OQ-3.)</em></li> <li>Preferences: <code>GET/PATCH /users/:id/preferences</code> (<code>users.controller.ts:78-93</code>) — notification toggles and theme.</li> <li>Avatar: <code>POST /users/:id/avatar</code> (<code>users.controller.ts:95-102</code>).</li> </ul> </li> <li><strong>Pains</strong> <ul> <li>Email change collides if the address is taken — 409 inline (<code>users.service.ts:120-126</code>).</li> <li>Email verification state: <code>auth_accounts.emailVerifiedAt</code> is written by <code>verifyEmail</code> (<code>auth.service.ts:217-223</code>) but the user-level <code>emailVerified</code> flag on the schema (<code>user.schema.ts:74-75</code>) is never updated by that path — UI must not rely on it (OQ-4).</li> </ul> </li> </ul> <h3 id="p4--self-service-user-any-role"><a class="header" href="#p4--self-service-user-any-role">P4 — Self-service user (any role)</a></h3> <ul> <li>Same capabilities as P3 minus admin screens; cares about theme (<code>preferences.theme.mode: light|dark|system</code> — <code>update-user-preferences.dto.ts:16-20</code>), notification channels (<code>email/push/sms</code> booleans — <code>:8-12</code>), and privacy.</li> <li><strong>Pain:</strong> no "account" endpoint that returns <em>own</em> profile — the client resolves self by the JWT <code>sub</code> and calls <code>GET /users/:id</code> (OQ-3).</li> </ul> <hr /> <h2 id="2-platform-persona"><a class="header" href="#2-platform-persona">2. Platform persona</a></h2> <h3 id="p5--super-admin-platform-1"><a class="header" href="#p5--super-admin-platform-1">P5 — Super Admin (platform)</a></h3> <blockquote> <p>"Operates the StudyLyon SaaS itself... needs global visibility without touching tenant data" (<code>USER_PERSONAS.md:10-14</code>).</p> </blockquote> <ul> <li><strong>Role in this module:</strong> oversight only — user data stays tenant-scoped. Platform admin bypasses tenant scope in repositories (<code>base.repository.ts:21-23</code>) but <code>isPlatformAdmin</code> is a token flag; there is <strong>no platform users screen</strong> in the module surface (<a href="users/05_Screen_Inventory.html">05_Global_IA</a> has no platform section for users). Platform view of users <code>(planned)</code> if ever needed (audit use-case).</li> </ul> <hr /> <h2 id="3-system-personas"><a class="header" href="#3-system-personas">3. System personas</a></h2> <h3 id="p6--email-engine-worker"><a class="header" href="#p6--email-engine-worker">P6 — Email Engine (worker)</a></h3> <ul> <li>Processes <code>emails</code> queue (<code>queue.constants.ts:2</code>); handles only <code>UserRegistered</code> ("Welcome to StudyLyon" + verify token) and <code>PasswordResetRequested</code> (<code>email.worker.ts:25-42</code>).</li> <li><strong>Consequence:</strong> admin-created users get <strong>no welcome/invite email today</strong> (invite mail <code>(planned)</code>); onboarding UI must say "account created" without promising an e-mail.</li> </ul> <h3 id="p7--purge-worker-gdpr"><a class="header" href="#p7--purge-worker-gdpr">P7 — Purge Worker (GDPR)</a></h3> <ul> <li><code>tenant-purge</code> queue (<code>queue.constants.ts:12</code>); erases user doc on <code>gdpr-erasure</code> job (<code>tenant-purge.worker.ts:49-56</code>); purges soft-deleted docs > 30 days (<code>:32-43</code>). Idempotent by design (<code>:26</code>).</li> </ul> <h3 id="p8--audit-logger"><a class="header" href="#p8--audit-logger">P8 — Audit Logger</a></h3> <ul> <li>Consumes <code>audit-write</code> for <code>UserUpdated</code>/<code>UserDeleted</code> (<code>event-queue-map.ts:11-12</code>); immutable per <code>PLAN.md 19.3</code>.</li> </ul> <h3 id="p9--in-app-notifications"><a class="header" href="#p9--in-app-notifications">P9 — In-app Notifications</a></h3> <ul> <li><code>UserCreated</code> → <code>in-app</code> queue job <code>user-created-notification</code> (<code>event-queue-map.ts:10</code>) — admin gets notified of creations, <strong>but not of bulk-import rows</strong> (those bypass the event — <code>users.service.ts:264-272</code>).</li> </ul> <hr /> <h2 id="4-persona--screen-map"><a class="header" href="#4-persona--screen-map">4. Persona → screen map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Persona</th><th>Screens (<a href="users/05_Screen_Inventory.html">05_Screen_Inventory.md</a>)</th><th>Permissions used</th></tr></thead><tbody> <tr><td>P1 Org Admin</td><td>Users list, detail, create, edit, bulk import, preferences (as manager)</td><td><code>user.*</code>, <code>rbac.member.*</code></td></tr> <tr><td>P2 HR</td><td>Users list, detail, edit, bulk import</td><td><code>user.read</code>, <code>user.update/create</code></td></tr> <tr><td>P3/P4 Teacher/Staff/Parent</td><td>Self-profile, preferences, avatar</td><td>self-scoped <code>GET/PATCH /users/:id</code> (client-enforced)</td></tr> <tr><td>P5 Super Admin</td><td>none today (tenant data)</td><td><code>isPlatformAdmin</code></td></tr> <tr><td>P6–P9</td><td>none (background)</td><td>—</td></tr> </tbody></table> </div> <blockquote> <p>Gap note: roles on the users list come from <code>GET /rbac/members</code> (<code>rbac.controller.ts:57-61</code>), which requires <code>org_admin</code> role (<code>rbac.controller.ts:21-22</code>) — a read-only <code>user.read</code> user cannot resolve role chips. Role display for non-admin viewers <code>(planned)</code>.</p> </blockquote> <div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journeys-users-module"><a class="header" href="#03--user-journeys-users-module">03 — User Journeys (Users Module)</a></h1> <ul> <li><a href="users/03_User_Journey.html#1-invite-a-user-admin-creates-identity--membership">1. Invite a user (admin creates identity + membership)</a></li> <li><a href="users/03_User_Journey.html#2-bulk-csv-import-users-with-progress">2. Bulk CSV import (users) with progress</a></li> <li><a href="users/03_User_Journey.html#3-edit-profile--preferences-self-service">3. Edit profile & preferences (self-service)</a></li> <li><a href="users/03_User_Journey.html#4-deactivate--soft-delete--gdpr-erasure">4. Deactivate / soft-delete / GDPR erasure</a></li> <li><a href="users/03_User_Journey.html#5-import-failures--recovery">5. Import failures & recovery</a></li> <li><a href="users/03_User_Journey.html#journey-matrix-who--where--what">Journey matrix (who / where / what)</a></li> </ul> <blockquote> <p>End-to-end journeys computed from <code>users.controller.ts</code>, <code>users.service.ts</code>, <code>bulk.controller.ts</code> / <code>bulk-import.service.ts</code> / <code>students-import.adapter.ts</code>, <code>rbac.controller.ts</code>, <code>auth.service.ts</code>, and <code>tenant-purge.worker.ts</code>. Each journey: entry, intent, decision points, system responses, failures, recovery, exit, permission denial, offline, conflict, multi-device, deep-link/push/email entry. <code>(planned)</code> / <code>(forward-looking)</code> per global rules.</p> </blockquote> <hr /> <h2 id="1-invite-a-user-admin-creates-identity--membership"><a class="header" href="#1-invite-a-user-admin-creates-identity--membership">1. Invite a user (admin creates identity + membership)</a></h2> <pre><code>entry: /users → FAB "Add user" intent: onboard a new staff member / teacher / parent with roles </code></pre> <pre class="mermaid">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. </pre> <ul> <li><strong>Decision points:</strong> status (default <code>active</code> — <code>create-user.dto.ts:44-47</code>); roles to assign (via RBAC step); whether to send verification email — today <strong>not sent</strong> for admin-created users (OQ-2).</li> <li><strong>Failures/recovery:</strong> email/phone dup → inline conflict (<code>users.service.ts:50-60</code>); RBAC member create failing after user create → user exists without roles (compensate: retry membership; note OQ-5).</li> <li><strong>Exit:</strong> success → list (refresh); back → draft warning.</li> <li><strong>Permission denial:</strong> no <code>user.create</code> → FAB hidden + 403 screen (client guard; server RBAC guard <code>(planned)</code> — only <code>JwtAuthGuard</code> today, <code>users.controller.ts:31</code>).</li> <li><strong>Multi-device:</strong> second admin sees the row on refresh; membership visible via <code>GET /rbac/members</code>.</li> <li><strong>Deep links / email entry:</strong> <code>(forward-looking)</code> invite email with accept-link; no accept endpoint exists yet.</li> </ul> <hr /> <h2 id="2-bulk-csv-import-users-with-progress"><a class="header" href="#2-bulk-csv-import-users-with-progress">2. Bulk CSV import (users) with progress</a></h2> <pre><code>entry: /users → "Import CSV" (bulk wizard) intent: onboard N users from a spreadsheet </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>Progress semantics:</strong> the endpoint is <strong>synchronous</strong> — 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 <code>(planned)</code> per <code>PLAN.md 2.7</code> (<code>POST /api/v1/files/upload-csv</code> → per-row <code>UserCreated</code>).</li> <li><strong>Validation facts shown in preview:</strong> required columns <code>email, firstname|first_name, lastname|last_name</code>; optional <code>phone, gender, language, timezone</code> (<code>users.service.ts:264-271</code>); within-file duplicate emails are caught sequentially (<code>:260-263</code>); quoted commas are <strong>not</strong> supported by the naive parser (<code>:251</code> — <code>split(',')</code>) — warn on quotes (OQ-7).</li> <li><strong>Partial-import semantics:</strong> 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 (<code>:260-263</code>) — user must delete those rows or accept the errors.</li> <li><strong>Idempotency:</strong> retrying the same file is safe (duplicates are rejected, not duplicated) — but each retry returns the dupes as errors (see <code>14_QA_Checklist.md C2</code>).</li> <li><strong>Timeout:</strong> for very large files the request may exceed gateway timeouts — client guidance ≤ 1000 rows (see <code>14_QA_Checklist.md C1</code>); upload timeout 120 s (<code>00-shared/11 §5</code>).</li> </ul> <hr /> <h2 id="3-edit-profile--preferences-self-service"><a class="header" href="#3-edit-profile--preferences-self-service">3. Edit profile & preferences (self-service)</a></h2> <pre><code>entry: avatar menu → Profile (self) — or admin: user row → Edit intent: update contact info / language / timezone / notification toggles / theme </code></pre> <pre class="mermaid">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! </pre> <ul> <li><strong>Critical contract:</strong> preferences are <strong>replaced</strong> (<code>$set: {preferences: dto}</code>, <code>users.service.ts:161-163</code>) — the client must PATCH the full merged preferences object; a partial payload wipes unmentioned sections.</li> <li><strong>Optimistic UI:</strong> safe toggles (theme, notification switches) may be optimistic with rollback (<code>00-shared/06 §3.5</code>); email/phone changes are never optimistic (conflict-prone, <code>00-shared/07 §9</code>).</li> <li><strong>Self vs managed:</strong> controller accepts any <code>:id</code>; client restricts "self profile" to <code>sub == :id</code> (server-side self guard <code>(planned)</code>, OQ-3).</li> <li><strong>Avatar:</strong> separate multipart flow <code>POST /users/:id/avatar</code> (<code>users.controller.ts:95-102</code>); old avatar deleted best-effort (<code>users.service.ts:227-229</code>).</li> </ul> <hr /> <h2 id="4-deactivate--soft-delete--gdpr-erasure"><a class="header" href="#4-deactivate--soft-delete--gdpr-erasure">4. Deactivate / soft-delete / GDPR erasure</a></h2> <pre><code>entry: user row → menu → "Deactivate" | "Delete user" | "GDPR erase" intent: stop access; remove from lists; comply with right-to-erasure </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>Status vs delete distinction (must be explicit in UI):</strong> <ul> <li><code>inactive</code> — record visible in admin lists, can be re-activated, <strong>can still log in</strong> if an <code>auth_account</code> exists (<code>auth.service.ts:124-137</code> checks only <code>isDeleted</code>).</li> <li>Soft-deleted — <strong>cannot log in</strong> (<code>findByEmailUnscoped</code> filters <code>isDeleted:false</code>, <code>users.repository.ts:21-25</code>), invisible everywhere, hard-purged after 30 days (<code>tenant-purge.worker.ts:32-43</code>).</li> <li>Erased — anonymized immediately, hard-deleted by job.</li> </ul> </li> <li><strong>Active sessions caveat:</strong> deleting/erasing a user does <strong>not</strong> revoke existing JWTs/sessions (no session revocation call — OQ-8); <code>logout-all</code> exists (<code>auth.controller.ts:66-72</code>) but is not called by user deletion.</li> <li><strong>Failures:</strong> 404 if already gone; network loss after confirm → retry is idempotent (DELETE is safe to retry).</li> <li><strong>Permission:</strong> <code>user.delete</code> for delete; erasure uses the same permission surface today (no separate perm) — note in UI copy.</li> </ul> <hr /> <h2 id="5-import-failures--recovery"><a class="header" href="#5-import-failures--recovery">5. Import failures & recovery</a></h2> <pre><code>entry: bulk wizard → result screen with errors → fix & re-run </code></pre> <pre class="mermaid">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 </pre> <ul> <li><strong>Recovery rules:</strong> partial imports are expected; the UI must never imply atomicity (server commits row-by-row — <code>users.service.ts:250-279</code>); error list is authoritative; cross-check imported count vs. expected.</li> <li><strong>Import via students adapter</strong> (bulk module) reports richer errors <code>{rowNumber, errors[]}</code> + <code>totalRows/imported/failed</code> (<code>import-adapter.interface.ts:14-25</code>) and pre-validates refs (<code>students-import.adapter.ts:38-60</code>); entity selector in the wizard <code>(planned)</code> for a <code>users</code> adapter (only <code>students</code> exists — <code>bulk-import.service.ts:17-20</code>).</li> <li><strong>Exit:</strong> result screen can be closed; import is already committed — no cancel path (synchronous).</li> </ul> <hr /> <h2 id="journey-matrix-who--where--what"><a class="header" href="#journey-matrix-who--where--what">Journey matrix (who / where / what)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Journey</th><th>Entry</th><th>Primary screens</th><th>Backend truth</th></tr></thead><tbody> <tr><td>Invite user</td><td><code>/users</code> FAB</td><td>CreateUser + RBAC roles sheet</td><td><code>POST /users</code>, <code>POST /rbac/members</code></td></tr> <tr><td>Bulk import</td><td><code>/users</code> "Import CSV"</td><td>ImportWizard (upload→preview→result)</td><td><code>POST /users/import</code> (sync)</td></tr> <tr><td>Edit profile/prefs</td><td>Avatar menu / row edit</td><td>EditUser, Preferences</td><td><code>PATCH /users/:id</code>, <code>PATCH /users/:id/preferences</code></td></tr> <tr><td>Deactivate / delete / erase</td><td>Row menu / detail</td><td>Confirm dialogs</td><td><code>PATCH status</code>, <code>DELETE /users/:id</code>, <code>POST /users/:id/erasure</code></td></tr> <tr><td>Import recovery</td><td>Result screen</td><td>Error table + re-upload</td><td>retry <code>POST /users/import</code></td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-users-module"><a class="header" href="#04--information-architecture-users-module">04 — Information Architecture (Users Module)</a></h1> <ul> <li><a href="users/04_Information_Architecture.html#1-navigation-placement">1. Navigation placement</a></li> <li><a href="users/04_Information_Architecture.html#2-screen-hierarchy-admin-surface">2. Screen hierarchy (admin surface)</a></li> <li><a href="users/04_Information_Architecture.html#3-screen-hierarchy-self-service-surface">3. Screen hierarchy (self-service surface)</a></li> <li><a href="users/04_Information_Architecture.html#4-modal-hierarchy-per-00-shared05-5">4. Modal hierarchy (per <code>00-shared/05 §5</code>)</a></li> <li><a href="users/04_Information_Architecture.html#5-screen-relationships">5. Screen relationships</a></li> <li><a href="users/04_Information_Architecture.html#6-routes--deep-links">6. Routes & deep links</a></li> <li><a href="users/04_Information_Architecture.html#7-nested-nav--state-survival">7. Nested nav & state survival</a></li> <li><a href="users/04_Information_Architecture.html#8-quick-actions-fabs-context-menus">8. Quick actions, FABs, context menus</a></li> <li><a href="users/04_Information_Architecture.html#9-global-search-integration">9. Global search integration</a></li> <li><a href="users/04_Information_Architecture.html#10-filters--sorting-list-semantics">10. Filters & sorting (list semantics)</a></li> </ul> <blockquote> <p>Module IA within the global shell (<a href="users/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a>). The module owns the <strong>Users & Roles</strong> surface (<code>/users</code>, <a href="users/05_Screen_Inventory.html">05_Global_Information_Architecture.md:47</a>) for admin/HR, plus the <strong>account/profile self-service</strong> surface (<a href="users/05_Screen_Inventory.html">05_Global_IA §8</a>: avatar menu → profile, password, 2FA, devices, preferences). Routes are <code>(planned)</code> — no client repo exists; they follow <code>go_router</code> conventions (<a href="users/../00-shared/11_Flutter_App_Architecture.html">00-shared/11 §6</a>).</p> </blockquote> <hr /> <h2 id="1-navigation-placement-1"><a class="header" href="#1-navigation-placement-1">1. Navigation placement</a></h2> <div class="table-wrapper"><table><thead><tr><th>Surface</th><th>Destination</th><th>Shell slot</th><th>Visible to</th></tr></thead><tbody> <tr><td>Users management</td><td><code>/users</code></td><td>Settings group: "Users & Roles" (<a href="users/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §2</a>)</td><td>Org Admin + HR/custom roles with <code>user.read</code>; full management needs <code>user.create/update/delete/import</code></td></tr> <tr><td>Roles & memberships</td><td><code>/roles</code></td><td>same group</td><td><code>org_admin</code> (RBAC controller is <code>org_admin</code>-gated — <code>rbac.controller.ts:21-22</code>)</td></tr> <tr><td>Self profile</td><td>avatar menu → <code>/settings/profile</code></td><td>cross-cutting account surface (<a href="users/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §8</a>)</td><td>all authenticated users</td></tr> </tbody></table> </div> <ul> <li>Role-gating note: <code>GET /rbac/members</code> requires <code>org_admin</code> (<code>rbac.controller.ts:21-22</code>); 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 <code>(planned)</code> server-side join.</li> </ul> <h2 id="2-screen-hierarchy-admin-surface"><a class="header" href="#2-screen-hierarchy-admin-surface">2. Screen hierarchy (admin surface)</a></h2> <pre><code>/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) </code></pre> <ul> <li>Master-detail on tablet/desktop (≥ 840 dp, <code>00-shared/05 §3</code>); push-on-top on phone; breadcrumbs on desktop at depth ≥ 2.</li> </ul> <h2 id="3-screen-hierarchy-self-service-surface"><a class="header" href="#3-screen-hierarchy-self-service-surface">3. Screen hierarchy (self-service surface)</a></h2> <pre><code>/settings/profile ──────────────── Self profile (read own GET /users/:id) ├── edit (inline page form) ├── preferences (notification toggles, theme mode) └── avatar (upload dialog) </code></pre> <ul> <li>Identity self-<code>:id</code> resolution: JWT <code>sub</code> claim (<code>auth.service.ts:461</code>); server-side self-guard <code>(planned)</code> (OQ-3).</li> </ul> <h2 id="4-modal-hierarchy-per-00-shared05-5-2"><a class="header" href="#4-modal-hierarchy-per-00-shared05-5-2">4. Modal hierarchy (per <code>00-shared/05 §5</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Level</th><th>Used for</th><th>Widget</th></tr></thead><tbody> <tr><td>Snackbar</td><td>create/update success, import summary, avatar saved, undo-less soft notes</td><td><code>AppSnackbar</code></td></tr> <tr><td>Bottom sheet</td><td>role picker (from <code>GET /rbac/roles</code>), status picker, language/timezone pickers, row actions</td><td><code>AppBottomSheet</code></td></tr> <tr><td>Dialog</td><td>deactivate confirm (consequence copy), delete confirm (typed), GDPR erasure confirm (typed, "ERASE"), avatar replace/remove</td><td><code>AppDialog</code></td></tr> <tr><td>Full-screen</td><td>Create user, Edit user, Import wizard, Preferences, User detail</td><td>Page routes</td></tr> <tr><td>Loader</td><td>only submit waits; screens prefer skeletons</td><td><code>AppProgress</code></td></tr> </tbody></table> </div> <p><strong>Destructive rules (<code>00-shared/05 §5</code>):</strong> delete needs typed confirm ("delete") — irreversible after 30-day purge; GDPR erasure needs typed confirm ("ERASE") — irreversible immediately (anonymized + purge job, <code>users.service.ts:186-209</code>, <code>tenant-purge.worker.ts:49-56</code>).</p> <h2 id="5-screen-relationships-7"><a class="header" href="#5-screen-relationships-7">5. Screen relationships</a></h2> <ul> <li>List → Detail → Edit → back to Detail (refresh). List → Create → back to List (refresh). List → Import → Result → back to List (refresh).</li> <li>Detail tabs fetch from three sources: <code>GET /users/:id</code> (profile), <code>GET /rbac/members</code> (membership — join by <code>userId</code>), <code>GET /users/:id/preferences</code> (preferences).</li> <li>Preferences manager view and self-profile preferences share the same screen component (<code>PATCH</code> target differs only by <code>:id</code>).</li> <li>Status change happens from Detail (top-bar action) and from list row context menu.</li> </ul> <h2 id="6-routes--deep-links-1"><a class="header" href="#6-routes--deep-links-1">6. Routes & deep links</a></h2> <div class="table-wrapper"><table><thead><tr><th>Route</th><th>Screen</th><th>Guard</th></tr></thead><tbody> <tr><td><code>/users</code></td><td>Users list</td><td>authed + <code>user.read</code></td></tr> <tr><td><code>/users/new</code></td><td>Create user</td><td><code>user.create</code></td></tr> <tr><td><code>/users/import</code></td><td>Import wizard</td><td><code>user.import</code></td></tr> <tr><td><code>/users/:id</code></td><td>User detail</td><td><code>user.read</code></td></tr> <tr><td><code>/users/:id/edit</code></td><td>Edit user</td><td><code>user.update</code></td></tr> <tr><td><code>/users/:id/preferences</code></td><td>Preferences</td><td><code>user.read</code> + <code>user.update</code></td></tr> <tr><td><code>/settings/profile</code> (+ <code>/preferences</code>, avatar)</td><td>Self profile</td><td>authed (self <code>:id</code>)</td></tr> </tbody></table> </div> <p>Deep links:</p> <ul> <li><code>studylyon://users/:id</code> — notification "user-created" → detail (<code>UserCreated</code> → in-app job <code>user-created-notification</code>, <code>event-queue-map.ts:10</code>).</li> <li><code>studylyon://settings/profile</code> — own profile (<code>(forward-looking)</code> push).</li> </ul> <h2 id="7-nested-nav--state-survival-1"><a class="header" href="#7-nested-nav--state-survival-1">7. Nested nav & state survival</a></h2> <ul> <li>Detail tabs use <code>TabBarView</code> keep-alive (<code>00-shared/03 AppTabs</code>); switching bottom-nav destinations preserves tab state (<code>00-shared/05 §3</code>).</li> <li>Import wizard state (file, preview, errors) survives screen navigation via the <code>BulkImportCubit</code> (<code>13_State_Management.md §6</code>) — never re-parse a file the user already validated unless they pick a new one.</li> </ul> <h2 id="8-quick-actions-fabs-context-menus-1"><a class="header" href="#8-quick-actions-fabs-context-menus-1">8. Quick actions, FABs, context menus</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>FAB</th><th>Quick action</th><th>Context menu (rows)</th></tr></thead><tbody> <tr><td>Users list</td><td>"Add user" (extended)</td><td><code>Ctrl+K</code> search global; <code>N</code> new (desktop); "Import CSV" toolbar action</td><td>View · Edit · Deactivate/Activate · Delete · GDPR erase</td></tr> <tr><td>User detail</td><td>—</td><td>Edit (top bar), status menu, erase (top-bar overflow)</td><td>—</td></tr> <tr><td>Import wizard</td><td>—</td><td>primary CTA per step (Upload → Review → Start import)</td><td>—</td></tr> <tr><td>Self profile</td><td>—</td><td>avatar camera, edit actions</td><td>—</td></tr> </tbody></table> </div> <ul> <li>FAB hidden while scrolling down / shown on scroll-up (<code>00-shared/03 AppFAB</code>); unique <code>heroTag</code> per screen.</li> <li>Import entry point is deliberately a <strong>toolbar/empty-state action</strong>, not a second FAB (one FAB rule, <code>00-shared/03 §D</code>).</li> </ul> <h2 id="9-global-search-integration-1"><a class="header" href="#9-global-search-integration-1">9. Global search integration</a></h2> <ul> <li>Users list: <code>q</code> → regex on firstName/lastName/email/displayName, case-insensitive (<code>users.service.ts:92-99</code>), debounced 300 ms (<code>AppSearchBar</code>).</li> <li>Global search backend indexing users <code>(planned)</code> (search module exists as permission <code>search</code>, <code>permissions.constants.ts:93</code>).</li> </ul> <h2 id="10-filters--sorting-list-semantics"><a class="header" href="#10-filters--sorting-list-semantics">10. Filters & sorting (list semantics)</a></h2> <ul> <li>Server-supported today: pagination <code>page/limit(1-100,20)/sort(±field)/q</code> (<code>pagination-query.dto.ts:5-30</code>); default sort <code>-createdAt</code> (<code>users.service.ts:103</code>).</li> <li><strong>Status filter and role filter are <code>(planned)</code></strong> — no query params exist in <code>users.service.ts:90-115</code>. Until then the client filter bar filters the <strong>loaded pages client-side</strong> (explicitly labeled, and honest about the limitation) or defers until server support lands.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-users-module"><a class="header" href="#05--screen-inventory-users-module">05 — Screen Inventory (Users Module)</a></h1> <ul> <li><a href="users/05_Screen_Inventory.html#s1--users-list-master">S1 — Users list (master)</a></li> <li><a href="users/05_Screen_Inventory.html#s2--create-user-wizard-lite-form">S2 — Create user (wizard-lite form)</a></li> <li><a href="users/05_Screen_Inventory.html#s3--user-detail">S3 — User detail</a></li> <li><a href="users/05_Screen_Inventory.html#s4--bulk-import-wizard-upload--preview--result">S4 — Bulk import wizard (upload → preview → result)</a></li> <li><a href="users/05_Screen_Inventory.html#s5--edit-user">S5 — Edit user</a></li> <li><a href="users/05_Screen_Inventory.html#s6--preferences-self--manager">S6 — Preferences (self + manager)</a></li> <li><a href="users/05_Screen_Inventory.html#s7--self-profile">S7 — Self profile</a></li> <li><a href="users/05_Screen_Inventory.html#s8--confirm-dialogs-delete--erase--deactivate">S8 — Confirm dialogs (delete / erase / deactivate)</a></li> <li><a href="users/05_Screen_Inventory.html#s9--import-result--error-report">S9 — Import result / error report</a></li> <li><a href="users/05_Screen_Inventory.html#screen--endpoint-matrix">Screen → endpoint matrix</a></li> </ul> <blockquote> <p>Every screen the module owns, with purpose, entry, data sources, and states. Detailed specs in <a href="users/06_Screen_Specifications.html">06_Screen_Specifications.md</a>. Screens are <code>(planned)</code> (no client repo); data source citations are backend-exact.</p> </blockquote> <hr /> <h2 id="s1--users-list-master"><a class="header" href="#s1--users-list-master">S1 — Users list (master)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Route</td><td><code>/users</code></td></tr> <tr><td>Entry</td><td>Settings group → "Users & Roles"</td></tr> <tr><td>Purpose</td><td>Find, filter, and act on every person in the tenant</td></tr> <tr><td>Data source</td><td><code>GET /api/v1/users?page&limit&sort&q</code> (<code>users.controller.ts:42-46</code>; <code>users.service.ts:90-115</code>) + <code>GET /api/v1/rbac/members</code> for role chips (<code>rbac.controller.ts:57-61</code>)</td></tr> <tr><td>Query contract</td><td><code>q</code> → <code>$or</code> regex on firstName/lastName/email/displayName (<code>users.service.ts:92-99</code>); default sort <code>-createdAt</code> (<code>:103</code>); paginated envelope <code>meta {page,limit,totalItems,totalPages,hasNext,hasPrevious}</code> (<code>pagination-query.dto.ts:32-55</code>)</td></tr> <tr><td>States</td><td>Initial → skeleton rows; success+empty → <code>AppEmptyState</code> ("No users — add your first user"); error → <code>AppErrorState</code> (404 never for lists; 429 → backoff copy)</td></tr> <tr><td>Key elements</td><td><code>AppSearchBar</code> (debounce 300 ms) · filter chips row (status, role — <code>(planned)</code> server params, client-side filter meanwhile, <a href="users/04_Information_Architecture.html">04_IA §10</a>) · user rows: <code>AppAvatar</code> + name/displayName + email + status badge + role chips + last-login · pagination (infinite scroll on phone, page controls on desktop)</td></tr> <tr><td>Actions</td><td>Row tap → S3 detail · row menu: Edit / Deactivate</td></tr> </tbody></table> </div> <h2 id="s2--create-user-wizard-lite-form"><a class="header" href="#s2--create-user-wizard-lite-form">S2 — Create user (wizard-lite form)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Route</td><td><code>/users/new</code></td></tr> <tr><td>Entry</td><td>List FAB</td></tr> <tr><td>Data source</td><td><code>POST /api/v1/users</code> with <code>CreateUserDto</code> (<code>create-user.dto.ts:5-62</code>) + <code>POST /api/v1/rbac/members</code> after success</td></tr> <tr><td>Elements</td><td>Identity section (firstName, middleName?, lastName, displayName?, email, phone?) · demographics (gender?, dateOfBirth?) · locale (language, timezone) · status (default active) · membership section (roles from <code>GET /rbac/roles</code>)</td></tr> <tr><td>Submission</td><td>create user → on success immediately create membership; if membership fails show explicit "created without roles" banner + retry (OQ-5)</td></tr> <tr><td>States</td><td>Form validation per field; 409 email/phone → inline conflict; success → snackbar + navigate to detail</td></tr> </tbody></table> </div> <h2 id="s3--user-detail"><a class="header" href="#s3--user-detail">S3 — User detail</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Route</td><td><code>/users/:id</code></td></tr> <tr><td>Data sources</td><td><code>GET /users/:id</code> (<code>users.controller.ts:48-53</code>) · <code>GET /rbac/members</code> (join by userId) · <code>GET /users/:id/preferences</code> (<code>:78-83</code>)</td></tr> <tr><td>Elements</td><td>Header: avatar + name + status badge + email + roles · tabs: Profile (all fields incl. <code>lastLoginAt</code>, <code>emailVerified</code>, <code>phoneVerified</code>, <code>metadata</code>) · Membership (roles + member status) · Preferences (manager view) · Audit <code>(planned)</code> (audit-write events exist — <code>event-queue-map.ts:11-12</code>; no read endpoint in scope)</td></tr> <tr><td>Actions</td><td>Edit · status menu · Delete / GDPR erase (top-bar overflow)</td></tr> <tr><td>States</td><td>404 → "User not found" empty state (cross-tenant ids resolve 404 — <code>base.repository.ts:20-30</code>)</td></tr> </tbody></table> </div> <h2 id="s4--bulk-import-wizard-upload--preview--result"><a class="header" href="#s4--bulk-import-wizard-upload--preview--result">S4 — Bulk import wizard (upload → preview → result)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Route</td><td><code>/users/import</code></td></tr> <tr><td>Entry</td><td>List toolbar "Import CSV"</td></tr> <tr><td>Data source</td><td><code>POST /api/v1/users/import</code> (multipart <code>file</code>, <code>users.controller.ts:104-110</code>); future bulk-module adapter path <code>POST /api/v1/bulk/import/:entity</code> <code>(planned)</code> for a <code>users</code> entity (<code>bulk-import.service.ts:17-20</code>)</td></tr> <tr><td>Steps</td><td><strong>1 Upload</strong> — file picker (<code>.csv</code> only), drag-drop on desktop, size guard, template download · <strong>2 Preview</strong> — client-side parse in isolate: header map, row count, first rows table, column mapping (<code>firstname</code>/<code>first_name</code>), warnings (missing optional cols, quotes detected) · <strong>3 Result</strong> — imported/errors counts, error table <code>Row N: message</code> (<code>users.service.ts:281</code>), download-error CSV, "Import more"</td></tr> <tr><td>Progress</td><td>stage-based client progress; server phase indeterminate (sync endpoint, <a href="users/03_User_Journey.html">03_User_Journey.md §2</a>)</td></tr> <tr><td>States</td><td>Upload invalid file → inline error; malformed (no header/data) → server returns errors <code>["CSV must have a header row and at least one data row"]</code> (<code>users.service.ts:238-243</code>); partial success → result screen</td></tr> </tbody></table> </div> <h2 id="s5--edit-user"><a class="header" href="#s5--edit-user">S5 — Edit user</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Route</td><td><code>/users/:id/edit</code></td></tr> <tr><td>Data source</td><td><code>PATCH /users/:id</code> with <code>UpdateUserDto</code> (<code>update-user.dto.ts:5-70</code>)</td></tr> <tr><td>Elements</td><td>same fields as S2 minus roles (roles edited in S3 Membership via <code>PATCH /rbac/members/:id</code> — <code>rbac.controller.ts:69-73</code>); <strong>email/phone conflicts → 409 inline</strong> (<code>users.service.ts:120-134</code>)</td></tr> <tr><td>Notes</td><td><code>displayName</code> recomputed server-side when names change (<code>users.service.ts:136-139</code>) — show resulting displayName</td></tr> </tbody></table> </div> <h2 id="s6--preferences-self--manager"><a class="header" href="#s6--preferences-self--manager">S6 — Preferences (self + manager)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Route</td><td><code>/settings/profile/preferences</code> (self) · <code>/users/:id/preferences</code> (manager)</td></tr> <tr><td>Data source</td><td><code>GET/PATCH /api/v1/users/:id/preferences</code> (<code>users.controller.ts:78-93</code>)</td></tr> <tr><td>Elements</td><td>Notifications: email/push/sms switches · Theme: mode radio `light</td></tr> <tr><td>States</td><td>Load → skeleton; save → button spinner; 404 → not found</td></tr> </tbody></table> </div> <h2 id="s7--self-profile"><a class="header" href="#s7--self-profile">S7 — Self profile</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Route</td><td><code>/settings/profile</code></td></tr> <tr><td>Data source</td><td><code>GET /users/:id</code> with <code>:id = jwt sub</code> (client-resolved; server self-guard <code>(planned)</code> — OQ-3)</td></tr> <tr><td>Elements</td><td>avatar (<code>POST /users/:id/avatar</code>, <code>users.controller.ts:95-102</code>) · name/email/phone/gender/DOB/language/timezone · status read-only (self) · link to S6 preferences · link to password/2FA/sessions (auth module surface, <code>auth.controller.ts:109-165</code>)</td></tr> <tr><td>Notes</td><td><code>emailVerified</code>/<code>phoneVerified</code> read-only; see OQ-4 (user-level <code>emailVerified</code> flag not maintained by <code>verifyEmail</code> — <code>auth.service.ts:217-223</code>)</td></tr> </tbody></table> </div> <h2 id="s8--confirm-dialogs-delete--erase--deactivate"><a class="header" href="#s8--confirm-dialogs-delete--erase--deactivate">S8 — Confirm dialogs (delete / erase / deactivate)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Scope</td><td>Modal overlays on S1/S3</td></tr> <tr><td>Variants</td><td>Deactivate (plain confirm + consequence copy: still log-in capable if credentials exist) · Delete (typed confirm "delete"; 30-day purge note — <code>tenant-purge.worker.ts:32-43</code>) · GDPR erase (typed confirm "ERASE"; irreversible — anonymization <code>users.service.ts:188-197</code> + purge job <code>:200-209</code>)</td></tr> </tbody></table> </div> <h2 id="s9--import-result--error-report"><a class="header" href="#s9--import-result--error-report">S9 — Import result / error report</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Scope</td><td>Step 3 of S4 (also standalone after server-side error-only response)</td></tr> <tr><td>Elements</td><td>headline counts (imported / failed), error table (row number, backend message), download-error-CSV, "Import more"</td></tr> <tr><td>Copy rule</td><td>error strings rendered as-is from server (<code>Row N: …</code> — <code>users.service.ts:256-278</code>); prefixed with a static i18n title, never interpreted client-side</td></tr> </tbody></table> </div> <hr /> <h2 id="screen--endpoint-matrix-1"><a class="header" href="#screen--endpoint-matrix-1">Screen → endpoint matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Endpoint(s)</th></tr></thead><tbody> <tr><td>S1</td><td><code>GET /users</code> · <code>GET /rbac/members</code></td></tr> <tr><td>S2</td><td><code>POST /users</code> · <code>POST /rbac/members</code> · <code>GET /rbac/roles</code></td></tr> <tr><td>S3</td><td><code>GET /users/:id</code> · <code>GET /rbac/members</code> · <code>GET /users/:id/preferences</code></td></tr> <tr><td>S4</td><td><code>POST /users/import</code> (future: <code>POST /bulk/import/users</code> <code>(planned)</code>)</td></tr> <tr><td>S5</td><td><code>PATCH /users/:id</code></td></tr> <tr><td>S6</td><td><code>GET/PATCH /users/:id/preferences</code></td></tr> <tr><td>S7</td><td><code>GET /users/:id</code> · <code>POST /users/:id/avatar</code></td></tr> <tr><td>S8</td><td>(client-only dialogs)</td></tr> <tr><td>S9</td><td>(client-only; feeds retry of <code>POST /users/import</code>)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-users-module"><a class="header" href="#06--screen-specifications-users-module">06 — Screen Specifications (Users Module)</a></h1> <ul> <li><a href="users/06_Screen_Specifications.html#ss1--users-list-users">SS1 — Users list (<code>/users</code>)</a> <ul> <li><a href="users/06_Screen_Specifications.html#layout-responsive">Layout (responsive)</a></li> <li><a href="users/06_Screen_Specifications.html#states">States</a></li> <li><a href="users/06_Screen_Specifications.html#filter-chips">Filter chips</a></li> <li><a href="users/06_Screen_Specifications.html#refreshpagination">Refresh/pagination</a></li> <li><a href="users/06_Screen_Specifications.html#a11y">A11y</a></li> </ul> </li> <li><a href="users/06_Screen_Specifications.html#ss2--create-user-usersnew">SS2 — Create user (<code>/users/new</code>)</a> <ul> <li><a href="users/06_Screen_Specifications.html#form-structure-single-full-screen-page-3-fields--page-not-sheet-00-shared05-5">Form structure (single full-screen page; >3 fields → page not sheet, <code>00-shared/05 §5</code>)</a></li> <li><a href="users/06_Screen_Specifications.html#a11y-1">A11y</a></li> </ul> </li> <li><a href="users/06_Screen_Specifications.html#ss3--user-detail-usersid">SS3 — User detail (<code>/users/:id</code>)</a> <ul> <li><a href="users/06_Screen_Specifications.html#layout">Layout</a></li> <li><a href="users/06_Screen_Specifications.html#states-1">States</a></li> </ul> </li> <li><a href="users/06_Screen_Specifications.html#ss4--bulk-import-wizard-usersimport">SS4 — Bulk import wizard (<code>/users/import</code>)</a> <ul> <li><a href="users/06_Screen_Specifications.html#step-1--upload">Step 1 — Upload</a></li> <li><a href="users/06_Screen_Specifications.html#step-2--preview-client-side-isolate">Step 2 — Preview (client-side, isolate)</a></li> <li><a href="users/06_Screen_Specifications.html#step-3--result">Step 3 — Result</a></li> <li><a href="users/06_Screen_Specifications.html#failure-handling">Failure handling</a></li> <li><a href="users/06_Screen_Specifications.html#a11y-2">A11y</a></li> </ul> </li> <li><a href="users/06_Screen_Specifications.html#ss5--edit-user-usersidedit">SS5 — Edit user (<code>/users/:id/edit</code>)</a></li> <li><a href="users/06_Screen_Specifications.html#ss6--preferences">SS6 — Preferences</a></li> <li><a href="users/06_Screen_Specifications.html#ss7--self-profile">SS7 — Self profile</a></li> <li><a href="users/06_Screen_Specifications.html#ss8--confirm-dialogs">SS8 — Confirm dialogs</a></li> <li><a href="users/06_Screen_Specifications.html#ss9--import-result--error-report">SS9 — Import result / error report</a></li> </ul> <blockquote> <p>Full production specifications for the screens in <a href="users/05_Screen_Inventory.html">05_Screen_Inventory.md</a>. Every field/behaviour is derived from backend DTOs/schemas; layout and copy follow the shared tokens/components (<a href="users/../00-shared/02_Design_Tokens.html">00-shared/02</a>, <a href="users/03_User_Journey.html">03</a>, <a href="users/04_Information_Architecture.html">04</a>, <a href="users/09_User_Behaviour.html">09</a>). Screens are <code>(planned)</code>; backend behavior cited exactly.</p> </blockquote> <hr /> <h2 id="ss1--users-list-users"><a class="header" href="#ss1--users-list-users">SS1 — Users list (<code>/users</code>)</a></h2> <h3 id="layout-responsive-1"><a class="header" href="#layout-responsive-1">Layout (responsive)</a></h3> <pre><code>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" │ │ │ │ └──────────────────────────────┘ └─────────────────────┴────────────┘ </code></pre> <ul> <li>Phone: list only; master-detail at ≥ 840 dp (selected row highlights, <code>00-shared/05 §3</code>).</li> <li>List rows: height ≥ 56 (<code>00-shared/03 AppListTile</code>); avatar 40; leading avatar, title = <code>displayName</code> (fallback <code>firstName lastName</code> — always set server-side, <code>users.service.ts:62</code>), subtitle = email; trailing = status badge + overflow menu.</li> <li>Role chips: from <code>GET /rbac/members</code> joined client-side by <code>userId</code> (<code>rbac.controller.ts:57-61</code>); <code>org_admin</code>-gated data source — chips show "—" for non-admin viewers (<code>04_IA §1</code>).</li> </ul> <h3 id="states-20"><a class="header" href="#states-20">States</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Render</th></tr></thead><tbody> <tr><td>Initial/Loading</td><td><code>AppSkeleton</code> list (8 rows)</td></tr> <tr><td>Success+data</td><td>rows; footer "end of list" at last page</td></tr> <tr><td>Success+empty (<code>q</code> present)</td><td><code>AppEmptyState</code> "No users match 'query'"</td></tr> <tr><td>Success+empty (no <code>q</code>)</td><td><code>AppEmptyState</code> "No users yet" + primary CTA "Add user"</td></tr> <tr><td>Error</td><td><code>AppErrorState</code> per code: 429 → backoff copy; 5xx → generic + requestId (<code>00-shared/06 §5</code>)</td></tr> </tbody></table> </div> <h3 id="filter-chips-1"><a class="header" href="#filter-chips-1">Filter chips</a></h3> <ul> <li>Status chip: options map to <code>UserStatus</code> enum exactly (<code>active, inactive, suspended, invited</code> — <code>user.schema.ts:7-12</code>).</li> <li>Role chip: options from <code>GET /rbac/roles</code> (<code>rbac.controller.ts:27-31</code>).</li> <li><strong>Server has no status/role query params</strong> (<code>users.service.ts:90-115</code>); the chip filters the current client pages and is labeled "filtering loaded results" until server support lands (<code>(planned)</code>, OQ-9).</li> <li>Search <code>q</code> is server-side (regex <code>$or</code> — <code>users.service.ts:92-99</code>); clearing restores list.</li> </ul> <h3 id="refreshpagination"><a class="header" href="#refreshpagination">Refresh/pagination</a></h3> <ul> <li>Pull-to-refresh resets to page 1, bypasses cache (<code>00-shared/03 §F</code>).</li> <li>Infinite scroll appends while <code>meta.hasNext</code> (<code>pagination-query.dto.ts:52</code>); desktop uses explicit pager + jump-to-page.</li> </ul> <h3 id="a11y-12"><a class="header" href="#a11y-12">A11y</a></h3> <ul> <li>Row = one <code>Semantics(button)</code> (avatar+name+status as summary); badge announced as "status: active" (<code>00-shared/09 §7</code>); live region announces "N results" after search (<code>:44</code>); filter chips announce selected state.</li> </ul> <hr /> <h2 id="ss2--create-user-usersnew"><a class="header" href="#ss2--create-user-usersnew">SS2 — Create user (<code>/users/new</code>)</a></h2> <h3 id="form-structure-single-full-screen-page-3-fields--page-not-sheet-00-shared05-5"><a class="header" href="#form-structure-single-full-screen-page-3-fields--page-not-sheet-00-shared05-5">Form structure (single full-screen page; >3 fields → page not sheet, <code>00-shared/05 §5</code>)</a></h3> <pre><code>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 </code></pre> <ul> <li>Field validation exactly mirrors <code>CreateUserDto</code> (<code>create-user.dto.ts:5-62</code>): required <code>firstName</code>, <code>lastName</code>, <code>email</code> (<code>@IsEmail</code> <code>:24-26</code>); <code>gender</code> is free string server-side (<code>@IsString</code> <code>:33-38</code>) but the client constrains to the schema enum; <code>status</code> enum-validated (<code>:44-47</code>).</li> <li>Submit flow: <code>POST /users</code> → on success, if roles selected → <code>POST /rbac/members {userId, roles}</code> (<code>rbac.service.ts:113-127</code>). Membership failure → keep user, show persistent banner "Created without roles — retry" (retry calls the same member POST; idempotent enough — member unique per <code>(tenantId, userId)</code> index, <code>organization-member.schema.ts:48</code>).</li> <li>Email conflict (409): inline under email field, focus it (<code>users.service.ts:50-54</code>); suggest "Search existing users for 'x'".</li> <li>Phone conflict (409): same treatment (<code>:56-60</code>).</li> <li>Anti-double-submit: button loading state replaces label (<code>00-shared/08 §6</code>); membership call is a second network round-trip — button stays "Creating…" through both.</li> </ul> <h3 id="a11y-13"><a class="header" href="#a11y-13">A11y</a></h3> <ul> <li>Labels visible + autofill hints (name, email, tel — <code>00-shared/09 §10</code>); on submit failure focus moves to first invalid field; error summary announced via live region.</li> </ul> <hr /> <h2 id="ss3--user-detail-usersid"><a class="header" href="#ss3--user-detail-usersid">SS3 — User detail (<code>/users/:id</code>)</a></h2> <h3 id="layout-36"><a class="header" href="#layout-36">Layout</a></h3> <pre><code>Header: [avatar 64] displayName [status badge] [⋯ menu] email · phone · gender · DOB · language · timezone lastLoginAt · emailVerified/phoneVerified (read-only) Tabs: Profile | Membership | Preferences | (Audit (planned)) </code></pre> <ul> <li><strong>Profile tab:</strong> all <code>UpdateUserDto</code> fields read-only except through Edit (S5); <code>metadata</code> shown as JSON <code>(proposed)</code> collapsible — arbitrary object (<code>user.schema.ts:68-69</code>).</li> <li><strong>Membership tab:</strong> member status (<code>MemberStatus</code> — <code>organization-member.schema.ts:7-11</code>), roles (editable chips → <code>PATCH /rbac/members/:id</code>, <code>rbac.controller.ts:69-73</code>), <code>joinedAt</code>, <code>invitedBy</code>, <code>acceptedAt</code>, <code>lastActiveAt</code> (<code>:30-40</code>); remove membership → <code>DELETE /rbac/members/:id</code> (<code>:75-79</code>).</li> <li><strong>Preferences tab:</strong> S6 embedded.</li> <li>Status change menu (header): <code>active→inactive|suspended</code> etc. — all four enum values selectable (<code>user.schema.ts:7-12</code>); confirm dialog for <code>suspended</code> with consequence copy; the <strong>invited</strong> value selectable only via <code>(planned)</code> invite flow.</li> <li>Delete / GDPR erase: top-bar overflow → S8 dialogs.</li> </ul> <h3 id="states-21"><a class="header" href="#states-21">States</a></h3> <div class="table-wrapper"><table><thead><tr><th>Condition</th><th>Render</th></tr></thead><tbody> <tr><td>404 (cross-tenant/erased/deleted id)</td><td>"User not found" empty state — do not leak existence (<code>00-shared/07 §3</code>)</td></tr> <tr><td>Erased user visited post-purge</td><td>same 404</td></tr> <tr><td>No membership record</td><td>Membership tab empty-state "Not a member — add roles"</td></tr> </tbody></table> </div> <hr /> <h2 id="ss4--bulk-import-wizard-usersimport"><a class="header" href="#ss4--bulk-import-wizard-usersimport">SS4 — Bulk import wizard (<code>/users/import</code>)</a></h2> <h3 id="step-1--upload"><a class="header" href="#step-1--upload">Step 1 — Upload</a></h3> <ul> <li>Drop zone (desktop) / file picker (phone); accept <code>text/csv, .csv</code>; size guard: warn > 2 MB (server has no explicit limit in code — upload timeout 120 s client-side, <code>00-shared/11 §5</code>; OQ-10).</li> <li>"Download template" → client-generated CSV with header <code>email,firstname,lastname,phone,gender,language,timezone</code> (column names exactly as parsed: lowercased headers — <code>users.service.ts:244-247</code>; aliases <code>first_name</code>/<code>last_name</code> supported — <code>:265-266</code>).</li> <li>Malformed file (binary, wrong ext) → inline error before upload.</li> </ul> <h3 id="step-2--preview-client-side-isolate"><a class="header" href="#step-2--preview-client-side-isolate">Step 2 — Preview (client-side, isolate)</a></h3> <ul> <li>Parse mirrors server semantics for preview fidelity: <ul> <li>header row = first non-empty line, lowercase/trim (<code>users.service.ts:244-247</code>);</li> <li>rows = subsequent non-empty lines (<code>:237</code>); naive <code>,</code> split (<code>:251</code>);</li> <li>required: <code>email</code>; name aliases optional with fallback <code>Unknown</code> (<code>:265-266</code>); language/timezone defaults <code>en</code>/<code>UTC</code> (<code>:270-271</code>).</li> </ul> </li> <li>Show: column map, row count, first 5 rows; warnings: missing optional columns; <strong>quotes present</strong> (server will split mid-quote — OQ-7); duplicate email within file (server rejects row 2+ — <code>:260-263</code>).</li> <li>Client-side <strong>duplicate check against current list</strong> is advisory only (server re-checks at import time).</li> </ul> <h3 id="step-3--result"><a class="header" href="#step-3--result">Step 3 — Result</a></h3> <ul> <li>Counts: <code>imported</code> / <code>errors.length</code> (exact fields of response — <code>users.service.ts:281</code>); progress bar 100% on response.</li> <li>Error table: row number + message verbatim (<code>Row N: …</code> — <code>:256-278</code>); paginate error list if > 50 (client-side).</li> <li>Buttons: "Download errors (CSV)" (client-side export of row number + message), "Import more" (back to step 1), "Done" → list refresh.</li> <li><strong>No cancel/rollback</strong>: server commits per-row (<code>:250-279</code>); copy states "Already-imported rows are kept" on leaving.</li> </ul> <h3 id="failure-handling"><a class="header" href="#failure-handling">Failure handling</a></h3> <ul> <li>400 envelope: response <code>data</code> may be absent; the endpoint returns <code>{imported:0, errors:[…]}</code> for empty files rather than a 400 (<code>users.service.ts:238-243</code>) — treat as result, not error.</li> <li>Network loss mid-request: retry is safe (server dedups emails — <code>:260-263</code>); inform "retry won't duplicate imported rows".</li> </ul> <h3 id="a11y-14"><a class="header" href="#a11y-14">A11y</a></h3> <ul> <li>Progress announced (<code>00-shared/09 §7</code> "Importing users: 12%"); result summary in live region; error table rows readable by screen reader with row-number prefix.</li> </ul> <hr /> <h2 id="ss5--edit-user-usersidedit"><a class="header" href="#ss5--edit-user-usersidedit">SS5 — Edit user (<code>/users/:id/edit</code>)</a></h2> <ul> <li>Same form as SS2 minus Membership; DTO = <code>UpdateUserDto</code> (<code>update-user.dto.ts:5-70</code>) — all optional; submit sends only changed fields.</li> <li>Email/phone change → server re-checks uniqueness (<code>users.service.ts:120-134</code>); 409 inline.</li> <li>After save: <code>displayName</code> may change server-side (<code>:136-139</code>) — detail header refreshes from response.</li> <li><code>avatarFileId</code> field exists in DTO (<code>update-user.dto.ts:38-39</code>) — not surfaced as a text field (managed via avatar upload S7/S3).</li> </ul> <h2 id="ss6--preferences"><a class="header" href="#ss6--preferences">SS6 — Preferences</a></h2> <ul> <li>Notifications: three switches <code>email</code>, <code>push</code>, <code>sms</code> (<code>update-user-preferences.dto.ts:8-12</code>).</li> <li>Theme: mode radio <code>light | dark | system</code> + language field (<code>:16-20</code>).</li> <li>Save: full merged object (see contract warning in <code>03_User_Journey.md §3</code>); switches optimistic with rollback; "Save" persists merged state.</li> <li>Load: <code>GET /users/:id/preferences</code> → <code>user.preferences ?? {}</code> (<code>users.service.ts:168-171</code>) — never null; empty state = defaults off.</li> </ul> <h2 id="ss7--self-profile"><a class="header" href="#ss7--self-profile">SS7 — Self profile</a></h2> <ul> <li>Read-only identity + avatar + links; <code>:id</code> from JWT <code>sub</code> (<code>auth.service.ts:461</code>); guard <code>(planned)</code> server-side.</li> <li>Avatar flow: pick → crop (square) → <code>POST /users/:id/avatar</code> multipart <code>file</code> (<code>users.controller.ts:95-102</code>) → response <code>{avatarFileId}</code> (<code>users.service.ts:230</code>); old file deleted best-effort server-side (<code>:227-229</code>). Optimistic thumb while uploading; error → revert + snackbar.</li> <li>Show <code>emailVerified</code>/<code>phoneVerified</code> read-only — flagged OQ-4.</li> </ul> <h2 id="ss8--confirm-dialogs"><a class="header" href="#ss8--confirm-dialogs">SS8 — Confirm dialogs</a></h2> <div class="table-wrapper"><table><thead><tr><th>Dialog</th><th>Trigger</th><th>Content</th><th>Confirm</th></tr></thead><tbody> <tr><td>Deactivate</td><td>row menu / header</td><td>"Set status to inactive? They can still sign in if they have credentials."</td><td>button "Deactivate"</td></tr> <tr><td>Suspend</td><td>header</td><td>"They will see a suspension notice on login." <code>(planned)</code> copy — backend has no suspension-block copy (login rejects only lockout <code>auth.service.ts:133-135</code>)</td><td>"Suspend"</td></tr> <tr><td>Delete</td><td>row menu / header</td><td>"User will be hidden immediately and hard-deleted after 30 days."</td><td>typed <code>delete</code></td></tr> <tr><td>GDPR erase</td><td>overflow</td><td>"PII is anonymized immediately; the account is permanently deleted. This cannot be undone."</td><td>typed <code>ERASE</code></td></tr> </tbody></table> </div> <h2 id="ss9--import-result--error-report"><a class="header" href="#ss9--import-result--error-report">SS9 — Import result / error report</a></h2> <ul> <li>Standalone reuse of SS4 step 3 with server-only response (no preview): e.g. API clients pasting a direct multipart result.</li> <li>Render <code>errors</code> flat strings; count badge <code>error</code> role (<code>00-shared/03 AppBadge</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-users-module"><a class="header" href="#07--component-library-users-module">07 — Component Library (Users Module)</a></h1> <ul> <li><a href="users/07_Component_Library.html#1-module-component-list">1. Module component list</a></li> <li><a href="users/07_Component_Library.html#2-userlisttile">2. UserListTile</a></li> <li><a href="users/07_Component_Library.html#3-statusbadge">3. StatusBadge</a></li> <li><a href="users/07_Component_Library.html#4-rolechips">4. RoleChips</a></li> <li><a href="users/07_Component_Library.html#5-filterbar">5. FilterBar</a></li> <li><a href="users/07_Component_Library.html#6-avataruploader">6. AvatarUploader</a></li> <li><a href="users/07_Component_Library.html#7-importwizard">7. ImportWizard</a></li> <li><a href="users/07_Component_Library.html#8-csvpreviewtable">8. CsvPreviewTable</a></li> <li><a href="users/07_Component_Library.html#9-importerrorlist">9. ImportErrorList</a></li> <li><a href="users/07_Component_Library.html#10-preferencespanel">10. PreferencesPanel</a></li> <li><a href="users/07_Component_Library.html#11-permissionscoped">11. PermissionScoped</a></li> <li><a href="users/07_Component_Library.html#12-reused-shared-components-no-new-code">12. Reused shared components (no new code)</a></li> <li><a href="users/07_Component_Library.html#golden-test-expectation-00-shared03-g">Golden-test expectation (00-shared/03 §G)</a></li> </ul> <blockquote> <p>Module-specific components. Shared building blocks referenced from <a href="users/../00-shared/03_Component_Library.html">00-shared/03_Component_Library.md</a> are not redefined. All components are <code>(planned)</code> (no client repo). Backend-derived constraints cited.</p> </blockquote> <hr /> <h2 id="1-module-component-list"><a class="header" href="#1-module-component-list">1. Module component list</a></h2> <div class="table-wrapper"><table><thead><tr><th>Component</th><th>Purpose</th><th>Builds on</th></tr></thead><tbody> <tr><td><code>UserListTile</code></td><td>List row for S1</td><td><code>AppListTile</code> + <code>AppAvatar</code> + <code>AppBadge</code></td></tr> <tr><td><code>StatusBadge</code></td><td>User status pill</td><td><code>AppBadge</code></td></tr> <tr><td><code>RoleChips</code></td><td>Role display/selection</td><td><code>AppChips</code></td></tr> <tr><td><code>FilterBar</code></td><td>Search + status/role chips</td><td><code>AppSearchBar</code> + <code>AppChips</code></td></tr> <tr><td><code>AvatarUploader</code></td><td>Avatar pick/crop/upload</td><td><code>AppAvatar</code> + <code>AppDialog</code></td></tr> <tr><td><code>ImportWizard</code></td><td>3-step bulk import shell</td><td><code>AppStepper</code> + <code>AppProgress</code></td></tr> <tr><td><code>CsvPreviewTable</code></td><td>Parsed-row preview</td><td>table (<a href="users/../00-shared/04_Design_System_Mapping.html">00-shared/04 §8</a> desktop)</td></tr> <tr><td><code>ImportErrorList</code></td><td>Row-numbered error table</td><td><code>AppListTile</code> dense + <code>AppBadge</code></td></tr> <tr><td><code>TemplateDownloadButton</code></td><td>CSV template export (client-generated)</td><td><code>AppButton</code> outlined</td></tr> <tr><td><code>PreferencesPanel</code></td><td>Notification/theme controls</td><td><code>AppSwitch</code> + <code>AppRadio</code> + <code>AppDropdown</code></td></tr> <tr><td><code>SelfProfileHeader</code></td><td>Avatar + identity header</td><td><code>AppAvatar</code> + <code>AppInfoRow</code></td></tr> <tr><td><code>PermissionScoped</code></td><td>Conditional render/guard wrapper</td><td>route guards (<a href="users/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §9</a>)</td></tr> <tr><td><code>TypedConfirmDialog</code></td><td>Delete/erase typed confirm</td><td><code>AppDialog</code></td></tr> </tbody></table> </div> <hr /> <h2 id="2-userlisttile"><a class="header" href="#2-userlisttile">2. UserListTile</a></h2> <ul> <li>Props: <code>user</code>, <code>roles?</code>, <code>onTap</code>, <code>onEdit</code>, <code>onDeactivate</code>, <code>onDelete</code>, <code>onErase</code>, <code>selected</code>.</li> <li>Render: avatar 40 (initials fallback — <a href="users/../00-shared/03_Component_Library.html">00-shared/03 AppAvatar</a>), title = <code>displayName</code> (<code>users.service.ts:62</code> guarantees non-empty), subtitle = <code>email</code>, trailing = <code>StatusBadge</code> + <code>AppMenu</code> (View · Edit · Deactivate/ Activate · Delete · GDPR erase).</li> <li>States: selected (master-detail), loading avatar placeholder.</li> <li>A11y: single button semantics; badge text included in summary.</li> </ul> <h2 id="3-statusbadge"><a class="header" href="#3-statusbadge">3. StatusBadge</a></h2> <ul> <li>Maps <code>UserStatus</code> exactly (<code>user.schema.ts:7-12</code>):</li> </ul> <div class="table-wrapper"><table><thead><tr><th>Status</th><th>Token</th><th>Icon+label (never color-only, <a href="users/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §9</a>)</th></tr></thead><tbody> <tr><td><code>active</code></td><td><code>success</code>/<code>secondary</code></td><td>✓ Active</td></tr> <tr><td><code>inactive</code></td><td>neutral <code>onSurfaceVariant</code> outlined</td><td>◌ Inactive</td></tr> <tr><td><code>suspended</code></td><td><code>error</code>/<code>tertiary</code></td><td>⊘ Suspended</td></tr> <tr><td><code>invited</code></td><td><code>primaryContainer</code></td><td>✉ Invited</td></tr> </tbody></table> </div> <ul> <li>Unknown value (server could add) → neutral "Unknown" badge, no crash (forward-compat).</li> </ul> <h2 id="4-rolechips"><a class="header" href="#4-rolechips">4. RoleChips</a></h2> <ul> <li>Props: <code>roles[]</code>, <code>onToggle</code>, <code>editable</code>, <code>loading</code>.</li> <li>Data: <code>GET /rbac/roles</code> (<code>rbac.controller.ts:27-31</code>) — list mode shows member roles from <code>GET /rbac/members</code> joined by <code>userId</code> (<code>rbac.controller.ts:57-61</code>); <strong>empty when the viewer lacks <code>org_admin</code></strong> (that endpoint is role-gated <code>:21-22</code>) — show "—" with tooltip, never fake.</li> <li>Selected chip = <code>secondaryContainer</code> (<a href="users/../00-shared/04_Design_System_Mapping.html">00-shared/04 §2</a>).</li> </ul> <h2 id="5-filterbar"><a class="header" href="#5-filterbar">5. FilterBar</a></h2> <ul> <li>Search: <code>AppSearchBar</code>, debounce 300 ms, server-side <code>q</code> (<code>users.service.ts:92-99</code>).</li> <li>Status chip group (<code>StatusBadge</code> values) + role chip group: client-side filter of loaded pages with explicit caption "(planned) server filter — applies to loaded results only" (<a href="users/04_Information_Architecture.html">04_IA §10</a>, OQ-9).</li> <li>Clear-all button resets chips + query.</li> </ul> <h2 id="6-avataruploader"><a class="header" href="#6-avataruploader">6. AvatarUploader</a></h2> <ul> <li>Flow: tap avatar → pick image (<code>file_picker</code>) → square-crop dialog → multipart <code>POST /users/:id/avatar</code> (<code>users.controller.ts:95-102</code>) with <code>field name = file</code> (<code>FileInterceptor('file')</code> <code>:96</code>).</li> <li>Response <code>{avatarFileId}</code> (<code>users.service.ts:230</code>) — display via storage URL resolution <code>(planned)</code> (no URL from this endpoint; file id only).</li> <li>States: uploading (avatar overlay spinner), error (revert + snackbar), success (<code>HapticFeedback.lightImpact</code>).</li> <li>Size guidance: keep ≤ 2 MB (no backend cap in code — OQ-10).</li> </ul> <h2 id="7-importwizard"><a class="header" href="#7-importwizard">7. ImportWizard</a></h2> <ul> <li><code>AppStepper</code> steps: 1 Upload → 2 Preview → 3 Result (<a href="users/../00-shared/03_Component_Library.html">00-shared/03 AppStepper</a>).</li> <li>Owns <code>BulkImportCubit</code> state (<a href="users/13_State_Management.html">13_State_Management.md §6</a>); back-nav from step 2 → step 1 keeps parsed file.</li> <li>Destructive exit (leave mid-flow) → unsaved-changes dialog (parse state only; nothing server-side pending).</li> </ul> <h2 id="8-csvpreviewtable"><a class="header" href="#8-csvpreviewtable">8. CsvPreviewTable</a></h2> <ul> <li>Columns from parsed header (<code>users.service.ts:244-247</code>); row numbers <strong>1-based, header = row 1</strong> — error rows are <code>index + 2</code> (<code>bulk-import.service.ts:46</code> precedent) and <code>Row N</code> in users import refers to the physical line (<code>users.service.ts:257</code>) — the preview must display physical line numbers to match error strings.</li> <li>Highlight rows flagged client-side (missing email, dup email, quotes).</li> </ul> <h2 id="9-importerrorlist"><a class="header" href="#9-importerrorlist">9. ImportErrorList</a></h2> <ul> <li>Rows: physical row number (mono font, <a href="users/../00-shared/02_Design_Tokens.html">00-shared/02 §2</a>) + message verbatim; badge count = errors length; "Download errors" button exports <code>row,message</code> CSV client-side.</li> <li>Empty state when <code>errors.length === 0</code> → success check icon.</li> </ul> <h2 id="10-preferencespanel"><a class="header" href="#10-preferencespanel">10. PreferencesPanel</a></h2> <ul> <li><code>AppSwitch</code> × 3 (email/push/sms — <code>update-user-preferences.dto.ts:8-12</code>), <code>AppRadio</code> theme mode × 3 (<code>light|dark|system</code> — <code>:16-20</code>), <code>AppDropdown</code> language.</li> <li>Save = merged full object (<code>users.service.ts:161-163</code> replaces wholesale).</li> </ul> <h2 id="11-permissionscoped"><a class="header" href="#11-permissionscoped">11. PermissionScoped</a></h2> <ul> <li>Wrapper taking a permission string (e.g. <code>user.delete</code>) → hides/rebuilds subtree; route-level equivalent via <code>permissionGuard</code> (<a href="users/../00-shared/11_Flutter_App_Architecture.html">00-shared/11 §6</a>).</li> <li>Caution: server users controller currently enforces <strong>JWT only</strong> (<code>users.controller.ts:31</code>); RBAC guard on those routes is <code>(planned)</code> — the client still gates by permission so behavior is correct the day the guard lands (OQ-11).</li> </ul> <h2 id="12-reused-shared-components-no-new-code"><a class="header" href="#12-reused-shared-components-no-new-code">12. Reused shared components (no new code)</a></h2> <p><code>AppSearchBar</code>, <code>AppChips</code>, <code>AppStepper</code>, <code>AppProgress</code>, <code>AppEmptyState</code>, <code>AppErrorState</code>, <code>AppSnackbar</code>, <code>AppDialog</code>, <code>AppBottomSheet</code>, <code>AppMenu</code>, <code>AppDatePicker</code>, <code>AppAvatar</code>, <code>AppBadge</code>, <code>AppListTile</code>, <code>AppInfoRow</code>, <code>AppFAB</code>, <code>AppTabs</code>, <code>AppSkeleton</code>.</p> <hr /> <h2 id="golden-test-expectation-00-shared03-g"><a class="header" href="#golden-test-expectation-00-shared03-g">Golden-test expectation (<a href="users/../00-shared/03_Component_Library.html">00-shared/03 §G</a>)</a></h2> <p><code>UserListTile</code>, <code>StatusBadge</code> (4 states), <code>RoleChips</code>, <code>ImportErrorList</code>, <code>PreferencesPanel</code> at 3 sizes + light/dark.</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-users-module"><a class="header" href="#08--form-specifications-users-module">08 — Form Specifications (Users Module)</a></h1> <ul> <li><a href="users/08_Form_Specifications.html#1-create-user-form-post-apiv1users--createuserdto">1. Create user form (<code>POST /api/v1/users</code> — <code>CreateUserDto</code>)</a> <ul> <li><a href="users/08_Form_Specifications.html#submission-semantics">Submission semantics</a></li> </ul> </li> <li><a href="users/08_Form_Specifications.html#2-edit-user-form-patch-apiv1usersid--updateuserdto">2. Edit user form (<code>PATCH /api/v1/users/:id</code> — <code>UpdateUserDto</code>)</a></li> <li><a href="users/08_Form_Specifications.html#3-preferences-form-patch-apiv1usersidpreferences--updateuserpreferencesdto">3. Preferences form (<code>PATCH /api/v1/users/:id/preferences</code> — <code>UpdateUserPreferencesDto</code>)</a></li> <li><a href="users/08_Form_Specifications.html#4-avatar-upload-post-apiv1usersidavatar">4. Avatar upload (<code>POST /api/v1/users/:id/avatar</code>)</a></li> <li><a href="users/08_Form_Specifications.html#5-users-csv-import-columns-post-apiv1usersimport">5. Users CSV import columns (<code>POST /api/v1/users/import</code>)</a></li> <li><a href="users/08_Form_Specifications.html#6-bulk-module-csv-post-apiv1bulkimportentity">6. Bulk-module CSV (<code>POST /api/v1/bulk/import/:entity</code>)</a></li> <li><a href="users/08_Form_Specifications.html#7-form-state-conventions-client">7. Form state conventions (client)</a></li> </ul> <blockquote> <p>Every field of every form, derived 1:1 from <code>create-user.dto.ts</code>, <code>update-user.dto.ts</code>, <code>update-user-preferences.dto.ts</code>, and <code>user.schema.ts</code>, plus upload fields and CSV column specs from <code>users.service.ts:233-282</code> and <code>students-import.adapter.ts:17-26</code>. Validation column = server enforcement (class-validator / schema).</p> </blockquote> <hr /> <h2 id="1-create-user-form-post-apiv1users--createuserdto"><a class="header" href="#1-create-user-form-post-apiv1users--createuserdto">1. Create user form (<code>POST /api/v1/users</code> — <code>CreateUserDto</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Type</th><th>Required</th><th>Server validation</th><th>Default</th><th>Notes</th></tr></thead><tbody> <tr><td>1</td><td><code>firstName</code></td><td>text</td><td>✅</td><td><code>@IsString</code> (<code>create-user.dto.ts:7-8</code>)</td><td>—</td><td>Schema <code>required: true</code> (<code>user.schema.ts:16-17</code>)</td></tr> <tr><td>2</td><td><code>middleName</code></td><td>text</td><td>—</td><td><code>@IsOptional @IsString</code> (<code>:10-13</code>)</td><td>—</td><td></td></tr> <tr><td>3</td><td><code>lastName</code></td><td>text</td><td>✅</td><td><code>@IsString</code> (<code>:15-17</code>)</td><td>—</td><td>Schema required (<code>:22-23</code>)</td></tr> <tr><td>4</td><td><code>displayName</code></td><td>text</td><td>—</td><td><code>@IsOptional @IsString</code> (<code>:19-22</code>)</td><td><code>firstName + " " + lastName</code> (<code>users.service.ts:62</code>)</td><td>Recomputed on update (<code>:136-139</code>)</td></tr> <tr><td>5</td><td><code>email</code></td><td>email</td><td>✅</td><td><code>@IsEmail</code> (<code>:24-26</code>); schema lowercase+trim (<code>user.schema.ts:28-29</code>)</td><td>—</td><td><strong>Unique per tenant</strong> (<code>user.schema.ts:83</code>); server 409 pre-check (<code>users.service.ts:50-54</code>)</td></tr> <tr><td>6</td><td><code>phone</code></td><td>tel</td><td>—</td><td><code>@IsOptional @IsString</code> (<code>:28-31</code>); schema trim (<code>:31-32</code>)</td><td>—</td><td><strong>Unique per tenant</strong> (partial index <code>:84-90</code>); 409 pre-check (<code>users.service.ts:56-60</code>)</td></tr> <tr><td>7</td><td><code>gender</code></td><td>select</td><td>—</td><td><code>@IsOptional @IsString</code> (<code>:33-38</code>)</td><td>—</td><td>Schema enum <code>male/female/other/prefer_not_to_say</code> (<code>user.schema.ts:37-41</code>); client constrains, server is string-typed (mismatch OQ-12)</td></tr> <tr><td>8</td><td><code>dateOfBirth</code></td><td>date</td><td>—</td><td>optional, no decorator beyond Optional (<code>:40-42</code>)</td><td>—</td><td>Schema <code>Date</code> (<code>:43-44</code>); max = today</td></tr> <tr><td>9</td><td><code>status</code></td><td>select</td><td>—</td><td><code>@IsEnum(UserStatus)</code> (<code>:44-47</code>)</td><td><code>active</code></td><td>Enum <code>active/inactive/suspended/invited</code> (<code>user.schema.ts:7-12</code>); <code>invited</code> only via <code>(planned)</code> invite flow</td></tr> <tr><td>10</td><td><code>language</code></td><td>text</td><td>—</td><td><code>@IsOptional @IsString</code> (<code>:49-52</code>)</td><td><code>en</code></td><td>Schema default <code>en</code> (<code>:49-50</code>)</td></tr> <tr><td>11</td><td><code>timezone</code></td><td>text</td><td>—</td><td><code>@IsOptional @IsString</code> (<code>:54-57</code>)</td><td><code>UTC</code></td><td>Schema default <code>UTC</code> (<code>:52-53</code>); use searchable <code>AppDropdown</code></td></tr> <tr><td>12</td><td><code>metadata</code></td><td>json</td><td>—</td><td><code>@IsOptional</code> object (<code>:59-61</code>)</td><td>—</td><td>Schema <code>Object</code> (<code>user.schema.ts:68-69</code>); not exposed as a user field by default <code>(proposed)</code></td></tr> </tbody></table> </div> <h3 id="submission-semantics"><a class="header" href="#submission-semantics">Submission semantics</a></h3> <ul> <li>Tenant: <strong>never</strong> sent by client — derived from JWT (<a href="users/../00-shared/07_API_Conventions.html">00-shared/07 §6</a>; repo injects <code>tenantId</code> — <code>base.repository.ts:32-36</code>).</li> <li>Success: 201 envelope with user doc; then (if roles chosen) <code>POST /rbac/members {userId, roles}</code> (<code>rbac.controller.ts:63-67</code>).</li> <li>Errors: 400 field-level; 409 email/phone (message text from <code>users.service.ts:50-60</code>); 429 backoff; 5xx generic.</li> </ul> <h2 id="2-edit-user-form-patch-apiv1usersid--updateuserdto"><a class="header" href="#2-edit-user-form-patch-apiv1usersid--updateuserdto">2. Edit user form (<code>PATCH /api/v1/users/:id</code> — <code>UpdateUserDto</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Field</th><th>Notes (same validation as §1 where present)</th></tr></thead><tbody> <tr><td>1</td><td><code>firstName</code> / <code>middleName</code> / <code>lastName</code></td><td>optional <code>@IsString</code> (<code>update-user.dto.ts:8-24</code>); renaming recomputes <code>displayName</code> (<code>users.service.ts:136-139</code>)</td></tr> <tr><td>2</td><td><code>displayName</code></td><td>explicit override wins over computed (<code>:136-139</code>)</td></tr> <tr><td>3</td><td><code>email</code></td><td><code>@IsEmail</code> (<code>:26-29</code>); change → uniqueness re-check vs. existing (<code>users.service.ts:120-126</code>)</td></tr> <tr><td>4</td><td><code>phone</code></td><td><code>@IsString</code> (<code>:30-33</code>); change → uniqueness re-check (<code>:128-134</code>)</td></tr> <tr><td>5</td><td><code>avatarFileId</code></td><td><code>@IsString</code> (<code>:38-39</code>) — <strong>not a user-facing field</strong>; avatar managed via <code>POST /users/:id/avatar</code></td></tr> <tr><td>6</td><td><code>gender</code> / <code>dateOfBirth</code> / <code>status</code> / <code>language</code> / <code>timezone</code> / <code>metadata</code></td><td>same as §1 (<code>:41-69</code>); <code>status</code> enum-validated (<code>:52-55</code>)</td></tr> </tbody></table> </div> <ul> <li>Partial PATCH: only changed fields sent; <code>$set</code> merge + <code>version</code> increment (<code>base.repository.ts:57-66</code>).</li> </ul> <h2 id="3-preferences-form-patch-apiv1usersidpreferences--updateuserpreferencesdto"><a class="header" href="#3-preferences-form-patch-apiv1usersidpreferences--updateuserpreferencesdto">3. Preferences form (<code>PATCH /api/v1/users/:id/preferences</code> — <code>UpdateUserPreferencesDto</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Group</th><th>Field</th><th>Type</th><th>Server validation</th><th>Notes</th></tr></thead><tbody> <tr><td>1</td><td><code>notifications</code></td><td><code>email</code></td><td>boolean</td><td><code>@IsOptional @IsObject</code> group (<code>update-user-preferences.dto.ts:4-12</code>)</td><td>Schema <code>Boolean</code> (<code>user.schema.ts:56-66</code>)</td></tr> <tr><td>2</td><td><code>notifications</code></td><td><code>push</code></td><td>boolean</td><td>same</td><td></td></tr> <tr><td>3</td><td><code>notifications</code></td><td><code>sms</code></td><td>boolean</td><td>same</td><td></td></tr> <tr><td>4</td><td><code>theme</code></td><td><code>mode</code></td><td>radio</td><td><code>@IsOptional @IsObject</code> (<code>:14-20</code>)</td><td>Schema enum <code>light/dark/system</code> (<code>user.schema.ts:61</code>)</td></tr> <tr><td>5</td><td><code>theme</code></td><td><code>language</code></td><td>text</td><td>same</td><td></td></tr> </tbody></table> </div> <p><strong>Contract warning:</strong> <code>updatePreferences</code> does <code>$set: { preferences: dto }</code> (<code>users.service.ts:161-163</code>) — a <strong>full replace</strong>. Client always sends the merged complete object; a partial payload wipes the other group (e.g. sending only <code>{theme}</code> drops <code>notifications</code>).</p> <p><strong>Read shape:</strong> <code>GET /users/:id/preferences</code> → <code>user.preferences ?? {}</code> (<code>users.service.ts:168-171</code>).</p> <h2 id="4-avatar-upload-post-apiv1usersidavatar"><a class="header" href="#4-avatar-upload-post-apiv1usersidavatar">4. Avatar upload (<code>POST /api/v1/users/:id/avatar</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody> <tr><td>Multipart field name</td><td><code>file</code> — <code>FileInterceptor('file')</code> (<code>users.controller.ts:96</code>)</td></tr> <tr><td>Content type</td><td><code>multipart/form-data</code> (<code>ApiConsumes</code> <code>:97</code>)</td></tr> <tr><td>Payload</td><td>image buffer; filename prefix <code>${id}-</code> server-side (<code>users.service.ts:219</code>)</td></tr> <tr><td>Response</td><td><code>{ avatarFileId }</code> (<code>:230</code>); previous avatar deleted best-effort (<code>:227-229</code>)</td></tr> <tr><td>Validation</td><td><strong>none in code</strong> (no size/mime check server-side) — client: image types, ≤ 2 MB, square crop (OQ-10)</td></tr> <tr><td>Storage path</td><td><code>sl/{tenantId}/avatars/{uuid}</code> blueprint (<code>04-Modules/Users.md:66</code>)</td></tr> </tbody></table> </div> <h2 id="5-users-csv-import-columns-post-apiv1usersimport"><a class="header" href="#5-users-csv-import-columns-post-apiv1usersimport">5. Users CSV import columns (<code>POST /api/v1/users/import</code>)</a></h2> <p>Endpoint parses rows keyed by lowercased header (<code>users.service.ts:244-247</code>):</p> <div class="table-wrapper"><table><thead><tr><th>Column (accepted headers)</th><th>Required</th><th>Server behavior</th></tr></thead><tbody> <tr><td><code>email</code></td><td>✅</td><td>missing → <code>Row N: missing email</code> (<code>:256-259</code>); duplicate (existing user or earlier row) → <code>Row N: email "x" already exists</code> (<code>:260-263</code>)</td></tr> <tr><td><code>firstname</code> / <code>first_name</code></td><td>—</td><td>fallback <code>Unknown</code> (<code>:265</code>)</td></tr> <tr><td><code>lastname</code> / <code>last_name</code></td><td>—</td><td>fallback <code>Unknown</code> (<code>:266</code>)</td></tr> <tr><td><code>phone</code></td><td>—</td><td>passed through if present (<code>:268</code>)</td></tr> <tr><td><code>gender</code></td><td>—</td><td>passed through (<code>:269</code>)</td></tr> <tr><td><code>language</code></td><td>—</td><td>default <code>en</code> (<code>:270</code>)</td></tr> <tr><td><code>timezone</code></td><td>—</td><td>default <code>UTC</code> (<code>:271</code>)</td></tr> </tbody></table> </div> <ul> <li>Header row required + ≥ 1 data row (<code>:238-243</code>); empty file → <code>errors</code> contains <code>"CSV must have a header row and at least one data row"</code>.</li> <li>Naive <code>,</code> split — quoted commas unsupported (OQ-7); empty lines skipped (<code>:237</code>).</li> <li>Success shape: <code>{ imported: number, errors: string[] }</code> (<code>:281</code>).</li> <li>Template download header (client): <code>email,firstname,lastname,phone,gender, language,timezone</code>.</li> </ul> <h2 id="6-bulk-module-csv-post-apiv1bulkimportentity"><a class="header" href="#6-bulk-module-csv-post-apiv1bulkimportentity">6. Bulk-module CSV (<code>POST /api/v1/bulk/import/:entity</code>)</a></h2> <ul> <li>Parser: <code>csv-parse/sync</code> <code>{columns:true, skip_empty_lines:true, trim:true}</code> (<code>bulk-import.service.ts:26-30</code>) — <strong>supports quoted fields</strong>; malformed → 400 <code>VALIDATION_ERROR</code> (<code>:31-33</code>); no rows → 400 (<code>:34-35</code>).</li> <li>Report shape: <code>{entity, totalRows, imported, failed, errors:[{rowNumber, errors[]}]}</code> (<code>import-adapter.interface.ts:14-25</code>); rowNumber is <strong>physical line</strong> (<code>index + 2</code>, header = row 1 — <code>bulk-import.service.ts:46</code>).</li> <li><code>students</code> adapter columns (<code>students-import.adapter.ts:17-26</code>): required <code>firstName, lastName, email, admissionNumber, grade, section, academicYear</code>; optional <code>rollNumber</code>. Validation: required presence (<code>:41-45</code>), email regex (<code>:46-47</code>), admission-number dup (<code>:49-54</code>), email registered dup (<code>:55-56</code>), ref resolution grade/section/academicYear/class (<code>:58,100-144</code>).</li> <li><strong>No <code>users</code> adapter yet</strong> — <code>entity='users'</code> → 404 "No import adapter" (<code>bulk-import.service.ts:17-20</code>) <code>(planned)</code>.</li> </ul> <h2 id="7-form-state-conventions-client"><a class="header" href="#7-form-state-conventions-client">7. Form state conventions (client)</a></h2> <ul> <li>Dirty tracking + unsaved-changes dialog (page forms, <a href="users/../00-shared/05_Global_Information_Architecture.html">00-shared/05 §5</a>).</li> <li>Error display: server <code>VALIDATION_ERROR.details</code> mapped to fields (<a href="users/../00-shared/07_API_Conventions.html">00-shared/07 §3</a>); 409 as inline conflict; focus first invalid field (<a href="users/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §10</a>).</li> <li>Anti-double-submit: all submit buttons disable + spinner (<a href="users/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §6</a>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-users-module"><a class="header" href="#09--user-behaviour-users-module">09 — User Behaviour (Users Module)</a></h1> <ul> <li><a href="users/09_User_Behaviour.html#1-discovery--search-behaviour">1. Discovery & search behaviour</a></li> <li><a href="users/09_User_Behaviour.html#2-create--duplicate-handling">2. Create / duplicate handling</a></li> <li><a href="users/09_User_Behaviour.html#3-status-change-behaviour-matrix">3. Status-change behaviour matrix</a></li> <li><a href="users/09_User_Behaviour.html#4-soft-delete-behaviour">4. Soft-delete behaviour</a></li> <li><a href="users/09_User_Behaviour.html#5-gdpr-erasure-behaviour">5. GDPR erasure behaviour</a></li> <li><a href="users/09_User_Behaviour.html#6-bulk-import-behaviour">6. Bulk import behaviour</a></li> <li><a href="users/09_User_Behaviour.html#7-self-service-behaviour">7. Self-service behaviour</a></li> <li><a href="users/09_User_Behaviour.html#8-role--membership-behaviour">8. Role & membership behaviour</a></li> <li><a href="users/09_User_Behaviour.html#9-concurrency--multi-device">9. Concurrency & multi-device</a></li> <li><a href="users/09_User_Behaviour.html#10-error-behaviour-policy-per-00-shared06-5">10. Error-behaviour policy (per 00-shared/06 §5)</a></li> </ul> <blockquote> <p>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.</p> </blockquote> <hr /> <h2 id="1-discovery--search-behaviour"><a class="header" href="#1-discovery--search-behaviour">1. Discovery & search behaviour</a></h2> <ul> <li><strong>Search is server-side and instant-debounced.</strong> <code>q</code> matches firstName, lastName, email, displayName case-insensitively (<code>users.service.ts:92-99</code>). Users type partial names — no leading wildcard cost concern for tenant scale; debounce 300 ms (<a href="users/../00-shared/03_Component_Library.html">00-shared/03 AppSearchBar</a>).</li> <li><strong>Typing in search while scrolled to page 7 resets to page 1</strong> — standard paginated-search semantics (<a href="users/../00-shared/06_State_Management.html">00-shared/06 §3.2</a>).</li> <li>Result count announced (live region) per <a href="users/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §7</a>.</li> </ul> <h2 id="2-create--duplicate-handling"><a class="header" href="#2-create--duplicate-handling">2. Create / duplicate handling</a></h2> <ul> <li><strong>Duplicate email is the #1 real-world error</strong> (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.</li> <li>Email is stored lowercase (<code>user.schema.ts:28-29</code>) — the client lowercases previews/display so users aren't surprised by case changes.</li> <li>Phone duplicates are blocked per tenant (partial unique index, <code>user.schema.ts:84-90</code>) — same inline treatment as email.</li> </ul> <h2 id="3-status-change-behaviour-matrix"><a class="header" href="#3-status-change-behaviour-matrix">3. Status-change behaviour matrix</a></h2> <div class="table-wrapper"><table><thead><tr><th>From → To</th><th>System behaviour</th><th>UI note</th></tr></thead><tbody> <tr><td>any → <code>active</code></td><td><code>PATCH status</code></td><td>re-enables login for <code>inactive/suspended</code> users (login checks only <code>isDeleted</code> + lockout — <code>auth.service.ts:124-141</code>)</td></tr> <tr><td>→ <code>inactive</code></td><td>visible in admin lists</td><td>copy: "can still sign in if credentials exist" (no server block)</td></tr> <tr><td>→ <code>suspended</code></td><td>visible; login allowed (no suspension block in code — OQ-13)</td><td>copy must not overpromise a login block</td></tr> <tr><td>→ <code>invited</code></td><td><strong>no server support</strong> (<code>(planned)</code> invite flow)</td><td>option hidden until invite endpoint exists</td></tr> </tbody></table> </div> <ul> <li>Never present status changes as "security lockdown" — the backend enforces only <code>isDeleted</code> and account lockout (<code>auth.service.ts:133-135</code>).</li> </ul> <h2 id="4-soft-delete-behaviour"><a class="header" href="#4-soft-delete-behaviour">4. Soft-delete behaviour</a></h2> <ul> <li>Deleted users vanish from all scoped queries (<code>base.repository.ts:20-30</code>) and cannot log in (<code>users.repository.ts:21-25</code>).</li> <li><strong>Audit trail survives</strong> (<code>UserDeleted</code> → <code>audit-write</code>, <code>event-queue-map.ts:12</code>) — 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, <code>users.service.ts:50-54</code>). UI copy: "Re-adding the same email creates a new account."</li> <li>Purge after 30 days (<code>tenant-purge.worker.ts:32-43</code>) — the delete dialog states this.</li> </ul> <h2 id="5-gdpr-erasure-behaviour"><a class="header" href="#5-gdpr-erasure-behaviour">5. GDPR erasure behaviour</a></h2> <ul> <li>Erasure is <strong>immediate and irreversible</strong> at the UI level (anonymized PII, <code>users.service.ts:188-197</code>; hard delete by job <code>:200-209</code>, <code>tenant-purge.worker.ts:49-56</code>).</li> <li>Product rule: erasure requires the user's written request context; the typed-confirm dialog is the consent gate. Erased emails become <code>erased-<id>@anonymized.invalid</code> (<code>users.service.ts:192</code>) — never shown in lists (doc is soft-deleted same step).</li> <li>No undo; no "erase" on active users without the delete step being visible in the same dialog.</li> </ul> <h2 id="6-bulk-import-behaviour"><a class="header" href="#6-bulk-import-behaviour">6. Bulk import behaviour</a></h2> <ul> <li><strong>Partial success is the norm</strong>; the result screen is the contract of truth (<code>imported</code> + <code>errors</code> — <code>users.service.ts:281</code>).</li> <li>Users fix files iteratively: error CSV → spreadsheet → re-upload; each round rejects already-imported emails (<code>:260-263</code>) — the wizard must make clear "remove previously imported rows or accept these errors".</li> <li><strong>Never auto-dedupe on the client</strong> — server is authoritative.</li> <li>Rows import with defaults (<code>Unknown</code> names, <code>en</code>, <code>UTC</code> — <code>users.service.ts:265-271</code>) — preview must surface "Unknown" fallbacks before import so admins can fix names.</li> <li>Bulk-imported users get <strong>no <code>UserCreated</code> event</strong> (<code>:264-272</code> calls <code>repo.create</code> directly) → no welcome notification. Behavior rule: result screen states "Accounts created; no emails sent" (<code>(planned)</code> invite emails).</li> </ul> <h2 id="7-self-service-behaviour"><a class="header" href="#7-self-service-behaviour">7. Self-service behaviour</a></h2> <ul> <li>Users edit their own profile via <code>PATCH /users/:id</code> — client must scope to <code>sub</code> (server self-guard <code>(planned)</code>, OQ-3).</li> <li>Preference toggles: instant feedback (optimistic) but <strong>always submitted as the full merged object</strong> (full-replace semantics, <code>users.service.ts:161-163</code>).</li> <li>Theme <code>system</code> default mirrors OS; changing language is a client-side locale switch on save (server stores the preference only).</li> </ul> <h2 id="8-role--membership-behaviour"><a class="header" href="#8-role--membership-behaviour">8. Role & membership behaviour</a></h2> <ul> <li>Roles are managed on the <strong>membership</strong>, not the user (<code>organization-member.schema.ts:21-25</code>; <code>rbac.service.ts:113-127</code>).</li> <li>Removing a member (<code>DELETE /rbac/members/:id</code>, <code>rbac.controller.ts:75-79</code>) does <strong>not</strong> delete the user or revoke their tokens — role list in JWT goes stale until next login/refresh (JWT roles embedded at issue time — <code>auth.service.ts:460-476</code>). UI copy: "Role changes apply on next sign-in."</li> <li><code>org_admin</code> self-demotion/self-removal: allow with confirmation (no server guard) — client warns "you may lose access".</li> </ul> <h2 id="9-concurrency--multi-device"><a class="header" href="#9-concurrency--multi-device">9. Concurrency & multi-device</a></h2> <ul> <li>Two admins editing the same user: last-write-wins with <code>version</code> bump (<code>base.repository.ts:57-66</code>) — no conflict error surfaced; the client refreshes from PATCH response so stale forms are overwritten.</li> <li>Two admins creating the same email concurrently: both pass the pre-check, one hits the unique index (<code>user.schema.ts:83</code>) → <strong>500 duplicate-key</strong> (not a clean 409 — OQ-14). Client treats 5xx with "try again"; server fix <code>(planned)</code>.</li> <li>List refresh after any mutation; no optimistic list-row removal except soft-delete (safe, idempotent, <a href="users/../00-shared/06_State_Management.html">00-shared/06 §3.5</a>).</li> </ul> <h2 id="10-error-behaviour-policy-per-00-shared06-5"><a class="header" href="#10-error-behaviour-policy-per-00-shared06-5">10. Error-behaviour policy (per <a href="users/../00-shared/06_State_Management.html">00-shared/06 §5</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Code</th><th>Behaviour</th></tr></thead><tbody> <tr><td>400</td><td>field errors inline; import errors as result rows</td></tr> <tr><td>401</td><td>silent refresh → session expiry flow</td></tr> <tr><td>403</td><td>hide action; if reached → 403 screen</td></tr> <tr><td>404</td><td>"User not found" empty state (never leak existence)</td></tr> <tr><td>409</td><td>inline conflict + "search existing" affordance</td></tr> <tr><td>422</td><td><code>(planned)</code> reserved; not produced by users module today</td></tr> <tr><td>429</td><td>backoff copy + no auto-retry</td></tr> <tr><td>5xx</td><td>generic + requestId; retry offered; note concurrency dupes (OQ-14)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-users-module"><a class="header" href="#10--interaction-specification-users-module">10 — Interaction Specification (Users Module)</a></h1> <ul> <li><a href="users/10_Interaction_Specification.html#1-touch-gestures-phonetablet">1. Touch gestures (phone/tablet)</a></li> <li><a href="users/10_Interaction_Specification.html#2-desktopweb-mouse--keyboard">2. Desktop/web (mouse + keyboard)</a></li> <li><a href="users/10_Interaction_Specification.html#3-haptics-mobile">3. Haptics (mobile)</a></li> <li><a href="users/10_Interaction_Specification.html#4-motion-language">4. Motion language</a></li> <li><a href="users/10_Interaction_Specification.html#5-micro-interactions">5. Micro-interactions</a></li> <li><a href="users/10_Interaction_Specification.html#6-import-progress-interaction-long-running">6. Import progress interaction (long-running)</a></li> <li><a href="users/10_Interaction_Specification.html#7-frustration-controls">7. Frustration controls</a></li> </ul> <blockquote> <p>Module-specific interactions on top of <a href="users/../00-shared/08_Interaction_&_Motion.html">00-shared/08_Interaction_&_Motion.md</a>. Tokens <code>m-*</code> per <a href="users/../00-shared/02_Design_Tokens.html">00-shared/02 §6</a>; all durations/curves from there.</p> </blockquote> <hr /> <h2 id="1-touch-gestures-phonetablet-4"><a class="header" href="#1-touch-gestures-phonetablet-4">1. Touch gestures (phone/tablet)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Gesture</th><th>Behaviour</th><th>Where</th></tr></thead><tbody> <tr><td>Tap row</td><td>Navigate to detail (phone) / select in pane (≥ 840 dp); ripple <code>m-fast</code></td><td>Users list</td></tr> <tr><td>Long-press row</td><td><code>AppMenu</code>: View · Edit · Deactivate/Activate · Delete · GDPR erase</td><td>List rows</td></tr> <tr><td>Swipe left (row)</td><td>Reveal ≤ 2 actions: <strong>Deactivate</strong>, <strong>Delete</strong> (<a href="users/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §1</a>, keep ≤ 2)</td><td>List rows (phone)</td></tr> <tr><td>Pull down</td><td>Refresh list (resets to page 1, bypasses cache)</td><td>List</td></tr> <tr><td>Tap FAB</td><td>New user (scale-in <code>m-entrance</code>; hide on scroll-down)</td><td>List</td></tr> <tr><td>Back gesture</td><td>Pop; from detail → list (state preserved)</td><td>All</td></tr> </tbody></table> </div> <h2 id="2-desktopweb-mouse--keyboard-4"><a class="header" href="#2-desktopweb-mouse--keyboard-4">2. Desktop/web (mouse + keyboard)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Input</th><th>Behaviour</th></tr></thead><tbody> <tr><td>Hover row</td><td>2% surface darken + tooltip on status badge (<code>m-fast</code>)</td></tr> <tr><td>Right-click row</td><td>Same context menu as long-press</td></tr> <tr><td><code>Ctrl+K</code></td><td>Global search (shell-level)</td></tr> <tr><td><code>N</code></td><td>New user (list focused, no input focused)</td></tr> <tr><td><code>I</code></td><td>Import CSV (list focused)</td></tr> <tr><td><code>/</code></td><td>Focus search field</td></tr> <tr><td><code>Esc</code></td><td>Close dialog/sheet/menu; from search → clear + unfocus</td></tr> <tr><td><code>Enter</code></td><td>Submit focused form; in dialogs = primary action (typed confirms require typing first)</td></tr> <tr><td>Arrow keys</td><td>Navigate list rows (desktop master-detail); menus</td></tr> <tr><td><code>Ctrl+Enter</code></td><td>Submit edit form from any text area</td></tr> </tbody></table> </div> <h2 id="3-haptics-mobile-12"><a class="header" href="#3-haptics-mobile-12">3. Haptics (mobile)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Haptic</th></tr></thead><tbody> <tr><td>Create/update success</td><td><code>lightImpact</code></td></tr> <tr><td>409 conflict / import errors shown</td><td><code>mediumImpact</code></td></tr> <tr><td>Long-press menu</td><td><code>selectionClick</code></td></tr> <tr><td>Pull-to-refresh complete</td><td><code>lightImpact</code></td></tr> <tr><td>Delete/erase confirm</td><td><code>heavyImpact</code></td></tr> <tr><td>Import complete (all rows)</td><td><code>lightImpact</code>; partial success: <code>mediumImpact</code></td></tr> </tbody></table> </div> <p>Haptics disabled per system setting; none on desktop (<a href="users/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §3</a>).</p> <h2 id="4-motion-language-1"><a class="header" href="#4-motion-language-1">4. Motion language</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Motion</th></tr></thead><tbody> <tr><td>List → detail</td><td>Slide forward <code>m-base</code>; hero avatar flight <code>m-slow</code> (<a href="users/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §4</a>)</td></tr> <tr><td>Skeleton → rows</td><td>Cross-fade <code>m-slow</code>, no pop</td></tr> <tr><td>Status badge change</td><td>Color/size transition <code>m-fast</code>; icon morph <code>AnimatedSwitcher</code></td></tr> <tr><td>Filter chips toggle</td><td><code>m-fast</code> color transition</td></tr> <tr><td>Import step change</td><td>Fade + slight rise <code>m-slow</code>; stepper indicator slide <code>m-base</code></td></tr> <tr><td>Import progress</td><td>Determinate <code>LinearProgressIndicator</code> <code>m-instant</code> value updates (no pulse)</td></tr> <tr><td>Error table expand</td><td><code>AnimatedSize</code> <code>m-base</code>, never layout pop</td></tr> <tr><td>Dialog (delete/erase)</td><td>Scale+fade <code>m-base</code>; scrim <code>m-base</code></td></tr> <tr><td>Snackbar (undo/confirm)</td><td><code>m-base</code> slide</td></tr> </tbody></table> </div> <p>Reduced motion: all transitions ≤ <code>m-instant</code> fades; no stagger, no hero (<a href="users/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §8</a>).</p> <h2 id="5-micro-interactions-2"><a class="header" href="#5-micro-interactions-2">5. Micro-interactions</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Detail</th></tr></thead><tbody> <tr><td>Search typing</td><td>Debounce 300 ms; clear (×) button fades in with input</td></tr> <tr><td>Result count</td><td>"42 users" live-region update after search completes</td></tr> <tr><td>Status toggle</td><td>Switch flips immediately; server round-trip; rollback on error + snackbar</td></tr> <tr><td>FAB</td><td>Appears <code>m-entrance</code> scale; hides on scroll-down, shows on scroll-up</td></tr> <tr><td>Import start</td><td>Button → spinner ("Importing…"); anti-double-submit (<a href="users/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §6</a>)</td></tr> <tr><td>Import row error click</td><td>Expands inline message (already visible) — no modal for single errors</td></tr> <tr><td>Avatar upload</td><td>Overlay spinner on avatar; success check morph; revert on failure</td></tr> <tr><td>Infinite scroll</td><td>Bottom spinner fade-in <code>m-instant</code>; "end of list" footer</td></tr> </tbody></table> </div> <h2 id="6-import-progress-interaction-long-running"><a class="header" href="#6-import-progress-interaction-long-running">6. Import progress interaction (long-running)</a></h2> <p>Since <code>POST /users/import</code> is synchronous (<code>users.service.ts:233-282</code>):</p> <ol> <li><strong>Phase 1 — parsing (client, local):</strong> indeterminate? No — determinate fake-local parse with real row count (isolate reports progress every ~200 rows).</li> <li><strong>Phase 2 — upload:</strong> determinate byte progress (dio <code>onSendProgress</code>).</li> <li><strong>Phase 3 — server processing:</strong> indeterminate spinner labeled "Server is importing N rows — this can take a minute".</li> <li><strong>Phase 4 — result:</strong> determinate summary (imported/failed counts, <code>m-entrance</code> entrance).</li> </ol> <p>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).</p> <h2 id="7-frustration-controls"><a class="header" href="#7-frustration-controls">7. Frustration controls</a></h2> <ul> <li>Every interactive element responds within 100 ms (<a href="users/../00-shared/08_Interaction_&_Motion.html">00-shared/08 §6</a>).</li> <li>All network actions show pending state (button spinners, row overlay).</li> <li>Double-tap FAB → guarded (already navigating); double-submit guarded.</li> <li>Import phase-3 offers "You'll be notified when done" <code>(planned)</code> — today the request blocks the UI; keep the screen focused and show elapsed time.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-users-module"><a class="header" href="#11--design-system-mapping-users-module">11 — Design System Mapping (Users Module)</a></h1> <ul> <li><a href="users/11_Design_System_Mapping.html#1-theme--tokens-used">1. Theme & tokens used</a></li> <li><a href="users/11_Design_System_Mapping.html#2-status-colour-mapping-semantic-iconlabel--00-shared09-9">2. Status colour mapping (semantic, icon+label — 00-shared/09 §9)</a></li> <li><a href="users/11_Design_System_Mapping.html#3-flutter-widget-mapping-module-specific">3. Flutter widget mapping (module-specific)</a></li> <li><a href="users/11_Design_System_Mapping.html#4-layout-tokens">4. Layout tokens</a></li> <li><a href="users/11_Design_System_Mapping.html#5-iconography">5. Iconography</a></li> <li><a href="users/11_Design_System_Mapping.html#6-typography-mapping">6. Typography mapping</a></li> <li><a href="users/11_Design_System_Mapping.html#7-theming-rules-enforced-00-shared04-7">7. Theming rules (enforced, 00-shared/04 §7)</a></li> <li><a href="users/11_Design_System_Mapping.html#8-responsive-behaviour">8. Responsive behaviour</a></li> <li><a href="users/11_Design_System_Mapping.html#9-emptyerrorloading-tokens">9. Empty/error/loading tokens</a></li> </ul> <blockquote> <p>Maps the module's components/screens to the token set (<a href="users/../00-shared/02_Design_Tokens.html">00-shared/02_Design_Tokens.md</a>) and M3 widget mapping (<a href="users/../00-shared/04_Design_System_Mapping.html">00-shared/04_Design_System_Mapping.md</a>).</p> </blockquote> <hr /> <h2 id="1-theme--tokens-used"><a class="header" href="#1-theme--tokens-used">1. Theme & tokens used</a></h2> <div class="table-wrapper"><table><thead><tr><th>Token</th><th>Usage in this module</th></tr></thead><tbody> <tr><td><code>primary</code>/<code>onPrimary</code></td><td>FAB "Add user", primary CTAs (Start import, Save)</td></tr> <tr><td><code>success</code> (= <code>secondary</code>)</td><td><code>active</code> status badge, import success counts</td></tr> <tr><td><code>warning</code> (= <code>tertiary</code>)</td><td><code>suspended</code> badge accent, partial-import banner</td></tr> <tr><td><code>error</code></td><td><code>suspended</code> badge variant, delete/erase dialog actions, import error counts</td></tr> <tr><td><code>primaryContainer</code></td><td><code>invited</code> badge, selected role chips, info banners</td></tr> <tr><td><code>surfaceVariant</code></td><td>filter chips unselected, input fills</td></tr> <tr><td><code>inverseSurface</code></td><td>snackbars</td></tr> <tr><td><code>mono</code> (<a href="users/../00-shared/02_Design_Tokens.html">00-shared/02 §2</a>)</td><td>row numbers in import error list, <code>erased-*@anonymized.invalid</code> display, member ids</td></tr> <tr><td><code>r-sm…r-full</code></td><td>badges <code>r-sm</code>, cards/inputs <code>r-md</code>, dialogs <code>r-lg</code>, avatar <code>r-full</code></td></tr> <tr><td><code>e-0…e-5</code></td><td>list rows <code>e-0</code>, cards <code>e-1</code>, FAB <code>e-3</code>, dialogs <code>e-4</code></td></tr> <tr><td><code>m-*</code> motion</td><td>per <a href="users/10_Interaction_Specification.html">10_Interaction_Specification.md §4</a></td></tr> </tbody></table> </div> <h2 id="2-status-colour-mapping-semantic-iconlabel--00-shared09-9"><a class="header" href="#2-status-colour-mapping-semantic-iconlabel--00-shared09-9">2. Status colour mapping (semantic, icon+label — <a href="users/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §9</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Status (<code>user.schema.ts:7-12</code>)</th><th>Badge token</th><th>Icon</th><th>Label</th></tr></thead><tbody> <tr><td><code>active</code></td><td><code>success</code> container</td><td>check</td><td>Active</td></tr> <tr><td><code>inactive</code></td><td>outlined neutral</td><td>circle-off</td><td>Inactive</td></tr> <tr><td><code>suspended</code></td><td><code>error</code>/<code>tertiary</code></td><td>ban</td><td>Suspended</td></tr> <tr><td><code>invited</code></td><td><code>primaryContainer</code></td><td>mail</td><td>Invited</td></tr> </tbody></table> </div> <p>Never color-only; every badge pairs icon + text (<a href="users/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §9</a>). Dark-mode variants per token table (<a href="users/../00-shared/02_Design_Tokens.html">00-shared/02 §1</a>).</p> <h2 id="3-flutter-widget-mapping-module-specific"><a class="header" href="#3-flutter-widget-mapping-module-specific">3. Flutter widget mapping (module-specific)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Module component</th><th>Flutter</th></tr></thead><tbody> <tr><td><code>UserListTile</code></td><td><code>ListTile</code> + <code>CircleAvatar</code> (initials) + <code>MenuAnchor</code></td></tr> <tr><td><code>StatusBadge</code></td><td>custom <code>Container</code> pill (radius <code>r-sm</code>) + <code>Icon</code> + <code>Text</code></td></tr> <tr><td><code>RoleChips</code></td><td><code>FilterChip</code> row (<code>Wrap</code>)</td></tr> <tr><td><code>FilterBar</code></td><td><code>SearchBar</code> + <code>Wrap</code> of chips + <code>ActionChip</code> clear-all</td></tr> <tr><td><code>ImportWizard</code></td><td>custom <code>AppStepper</code> (<a href="users/../00-shared/03_Component_Library.html">00-shared/03</a>); step content via <code>AnimatedSwitcher</code></td></tr> <tr><td><code>CsvPreviewTable</code></td><td><code>DataTable</code> (desktop) / <code>ListView</code> of <code>ListTile</code>s (phone)</td></tr> <tr><td><code>ImportErrorList</code></td><td>dense <code>ListTile</code> rows, mono row number, <code>Badge</code> count</td></tr> <tr><td><code>PreferencesPanel</code></td><td><code>SwitchListTile</code> ×3, <code>RadioListTile</code> ×3, <code>DropdownButtonFormField</code></td></tr> <tr><td><code>AvatarUploader</code></td><td><code>GestureDetector</code> avatar + <code>showModalBottomSheet</code> (pick/crop)</td></tr> <tr><td><code>TypedConfirmDialog</code></td><td><code>AlertDialog</code> + <code>TextField</code> + destructive <code>FilledButton</code></td></tr> </tbody></table> </div> <h2 id="4-layout-tokens-2"><a class="header" href="#4-layout-tokens-2">4. Layout tokens</a></h2> <ul> <li>List row height ≥ 56 (<a href="users/../00-shared/02_Design_Tokens.html">00-shared/02 §3</a>); dense error rows ≥ 48.</li> <li>Screen padding 16/24 (<a href="users/../00-shared/04_Design_System_Mapping.html">00-shared/04 §6</a>); card gap 12; section gap 24.</li> <li>Master-detail breakpoint 840 dp (<a href="users/../00-shared/04_Design_System_Mapping.html">00-shared/04 §6</a>); filters collapse to a bottom sheet on phone (< 600 dp).</li> </ul> <h2 id="5-iconography-2"><a class="header" href="#5-iconography-2">5. Iconography</a></h2> <p>Material Symbols outlined, 24 dp default (<a href="users/../00-shared/02_Design_Tokens.html">00-shared/02 §7</a>); status icons filled variant. Icons used: <code>person_add</code> (FAB), <code>upload_file</code> (import), <code>download</code> (template/errors), <code>edit</code>, <code>block</code> (suspend), <code>delete_forever</code>, <code>shield_person</code> (erasure), <code>notifications</code>, <code>palette</code> (theme), <code>photo_camera</code> (avatar). All with labels/tooltips (<a href="users/../00-shared/02_Design_Tokens.html">00-shared/02 §7</a>).</p> <h2 id="6-typography-mapping"><a class="header" href="#6-typography-mapping">6. Typography mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Element</th><th>Token</th></tr></thead><tbody> <tr><td>Screen title "Users"</td><td><code>headlineMedium</code></td></tr> <tr><td>Row title (displayName)</td><td><code>titleMedium</code></td></tr> <tr><td>Row subtitle (email)</td><td><code>bodyMedium</code> <code>onSurfaceVariant</code></td></tr> <tr><td>Badge text</td><td><code>labelMedium</code></td></tr> <tr><td>Import counts</td><td><code>displaySmall</code> (headline numbers) + <code>bodyMedium</code> captions</td></tr> <tr><td>Error row message</td><td><code>bodyMedium</code> <code>error</code>-container tint</td></tr> <tr><td>Row numbers / ids</td><td><code>mono</code></td></tr> </tbody></table> </div> <h2 id="7-theming-rules-enforced-00-shared04-7"><a class="header" href="#7-theming-rules-enforced-00-shared04-7">7. Theming rules (enforced, <a href="users/../00-shared/04_Design_System_Mapping.html">00-shared/04 §7</a>)</a></h2> <ol> <li>No literal colors — token references only.</li> <li>No literal spacing — <code>AppSpacing</code> grid.</li> <li>No literal durations — <code>AppMotion</code>.</li> <li>Dark mode first-class (badge contrast ≥ 4.5:1 text, ≥ 3:1 UI <a href="users/../00-shared/09_Accessibility_Baseline.html">00-shared/09 §2</a>).</li> <li>Tenant branding overrides <code>primary</code> only — status colors stay semantic.</li> </ol> <h2 id="8-responsive-behaviour"><a class="header" href="#8-responsive-behaviour">8. Responsive behaviour</a></h2> <div class="table-wrapper"><table><thead><tr><th>Class</th><th>Layout</th></tr></thead><tbody> <tr><td>Phone < 600 dp</td><td>single pane; filters in bottom sheet; FAB</td></tr> <tr><td>Tablet 600–839 dp</td><td>list + expanded row info; filter chips inline</td></tr> <tr><td>Landscape/desktop ≥ 840 dp</td><td>master-detail; pager; hover states; keyboard shortcuts (<a href="users/10_Interaction_Specification.html">10_Interaction_Specification.md §2</a>)</td></tr> </tbody></table> </div> <h2 id="9-emptyerrorloading-tokens"><a class="header" href="#9-emptyerrorloading-tokens">9. Empty/error/loading tokens</a></h2> <ul> <li>Skeleton: <code>surfaceVariant</code> shimmer <code>m-slow</code> (<a href="users/../00-shared/03_Component_Library.html">00-shared/03 AppSkeleton</a>).</li> <li>Empty state: <code>displaySmall</code> title (<a href="users/../00-shared/03_Component_Library.html">00-shared/03 AppEmptyState</a>).</li> <li>Error state: <code>error</code> icon, <code>titleLarge</code>, generic copy + requestId (<a href="users/../00-shared/03_Component_Library.html">00-shared/03 AppErrorState</a>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-users-module"><a class="header" href="#12--api-mapping-users-module">12 — API Mapping (Users Module)</a></h1> <ul> <li><a href="users/12_API_Mapping.html#e1--create-user">E1 — Create user</a></li> <li><a href="users/12_API_Mapping.html#e2--list-users">E2 — List users</a></li> <li><a href="users/12_API_Mapping.html#e3--get-user">E3 — Get user</a></li> <li><a href="users/12_API_Mapping.html#e4--update-user">E4 — Update user</a></li> <li><a href="users/12_API_Mapping.html#e5--delete-user-soft">E5 — Delete user (soft)</a></li> <li><a href="users/12_API_Mapping.html#e6--gdpr-erasure">E6 — GDPR erasure</a></li> <li><a href="users/12_API_Mapping.html#e7--get-preferences">E7 — Get preferences</a></li> <li><a href="users/12_API_Mapping.html#e8--update-preferences">E8 — Update preferences</a></li> <li><a href="users/12_API_Mapping.html#e9--upload-avatar">E9 — Upload avatar</a></li> <li><a href="users/12_API_Mapping.html#e10--bulk-import-users-inline-synchronous">E10 — Bulk import users (inline, synchronous)</a></li> <li><a href="users/12_API_Mapping.html#e11--bulk-module-adapters--export">E11 — Bulk module (adapters) & export</a></li> <li><a href="users/12_API_Mapping.html#e12--rbac-bridge-roles--membership">E12 — RBAC bridge (roles & membership)</a></li> <li><a href="users/12_API_Mapping.html#e13--auth-module-touchpoints-self-service-context">E13 — Auth module touchpoints (self-service context)</a></li> <li><a href="users/12_API_Mapping.html#rate-limits-client-relevant-00-shared07-4">Rate limits (client-relevant, 00-shared/07 §4)</a></li> <li><a href="users/12_API_Mapping.html#client-contract-summary-all-screens">Client contract summary (all screens)</a></li> </ul> <blockquote> <p>Exact endpoints per screen. Wire contract per <a href="users/../00-shared/07_API_Conventions.html">00-shared/07</a>: base <code>/api/v1</code>, Bearer JWT, success <code>{success:true,message:"OK",data,meta?,timestamp, requestId}</code>, error envelope with codes. Only shapes in code are used. <code>(planned)</code>/<code>(forward-looking)</code> marked. Sources: <code>users.controller.ts</code>, <code>users.service.ts</code>, <code>bulk.controller.ts</code>, <code>rbac.controller.ts</code>, <code>auth.controller.ts</code>.</p> </blockquote> <hr /> <h2 id="e1--create-user"><a class="header" href="#e1--create-user">E1 — Create user</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>POST /api/v1/users</code> (<code>users.controller.ts:36-40</code>)</td></tr> <tr><td>Guard</td><td><code>JwtAuthGuard</code> (<code>:31</code>); RBAC permission guard <code>(planned)</code> — <code>user.create</code> exists (<code>permissions.constants.ts:7</code>) but is not enforced on this route (OQ-11)</td></tr> <tr><td>Request</td><td><code>CreateUserDto</code> (<code>create-user.dto.ts:5-62</code>) — firstName/lastName/email required; status enum default <code>active</code> (<code>:44-47</code>); tenantId from token only</td></tr> <tr><td>Response</td><td>201 envelope, <code>data</code> = user doc (displayName auto <code>"<firstName> <lastName>"</code> — <code>users.service.ts:62</code>); no <code>meta</code></td></tr> <tr><td>Errors</td><td>400 <code>VALIDATION_ERROR</code>; 409 <code>DUPLICATE_RESOURCE</code> email/phone (<code>users.service.ts:50-60</code>); 429 <code>RATE_LIMITED</code>; 5xx (incl. race dup-key, OQ-14)</td></tr> <tr><td>Side effects</td><td><code>UserCreated</code> event → <code>in-app</code> queue <code>user-created-notification</code> (<code>event-queue-map.ts:10</code>)</td></tr> <tr><td>Client</td><td>SS2 create form; roles via E12 after success; cache invalidate users list</td></tr> <tr><td>Realtime</td><td>in-app notification to tenant (P9, <a href="users/02_User_Personas.html">02_User_Personas.md</a>)</td></tr> </tbody></table> </div> <h2 id="e2--list-users"><a class="header" href="#e2--list-users">E2 — List users</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>GET /api/v1/users?page&limit&sort&q</code> (<code>users.controller.ts:42-46</code>)</td></tr> <tr><td>Params</td><td><code>page</code> ≥1 default 1; <code>limit</code> 1–100 default 20; <code>sort</code> (<code>-field</code> desc); <code>q</code> → <code>$or</code> regex on firstName/lastName/email/displayName, case-insensitive (<code>users.service.ts:92-99</code>); default sort <code>-createdAt</code> (<code>:103</code>)</td></tr> <tr><td>Response</td><td>paginated: <code>data</code> array + <code>meta {page,limit,totalItems,totalPages,hasNext,hasPrevious}</code> (<code>pagination-query.dto.ts:32-55</code>)</td></tr> <tr><td>Errors</td><td>400 (bad params); 429; 5xx</td></tr> <tr><td>Client</td><td>SS1 list; debounced search; infinite scroll while <code>hasNext</code>; pull-to-refresh bypasses cache</td></tr> <tr><td>Filters</td><td><strong>No status/role query params</strong> (<code>users.service.ts:90-115</code>) — status/role chips are client-side <code>(planned)</code> server params (OQ-9)</td></tr> <tr><td>Cache</td><td>client paginated cache <code>sl:{tenant}:users:{query}</code> TTL 5 min (volatile, <a href="users/../00-shared/06_State_Management.html">00-shared/06 §3.3</a>)</td></tr> </tbody></table> </div> <h2 id="e3--get-user"><a class="header" href="#e3--get-user">E3 — Get user</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>GET /api/v1/users/:id</code> (<code>users.controller.ts:48-53</code>)</td></tr> <tr><td>Response</td><td>200 envelope, <code>data</code> = user doc</td></tr> <tr><td>Errors</td><td>400 (bad ObjectId); 404 <code>RESOURCE_NOT_FOUND</code> (<code>users.service.ts:86</code>); cross-tenant/deleted ids → 404 (scoped repo — <code>base.repository.ts:20-30</code>; no existence leak)</td></tr> <tr><td>Client</td><td>SS3 detail / SS7 self profile (<code>:id</code> = JWT <code>sub</code>, <code>auth.service.ts:461</code>; server self-guard <code>(planned)</code> OQ-3)</td></tr> </tbody></table> </div> <h2 id="e4--update-user"><a class="header" href="#e4--update-user">E4 — Update user</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>PATCH /api/v1/users/:id</code> (<code>users.controller.ts:55-60</code>)</td></tr> <tr><td>Request</td><td><code>UpdateUserDto</code> (<code>update-user.dto.ts:5-70</code>) — partial; <code>$set</code> merge + <code>version</code> +1 (<code>base.repository.ts:57-66</code>)</td></tr> <tr><td>Response</td><td>200 updated doc; displayName recomputed when names change (<code>users.service.ts:136-139</code>)</td></tr> <tr><td>Errors</td><td>404; 409 email/phone change conflicts (<code>:120-134</code>); 400 enums invalid</td></tr> <tr><td>Side effects</td><td><code>UserUpdated</code> (changes list) → <code>audit-write</code> (<code>users.service.ts:145-153</code>; <code>event-queue-map.ts:11</code>)</td></tr> <tr><td>Client</td><td>SS5 edit; SS6 status change; conflict → inline field error</td></tr> </tbody></table> </div> <h2 id="e5--delete-user-soft"><a class="header" href="#e5--delete-user-soft">E5 — Delete user (soft)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>DELETE /api/v1/users/:id</code> (<code>users.controller.ts:62-67</code>)</td></tr> <tr><td>Behaviour</td><td>soft delete: <code>isDeleted:true, deletedAt, deletedBy</code> + version (<code>base.repository.ts:68-74</code>); handler returns void → 200 <code>data</code> null; emits <code>UserDeleted</code> → <code>audit-write</code> (<code>users.service.ts:176-183</code>; <code>event-queue-map.ts:12</code>)</td></tr> <tr><td>Errors</td><td>404 (<code>users.service.ts:175</code>)</td></tr> <tr><td>Client</td><td>SS8 typed-confirm; row removed; snackbar "purged after 30 days"</td></tr> <tr><td>Purge</td><td><code>TENANT_PURGE</code> worker hard-deletes <code>isDeleted</code> docs older than 30 days (<code>tenant-purge.worker.ts:32-43</code>); idempotent</td></tr> <tr><td>Sessions</td><td>active JWTs not revoked (OQ-8); user cannot re-login (<code>users.repository.ts:21-25</code>)</td></tr> </tbody></table> </div> <h2 id="e6--gdpr-erasure"><a class="header" href="#e6--gdpr-erasure">E6 — GDPR erasure</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>POST /api/v1/users/:id/erasure</code> (<code>users.controller.ts:69-76</code>)</td></tr> <tr><td>Behaviour</td><td>anonymize (<code>Erased User</code>, <code>erased-<id>@anonymized.invalid</code>), <code>isDeleted:true</code> (<code>users.service.ts:187-197</code>); enqueue <code>gdpr-erasure</code> on <code>tenant-purge</code> queue, <code>attempts:3</code>, exponential backoff 5000 ms (<code>:200-209</code>); worker hard-deletes user doc (<code>tenant-purge.worker.ts:49-56</code>)</td></tr> <tr><td>Errors</td><td>404 (<code>users.service.ts:198</code>)</td></tr> <tr><td>Client</td><td>SS8 typed "ERASE" confirm; irreversible copy</td></tr> </tbody></table> </div> <h2 id="e7--get-preferences"><a class="header" href="#e7--get-preferences">E7 — Get preferences</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>GET /api/v1/users/:id/preferences</code> (<code>users.controller.ts:78-83</code>)</td></tr> <tr><td>Response</td><td><code>data</code> = <code>user.preferences ?? {}</code> (<code>users.service.ts:168-171</code>) — never null</td></tr> <tr><td>Errors</td><td>404 (via <code>findById</code>)</td></tr> <tr><td>Client</td><td>SS6 load</td></tr> </tbody></table> </div> <h2 id="e8--update-preferences"><a class="header" href="#e8--update-preferences">E8 — Update preferences</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>PATCH /api/v1/users/:id/preferences</code> (<code>users.controller.ts:85-93</code>)</td></tr> <tr><td>Request</td><td><code>UpdateUserPreferencesDto</code> (<code>update-user-preferences.dto.ts:4-21</code>) — <code>notifications{email,push,sms}</code>, `theme{mode: light</td></tr> <tr><td>Behaviour</td><td><strong>full replace</strong> <code>$set:{preferences:dto}</code> (<code>users.service.ts:161-163</code>) — client sends merged complete object</td></tr> <tr><td>Response</td><td>200 updated doc</td></tr> <tr><td>Errors</td><td>404; 400 (non-object groups)</td></tr> <tr><td>Client</td><td>SS6; switches optimistic but submit full object</td></tr> </tbody></table> </div> <h2 id="e9--upload-avatar"><a class="header" href="#e9--upload-avatar">E9 — Upload avatar</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>POST /api/v1/users/:id/avatar</code> (<code>users.controller.ts:95-102</code>) — multipart, field <code>file</code> (<code>FileInterceptor('file')</code> <code>:96</code>)</td></tr> <tr><td>Request</td><td>image buffer; server prefixes filename <code>${id}-</code> (<code>users.service.ts:219</code>); no size/mime validation in code (OQ-10)</td></tr> <tr><td>Response</td><td><code>data {avatarFileId}</code> (<code>:230</code>); previous avatar deleted best-effort (<code>:227-229</code>)</td></tr> <tr><td>Errors</td><td>404 (<code>:216</code>); 400 missing file (multer); 5xx storage failures</td></tr> <tr><td>Client</td><td>SS7 <code>AvatarUploader</code>; display URL resolution <code>(planned)</code> (endpoint returns id only)</td></tr> </tbody></table> </div> <h2 id="e10--bulk-import-users-inline-synchronous"><a class="header" href="#e10--bulk-import-users-inline-synchronous">E10 — Bulk import users (inline, synchronous)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>POST /api/v1/users/import</code> (<code>users.controller.ts:104-110</code>) — multipart, field <code>file</code></td></tr> <tr><td>Behaviour</td><td>sync parse + per-row create (<code>users.service.ts:233-282</code>); header required + ≥1 row (<code>:238-243</code>); headers lowercased (<code>:244-247</code>); required <code>email</code> (<code>:256-259</code>); dup email → row error (<code>:260-263</code>); defaults <code>Unknown</code>/<code>en</code>/<code>UTC</code> (<code>:265-271</code>); <strong>no <code>UserCreated</code> events for rows</strong> (<code>:264-272</code> direct <code>repo.create</code>)</td></tr> <tr><td>Response</td><td>200 envelope, <code>data = {imported: number, errors: string[]}</code> (<code>:281</code>) — errors <code>"Row N: message"</code></td></tr> <tr><td>Errors</td><td>429; 5xx. Malformed/empty CSV is <strong>not</strong> an error envelope — returns <code>{imported:0, errors:[…]}</code> (<code>:238-243</code>)</td></tr> <tr><td>Async/polling</td><td><strong>none exists</strong> — result returned in the same request. Async queue + polling <code>(planned)</code> per <code>PLAN.md 2.7</code> (OQ-6)</td></tr> <tr><td>Client</td><td>SS4 wizard; progress UI per <a href="users/10_Interaction_Specification.html">10_Interaction_Specification.md §6</a></td></tr> </tbody></table> </div> <h2 id="e11--bulk-module-adapters--export"><a class="header" href="#e11--bulk-module-adapters--export">E11 — Bulk module (adapters) & export</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Import</td><td><code>POST /api/v1/bulk/import/:entity</code> (<code>bulk.controller.ts:35-48</code>) — multipart <code>file</code> required (<code>:43-46</code>); parser <code>csv-parse/sync</code> (<code>bulk-import.service.ts:26-30</code>); malformed → 400 <code>VALIDATION_ERROR</code> (<code>:31-33</code>); report <code>{entity,totalRows,imported,failed,errors[{rowNumber,errors[]}]}</code> (<code>import-adapter.interface.ts:14-25</code>); rowNumber = physical line (<code>:46</code>)</td></tr> <tr><td>Entities</td><td><code>students</code> only (<code>students-import.adapter.ts:15</code>) — creates users via <code>UsersService.create</code> per row (<code>:66-71</code>); <code>users</code> entity → 404 <code>RESOURCE_NOT_FOUND</code> "No import adapter" (<code>bulk-import.service.ts:17-20</code>) <code>(planned)</code> users adapter</td></tr> <tr><td>Export</td><td><code>GET /api/v1/bulk/export/:entity</code> (<code>bulk.controller.ts:50-60</code>) — <code>text/csv</code>, <code>Content-Disposition: attachment; filename="<entity>.csv"</code> (<code>:55-58</code>); students rows = admissionNumber/rollNumber/status/admissionDate (<code>students-import.adapter.ts:87-98</code>)</td></tr> <tr><td>Client</td><td>SS4 alternate path when users adapter lands; template download</td></tr> </tbody></table> </div> <h2 id="e12--rbac-bridge-roles--membership"><a class="header" href="#e12--rbac-bridge-roles--membership">E12 — RBAC bridge (roles & membership)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Roles</td><td><code>GET /api/v1/rbac/roles</code> (<code>rbac.controller.ts:27-31</code>) — role slugs for chips/selectors</td></tr> <tr><td>Members</td><td><code>GET /api/v1/rbac/members</code> (<code>:57-61</code>) — join for role chips; <code>POST /api/v1/rbac/members</code> <code>{userId, roles[]}</code> (<code>:63-67</code>; creates <code>ACTIVE</code> member — <code>rbac.service.ts:113-127</code>); <code>PATCH /api/v1/rbac/members/:id</code> roles (<code>:69-73</code>); <code>DELETE /api/v1/rbac/members/:id</code> (<code>:75-79</code>)</td></tr> <tr><td>Guard</td><td><code>org_admin</code> role required (<code>rbac.controller.ts:21-22</code>) — members API unavailable to HR-only roles (OQ-15)</td></tr> <tr><td>Cache</td><td>permissions cached Redis <code>sl:{tenantId}:perm:{userId}</code> TTL 300 s (<code>rbac.service.ts:44-73</code>)</td></tr> </tbody></table> </div> <h2 id="e13--auth-module-touchpoints-self-service-context"><a class="header" href="#e13--auth-module-touchpoints-self-service-context">E13 — Auth module touchpoints (self-service context)</a></h2> <div class="table-wrapper"><table><thead><tr><th></th><th></th></tr></thead><tbody> <tr><td>Register</td><td><code>POST /api/v1/auth/register</code> (rate 5/min — <code>auth.controller.ts:30-36</code>): creates user + <code>auth_account</code> + <code>org_admin</code> membership + welcome email (<code>auth.service.ts:54-121</code>; <code>event-queue-map.ts:7</code>)</td></tr> <tr><td>Verify/resend</td><td><code>POST /auth/verify-email</code> (<code>:74-81</code>); <code>POST /auth/resend-verification</code> (5/120 s, <code>:83-89</code>) — verification email is the only email an admin-created user can get today (via manual resend, needs auth) — invite email <code>(planned)</code></td></tr> <tr><td>Sessions</td><td><code>GET /auth/sessions</code>, <code>DELETE /auth/sessions/:id</code>, <code>POST /auth/logout-all</code> (<code>:66-72,130-142</code>) — logout-all is the only session revocation tool (not called on delete/erase — OQ-8)</td></tr> </tbody></table> </div> <hr /> <h2 id="rate-limits-client-relevant-00-shared07-4-1"><a class="header" href="#rate-limits-client-relevant-00-shared07-4-1">Rate limits (client-relevant, <a href="users/../00-shared/07_API_Conventions.html">00-shared/07 §4</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Tier</th><th>Limit</th><th>Notes</th></tr></thead><tbody> <tr><td>auth</td><td>10/min (5 register, 10 login, 20 refresh, 5 resend/120 s)</td><td>countdown copy, no auto-retry</td></tr> <tr><td>api</td><td>100/min</td><td>users CRUD/import default tier</td></tr> <tr><td>admin</td><td>500/min</td><td><code>(planned)</code> if admin endpoints get throttled</td></tr> </tbody></table> </div> <h2 id="client-contract-summary-all-screens-2"><a class="header" href="#client-contract-summary-all-screens-2">Client contract summary (all screens)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Concern</th><th>Rule</th></tr></thead><tbody> <tr><td>Auth</td><td>Bearer JWT; 401 → single-flight refresh → replay; fail → session expiry (<a href="users/../00-shared/06_State_Management.html">00-shared/06 §3.6</a>)</td></tr> <tr><td>Optimistic</td><td>only safe toggles (status, preferences switches) with rollback; create/import/erase never optimistic</td></tr> <tr><td>Idempotency</td><td>DELETE/PATCH retry-safe; <code>Idempotency-Key</code> support unconfirmed (B6) — import retry safe by dedup (<code>users.service.ts:260-263</code>)</td></tr> <tr><td>Offline</td><td>reads from last-good cache + banner; writes blocked (no module offline queue)</td></tr> <tr><td>Pagination</td><td><code>page/limit/sort/q</code> + <code>meta</code> exact (<a href="users/../00-shared/07_API_Conventions.html">00-shared/07 §5</a>)</td></tr> <tr><td>Realtime</td><td>WS topics n/a for users today; <code>(forward-looking)</code> <code>user.updated</code>, <code>user.deleted</code></td></tr> <tr><td>Error mapping</td><td><a href="users/../00-shared/06_State_Management.html">00-shared/06 §5</a>: 400 field, 403 hide/deny, 404 empty, 409 inline conflict, 429 backoff, 5xx generic + requestId</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-users-module"><a class="header" href="#13--state-management-users-module">13 — State Management (Users Module)</a></h1> <ul> <li><a href="users/13_State_Management.html#1-cubit-map">1. Cubit map</a></li> <li><a href="users/13_State_Management.html#2-userslistcubit">2. UsersListCubit</a></li> <li><a href="users/13_State_Management.html#3-userdetailcubit">3. UserDetailCubit</a></li> <li><a href="users/13_State_Management.html#4-userformcubit-create--edit">4. UserFormCubit (create + edit)</a></li> <li><a href="users/13_State_Management.html#5-preferencescubit">5. PreferencesCubit</a></li> <li><a href="users/13_State_Management.html#6-bulkimportcubit--the-import-state-machine">6. BulkImportCubit — the import state machine</a></li> <li><a href="users/13_State_Management.html#7-selfprofilecubit">7. SelfProfileCubit</a></li> <li><a href="users/13_State_Management.html#8-membershipcubit">8. MembershipCubit</a></li> <li><a href="users/13_State_Management.html#9-cross-cutting">9. Cross-cutting</a></li> </ul> <blockquote> <p>Per-screen state on top of <a href="users/../00-shared/06_State_Management.html">00-shared/06_State_Management.md</a> (Bloc/Cubit proposal). Cubits are <code>(planned)</code>; repository/API facts are exact. Special attention: the long-running import state machine (<a href="users/06_Screen_Specifications.html">06 §6</a>).</p> </blockquote> <hr /> <h2 id="1-cubit-map-9"><a class="header" href="#1-cubit-map-9">1. Cubit map</a></h2> <pre><code>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) </code></pre> <h2 id="2-userslistcubit"><a class="header" href="#2-userslistcubit">2. UsersListCubit</a></h2> <ul> <li>State: <code>LoadState</code> (<code>00-shared/06 §3.1</code>) + <code>items</code>, <code>page</code>, <code>limit=20</code>, <code>sort=-createdAt</code> (default, <code>users.service.ts:103</code>), <code>q</code>, <code>totalItems</code>, <code>hasNext</code>, plus <code>rolesMap</code> (from <code>GET /rbac/members</code> joined by userId — <code>rbac.controller.ts:57-61</code>).</li> <li>Events: <code>Load</code>, <code>Refresh</code> (reset page 1, bypass cache), <code>LoadMore</code>, <code>ChangeQuery(q)</code> (debounced 300 ms, resets page), <code>ChangeStatusFilter</code>, <code>ChangeRoleFilter</code>, <code>Retry</code>.</li> <li>Server contract: envelope <code>data[]</code> + <code>meta {page,limit,totalItems, totalPages,hasNext,hasPrevious}</code> (<code>pagination-query.dto.ts:32-55</code>).</li> <li>Status/role chips filter <strong>loaded pages only</strong> (no server params — <code>users.service.ts:90-115</code>; OQ-9) — state carries a <code>clientFilterActive</code> flag for the honest caption.</li> <li>Cache: <code>sl:{tenant}:users:{query}</code> TTL 5 min; <code>Refresh</code> bypasses.</li> <li>Row action outcomes: after <code>DELETE /users/:id</code> (soft) remove row locally + snackbar (safe, idempotent); after <code>PATCH status</code> update badge locally, rollback on error.</li> </ul> <h2 id="3-userdetailcubit"><a class="header" href="#3-userdetailcubit">3. UserDetailCubit</a></h2> <ul> <li>State: <code>LoadState</code> + <code>user</code>, <code>member?</code> (from <code>GET /rbac/members</code> join), <code>preferences</code> (lazy via tab).</li> <li>404 handling: <code>RESOURCE_NOT_FOUND</code> → empty-state "User not found" (cross-tenant/deleted/erased — <code>base.repository.ts:20-30</code>).</li> <li>After Edit save: replace <code>user</code> with PATCH response (server recomputed <code>displayName</code> — <code>users.service.ts:136-139</code>).</li> <li>Erasure: sets flag <code>erased</code> → navigates back, list refresh.</li> </ul> <h2 id="4-userformcubit-create--edit"><a class="header" href="#4-userformcubit-create--edit">4. UserFormCubit (create + edit)</a></h2> <ul> <li>Shared by SS2/SS5; mode <code>create | edit</code>.</li> <li>Field-level state mirrors <code>CreateUserDto</code>/<code>UpdateUserDto</code> (<code>08_Form_Specifications.md §1-§2</code>); dirty tracking for unsaved-changes dialog.</li> <li>Submit flow (create): <code>POST /users</code> → on success, if roles selected → <code>POST /rbac/members</code>; second call failure → state <code>createdWithoutRoles</code> → persistent retry banner (membership unique index makes retry safe — <code>organization-member.schema.ts:48</code>).</li> <li>Errors: 400 <code>VALIDATION_ERROR.details</code> mapped per field (<code>00-shared/07 §3</code>); 409 email/phone → inline conflict state + "search existing" affordance; 429 → backoff copy; 5xx → generic + requestId.</li> <li>5xx on create may be a <strong>concurrent duplicate</strong> (unique index race — <code>user.schema.ts:83</code>; OQ-14) — copy "someone may already exist with this email" + refresh list.</li> </ul> <h2 id="5-preferencescubit"><a class="header" href="#5-preferencescubit">5. PreferencesCubit</a></h2> <ul> <li>State: <code>LoadState</code> + full <code>preferences</code> object (<code>GET</code> → <code>?? {}</code>, <code>users.service.ts:168-171</code>).</li> <li>Optimistic toggles per <code>00-shared/06 §3.5</code> (safe switches), <strong>but submit sends the complete merged object</strong> — full-replace contract (<code>users.service.ts:161-163</code>); rollback on failure.</li> <li>Debounced auto-save <code>(proposed)</code> — every toggle change PATCHes the full object; avoid race by serializing saves (queue one in-flight save).</li> </ul> <h2 id="6-bulkimportcubit--the-import-state-machine"><a class="header" href="#6-bulkimportcubit--the-import-state-machine">6. BulkImportCubit — the import state machine</a></h2> <p>Synchronous backend (<code>users.service.ts:233-282</code>) ⇒ the machine is <strong>client-staged</strong>, with honest labels (never fake server progress — OQ-6):</p> <pre><code class="language-dart">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 } </code></pre> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Transition</th><th>Notes</th></tr></thead><tbody> <tr><td><code>PickFile</code></td><td>Idle → ParsingLocal</td><td>reject non-CSV/size > 2 MB (OQ-10)</td></tr> <tr><td><code>ParseProgress(n,total)</code></td><td>ParsingLocal (re-emit)</td><td>isolate posts every ~200 rows</td></tr> <tr><td><code>ParseDone</code></td><td>→ PreviewReady</td><td>warnings: missing optional cols, quotes (naive parser — <code>users.service.ts:251</code>; OQ-7), in-file dup emails (<code>:260-263</code>), <code>Unknown</code> fallback preview (<code>:265-266</code>)</td></tr> <tr><td><code>ConfirmImport</code></td><td>→ Uploading</td><td>multipart field <code>file</code> (<code>users.controller.ts:104-110</code>)</td></tr> <tr><td><code>UploadDone</code></td><td>→ ServerProcessing</td><td>request in flight; elapsed timer</td></tr> <tr><td><code>ImportDone(payload)</code></td><td>→ Succeeded</td><td><code>{imported, errors}</code> (<code>users.service.ts:281</code>)</td></tr> <tr><td><code>ImportError(e)</code></td><td>→ Failed</td><td>retry allowed — server dedups emails (<code>:260-263</code>), retry never duplicates</td></tr> <tr><td><code>Cancel</code></td><td>Uploading → Idle</td><td>local only; ServerProcessing cannot cancel</td></tr> <tr><td><code>ImportMore</code></td><td>Succeeded/Failed → Idle</td><td>keep parsed template</td></tr> </tbody></table> </div> <ul> <li><strong>State survival:</strong> cubit lives above the route (registered at shell scope) so leaving the wizard preserves preview/result; navigation back reuses state (<code>04_IA §7</code>).</li> <li><strong>Result view</strong> derives: <code>errors.isEmpty → success</code>; else counts + <code>ImportErrorList</code> rows; "Download errors" client-side CSV export.</li> <li><strong>Concurrency:</strong> one import at a time per tenant UI; block the FAB/route guard while <code>Uploading/ServerProcessing</code> (anti-double-submit, <code>00-shared/08 §6</code>). Two tabs importing the same file: server dedups; both see consistent <code>errors</code> (each row's email check is sequential — <code>users.service.ts:250-279</code>).</li> <li><strong>Idempotency key:</strong> <code>Idempotency-Key</code> support unconfirmed (B6); safe because duplicates are rejected not re-created.</li> </ul> <h2 id="7-selfprofilecubit"><a class="header" href="#7-selfprofilecubit">7. SelfProfileCubit</a></h2> <ul> <li>Resolves <code>:id</code> from JWT <code>sub</code> (<code>auth.service.ts:461</code>); state <code>LoadState</code> <ul> <li>user; avatar upload sub-state (uploading → success <code>{avatarFileId}</code> / error) — <code>users.service.ts:230</code>.</li> </ul> </li> <li>Permission-derived visibility: edit/delete/erase controls rendered via <code>PermissionScoped</code> (<code>07 §11</code>) with the client permission set (<code>00-shared/05 §9</code>).</li> </ul> <h2 id="8-membershipcubit"><a class="header" href="#8-membershipcubit">8. MembershipCubit</a></h2> <ul> <li>Loads <code>GET /rbac/members</code> + <code>GET /rbac/roles</code> (<code>rbac.controller.ts:27-31,57-61</code>); role edit → <code>PATCH /rbac/members/:id</code> (<code>:69-73</code>); remove → <code>DELETE /rbac/members/:id</code> (<code>:75-79</code>).</li> <li>Not org_admin (403) → empty state with "no role visibility" copy (OQ-15).</li> <li>Copy rule: "Role changes apply on next sign-in" (JWT embeds roles at issue — <code>auth.service.ts:460-476</code>).</li> </ul> <h2 id="9-cross-cutting-1"><a class="header" href="#9-cross-cutting-1">9. Cross-cutting</a></h2> <ul> <li>Auth state (<code>00-shared/06 §3.6</code>): any 401 → single-flight refresh; on failure, session-expiry overlay preserving list query.</li> <li>Connectivity (<code>00-shared/06 §3.7</code>): offline → cached list + banner; writes blocked (no module offline queue); import wizard blocked at step 1 with guidance.</li> <li>Permission changes → route rebuild (<code>00-shared/05 §9</code>): losing <code>user.*</code> mid-session removes users routes.</li> <li>Analytics <code>(proposed)</code> (<code>10_QA_Baseline.md §8</code>): <code>users.list.search</code>, <code>users.create.submit</code>, <code>users.import.start|complete|failure</code>, <code>users.erasure.confirm</code>, <code>users.status.change</code>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-users-module"><a class="header" href="#14--qa-checklist-users-module">14 — QA Checklist (Users Module)</a></h1> <ul> <li><a href="users/14_QA_Checklist.html#1-functional-core-crud">1. Functional (core CRUD)</a></li> <li><a href="users/14_QA_Checklist.html#2-gdpr-erasure">2. GDPR erasure</a></li> <li><a href="users/14_QA_Checklist.html#3-bulk-import--users-inline-endpoint">3. Bulk import — users inline endpoint</a> <ul> <li><a href="users/14_QA_Checklist.html#c1--1000-row-scenario">C1 — 1,000-row scenario</a></li> <li><a href="users/14_QA_Checklist.html#c2--concurrency--idempotency">C2 — Concurrency & idempotency</a></li> <li><a href="users/14_QA_Checklist.html#c3--csv-edge-cases">C3 — CSV edge cases</a></li> <li><a href="users/14_QA_Checklist.html#c4--bulk-module-students-adapter">C4 — Bulk module (students adapter)</a></li> </ul> </li> <li><a href="users/14_QA_Checklist.html#4-multi-tenant-isolation">4. Multi-tenant isolation</a></li> <li><a href="users/14_QA_Checklist.html#5-avatar">5. Avatar</a></li> <li><a href="users/14_QA_Checklist.html#6-permissions--guards">6. Permissions & guards</a></li> <li><a href="users/14_QA_Checklist.html#7-performance--reliability">7. Performance & reliability</a></li> <li><a href="users/14_QA_Checklist.html#8-ux--a11y-per-00-shared09">8. UX & a11y (per 00-shared/09)</a></li> <li><a href="users/14_QA_Checklist.html#9-analytics-proposed-00-shared10-8">9. Analytics (proposed, 00-shared/10 §8)</a></li> <li><a href="users/14_QA_Checklist.html#10-release-gates">10. Release gates</a></li> </ul> <blockquote> <p>Module-specific checks on top of <a href="users/../00-shared/10_QA_Baseline.html">00-shared/10_QA_Baseline.md</a>. Every item is verifiable against the backend behaviors cited. Backend facts exact; expected-client column is this module's contract.</p> </blockquote> <hr /> <h2 id="1-functional-core-crud"><a class="header" href="#1-functional-core-crud">1. Functional (core CRUD)</a></h2> <ul> <li><input disabled="" type="checkbox"/> Create user happy path → 201 doc; <code>displayName</code> = <code>"<first> <last>"</code> (<code>users.service.ts:62</code>); list shows row after refresh.</li> <li><input disabled="" type="checkbox"/> Create without <code>displayName</code> vs with explicit <code>displayName</code> — explicit preserved.</li> <li><input disabled="" type="checkbox"/> Duplicate email → 409 with message text (<code>users.service.ts:50-54</code>); duplicate phone → 409 (<code>:56-60</code>).</li> <li><input disabled="" type="checkbox"/> Email stored lowercase (<code>user.schema.ts:28-29</code>) — <code>JOHN@X.com</code> displays lowercase.</li> <li><input disabled="" type="checkbox"/> PATCH partial: only changed fields; <code>version</code> increments (<code>base.repository.ts:57-66</code>).</li> <li><input disabled="" type="checkbox"/> PATCH email to another user's email → 409 (<code>users.service.ts:120-126</code>); same email (no-op) passes (<code>:120</code> guard).</li> <li><input disabled="" type="checkbox"/> Rename first/last → <code>displayName</code> recomputed (<code>:136-139</code>).</li> <li><input disabled="" type="checkbox"/> GET list: <code>q</code> matches firstName/lastName/email/displayName case-insensitive (<code>:92-99</code>); default sort newest-first (<code>:103</code>).</li> <li><input disabled="" type="checkbox"/> Pagination envelope exact: <code>page/limit/totalItems/totalPages/hasNext/hasPrevious</code> (<code>pagination-query.dto.ts:32-55</code>); <code>limit=101</code> → 400 (Max 100 <code>:18</code>); <code>page=0</code> → 400 (Min 1 <code>:10</code>).</li> <li><input disabled="" type="checkbox"/> DELETE → row disappears from list & GET :id → 404; re-create same email allowed (no tombstone).</li> <li><input disabled="" type="checkbox"/> Deleted user <strong>cannot log in</strong> (<code>users.repository.ts:21-25</code>).</li> <li><input disabled="" type="checkbox"/> Status PATCH: all 4 enum values round-trip (<code>user.schema.ts:7-12</code>); invalid value → 400.</li> </ul> <h2 id="2-gdpr-erasure"><a class="header" href="#2-gdpr-erasure">2. GDPR erasure</a></h2> <ul> <li><input disabled="" type="checkbox"/> <code>POST /users/:id/erasure</code> → doc anonymized: <code>Erased User</code> / <code>erased-<id>@anonymized.invalid</code>, <code>isDeleted:true</code> (<code>users.service.ts:187-197</code>).</li> <li><input disabled="" type="checkbox"/> Purge job enqueued on <code>tenant-purge</code> queue with <code>attempts:3</code> + exponential backoff 5000 (<code>:200-209</code>) — verify queue contents after call.</li> <li><input disabled="" type="checkbox"/> Worker hard-deletes user doc (<code>tenant-purge.worker.ts:49-56</code>); job idempotent on replay (<code>:26</code>).</li> <li><input disabled="" type="checkbox"/> Erased user 404s everywhere; erasure twice → 404 (not crash).</li> </ul> <h2 id="3-bulk-import--users-inline-endpoint"><a class="header" href="#3-bulk-import--users-inline-endpoint">3. Bulk import — users inline endpoint</a></h2> <h3 id="c1--1000-row-scenario"><a class="header" href="#c1--1000-row-scenario">C1 — 1,000-row scenario</a></h3> <ul> <li><input disabled="" type="checkbox"/> Upload 1,000-row CSV (valid) → 201 <code>{imported:1000, errors:[]}</code>; <strong>no timeout at 120 s client budget</strong> (<a href="users/../00-shared/11_Flutter_App_Architecture.html">00-shared/11 §5</a>); measure server time — sequential per-row DB writes (<code>users.service.ts:250-279</code>) at ~1,000 round-trips; if > 2 min → flag to product (async <code>(planned)</code>, <code>PLAN.md 2.7</code>).</li> <li><input disabled="" type="checkbox"/> Memory: file ≤ ~1 MB parsed in-memory; no client OOM in preview isolate.</li> <li><input disabled="" type="checkbox"/> Progress UI honest: phases 1–2 real progress, phase 3 indeterminate (<a href="users/10_Interaction_Specification.html">10_Interaction_Specification.md §6</a>).</li> </ul> <h3 id="c2--concurrency--idempotency"><a class="header" href="#c2--concurrency--idempotency">C2 — Concurrency & idempotency</a></h3> <ul> <li><input disabled="" type="checkbox"/> Same file uploaded twice → second run: <code>imported:0</code>, all rows "already exists" (<code>users.service.ts:260-263</code>) — <strong>no duplicates created</strong> (sequential check+insert + unique index <code>user.schema.ts:83</code>).</li> <li><input disabled="" type="checkbox"/> Two parallel requests with same email → one succeeds; other: either 409-style row error (if it saw the row) or <strong>500 dup-key</strong> (race — OQ-14). Record result; server fix <code>(planned)</code> to map to 409.</li> <li><input disabled="" type="checkbox"/> Retry after network loss mid-request → no partial duplicates (each email checked before insert).</li> </ul> <h3 id="c3--csv-edge-cases"><a class="header" href="#c3--csv-edge-cases">C3 — CSV edge cases</a></h3> <ul> <li><input disabled="" type="checkbox"/> Empty file / header only → <code>{imported:0, errors:["CSV must have a header row and at least one data row"]}</code> (<code>users.service.ts:238-243</code>) — not an error envelope.</li> <li><input disabled="" type="checkbox"/> Header casing/whitespace: <code>Email, FirstName</code> → normalized (<code>:244-247</code>).</li> <li><input disabled="" type="checkbox"/> <code>first_name</code>/<code>last_name</code> aliases work (<code>:265-266</code>); missing names → <code>Unknown</code> (<code>:265-266</code>).</li> <li><input disabled="" type="checkbox"/> Missing email → <code>Row N: missing email</code> (<code>:256-259</code>); row number = physical line (client preview must agree).</li> <li><input disabled="" type="checkbox"/> In-file duplicate emails → row 2+ flagged (<code>:260-263</code>).</li> <li><input disabled="" type="checkbox"/> <strong>Quoted commas</strong> <code>"Doe, Jr."</code> → mis-split (naive parser <code>:251</code>); verify the client preview warns before upload (OQ-7); document limitation.</li> <li><input disabled="" type="checkbox"/> BOM/UTF-8: <code>file.buffer.toString('utf-8')</code> (<code>:236</code>) — BOM in first header cell; client strips BOM in preview and warns if server mis-parses.</li> <li><input disabled="" type="checkbox"/> CRLF line endings handled (<code>split('\n')</code> + trim filter <code>:237</code>).</li> <li><input disabled="" type="checkbox"/> 100+ error rows → UI paginates error list; "Download errors" CSV matches row numbers.</li> </ul> <h3 id="c4--bulk-module-students-adapter"><a class="header" href="#c4--bulk-module-students-adapter">C4 — Bulk module (students adapter)</a></h3> <ul> <li><input disabled="" type="checkbox"/> <code>POST /bulk/import/students</code>: valid file → report <code>{entity,totalRows,imported,failed,errors[{rowNumber,errors[]}]}</code> (<code>import-adapter.interface.ts:14-25</code>); rowNumber = <code>index+2</code> (<code>bulk-import.service.ts:46</code>).</li> <li><input disabled="" type="checkbox"/> Missing required column → row error (not crash) (<code>students-import.adapter.ts:41-45</code>).</li> <li><input disabled="" type="checkbox"/> Unknown grade/section/academicYear → per-row ref errors (<code>:113-125</code>); no class for grade+section → error (<code>:138-141</code>).</li> <li><input disabled="" type="checkbox"/> Duplicate admissionNumber / existing email → row errors (<code>:49-56</code>).</li> <li><input disabled="" type="checkbox"/> Malformed CSV → 400 <code>VALIDATION_ERROR</code> (<code>bulk-import.service.ts:31-33</code>).</li> <li><input disabled="" type="checkbox"/> <code>entity=users</code> → 404 "No import adapter" (<code>bulk-import.service.ts:17-20</code>) — UI must not offer it (<code>(planned)</code> users adapter).</li> <li><input disabled="" type="checkbox"/> <code>GET /bulk/export/students</code> → <code>text/csv</code> + <code>Content-Disposition: attachment</code> (<code>bulk.controller.ts:50-60</code>).</li> </ul> <h2 id="4-multi-tenant-isolation"><a class="header" href="#4-multi-tenant-isolation">4. Multi-tenant isolation</a></h2> <ul> <li><input disabled="" type="checkbox"/> Tenant A user list never shows tenant B rows (<code>base.repository.ts:20-30</code>).</li> <li><input disabled="" type="checkbox"/> Tenant A <code>GET /users/:id</code> with tenant B id → 404 (no leak, <a href="users/../00-shared/07_API_Conventions.html">00-shared/07 §3</a>).</li> <li><input disabled="" type="checkbox"/> Same email in two tenants → both created (unique is per-tenant — <code>user.schema.ts:83</code>).</li> <li><input disabled="" type="checkbox"/> Platform admin bypass scopes (<code>base.repository.ts:21-23</code>) — verify token flag only for platform surfaces.</li> </ul> <h2 id="5-avatar"><a class="header" href="#5-avatar">5. Avatar</a></h2> <ul> <li><input disabled="" type="checkbox"/> Upload → 200 <code>{avatarFileId}</code>; <code>filename</code> prefixed <code>${id}-</code> (<code>users.service.ts:219</code>); doc <code>avatarFileId</code> updated (<code>:223-225</code>).</li> <li><input disabled="" type="checkbox"/> Re-upload replaces: previous file deleted best-effort (<code>:227-229</code>).</li> <li><input disabled="" type="checkbox"/> Missing file / wrong field name → multer 400.</li> <li><input disabled="" type="checkbox"/> Oversized image: no server cap (OQ-10) — client enforces ≤ 2 MB; verify graceful client error.</li> </ul> <h2 id="6-permissions--guards"><a class="header" href="#6-permissions--guards">6. Permissions & guards</a></h2> <ul> <li><input disabled="" type="checkbox"/> Unauthenticated → 401 on all users routes (<code>JwtAuthGuard</code> — <code>users.controller.ts:31</code>).</li> <li><input disabled="" type="checkbox"/> <strong>Known gap:</strong> no RBAC permission enforcement on users routes today (OQ-11) — client hides actions via <code>PermissionScoped</code>; re-test the moment server guards land.</li> <li><input disabled="" type="checkbox"/> <code>rbac</code> routes require <code>org_admin</code> (<code>rbac.controller.ts:21-22</code>): HR-only user → 403 on members API → role chips show "—" (OQ-15).</li> <li><input disabled="" type="checkbox"/> Cross-tenant JWT → 403/404 per <code>PLAN.md 1.6</code> (<a href="users/../00-shared/07_API_Conventions.html">00-shared/07 §6</a>).</li> </ul> <h2 id="7-performance--reliability"><a class="header" href="#7-performance--reliability">7. Performance & reliability</a></h2> <ul> <li><input disabled="" type="checkbox"/> List load (network) ≤ 2 s at 10k users (<a href="users/../00-shared/10_QA_Baseline.html">00-shared/10 §1</a>); scroll 60 fps with avatars.</li> <li><input disabled="" type="checkbox"/> Indexes present: <code>(tenantId,email)</code> unique, <code>(tenantId,phone)</code> partial unique, <code>(tenantId,status)</code>, <code>(tenantId,displayName)</code> (<code>user.schema.ts:83-92</code>).</li> <li><input disabled="" type="checkbox"/> No duplicate parallel requests for same query (<a href="users/../00-shared/10_QA_Baseline.html">00-shared/10 §1</a>).</li> <li><input disabled="" type="checkbox"/> Preferences save serialized (one in-flight PATCH).</li> </ul> <h2 id="8-ux--a11y-per-00-shared09"><a class="header" href="#8-ux--a11y-per-00-shared09">8. UX & a11y (per <a href="users/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>)</a></h2> <ul> <li><input disabled="" type="checkbox"/> Status never color-only (icon+label) — <a href="users/11_Design_System_Mapping.html">11 §2</a>.</li> <li><input disabled="" type="checkbox"/> Typed confirms: <code>delete</code> and <code>ERASE</code> work; wrong text disables button; barrier tap cancels.</li> <li><input disabled="" type="checkbox"/> TalkBack/VoiceOver: list rows announce name+status; live regions for search count and import progress; keyboard-only flow (list → filters → dialogs).</li> <li><input disabled="" type="checkbox"/> Text scale 2×: no clipping in list rows, error table, dialogs.</li> <li><input disabled="" type="checkbox"/> Reduced motion: no hero/stagger in wizard transitions.</li> <li><input disabled="" type="checkbox"/> Dark mode full pass (badges, error table, dialog contrast ≥ 4.5:1 text).</li> <li><input disabled="" type="checkbox"/> Offline: cached list + banner; import blocked with guidance; write attempts show guidance (<a href="users/../00-shared/10_QA_Baseline.html">00-shared/10 §2</a>).</li> </ul> <h2 id="9-analytics-proposed-00-shared10-8"><a class="header" href="#9-analytics-proposed-00-shared10-8">9. Analytics (proposed, <a href="users/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a>)</a></h2> <ul> <li><input disabled="" type="checkbox"/> Events fire: <code>users.list.search</code>, <code>users.create.submit|success|failure(409)</code>, <code>users.import.start|complete|failure</code>, <code>users.status.change</code>, <code>users.erasure.confirm</code>, <code>users.avatar.upload</code>.</li> </ul> <h2 id="10-release-gates-2"><a class="header" href="#10-release-gates-2">10. Release gates</a></h2> <ul> <li><input disabled="" type="checkbox"/> Typecheck/lint/tests green (backend unchanged by this doc).</li> <li><input disabled="" type="checkbox"/> Backend behavioral probes above automated where possible (unit: <code>users.service.spec</code>, bulk <code>bulk-import.service.spec.ts</code> exists — extend for import edge cases).</li> <li><input disabled="" type="checkbox"/> Perf budgets, a11y checklist, offline matrix per <a href="users/../00-shared/10_QA_Baseline.html">00-shared/10</a>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-users-module"><a class="header" href="#15--flutter-implementation-guide-users-module">15 — Flutter Implementation Guide (Users Module)</a></h1> <ul> <li><a href="users/15_Flutter_Implementation_Guide.html#1-module-structure">1. Module structure</a></li> <li><a href="users/15_Flutter_Implementation_Guide.html#2-usersrepository">2. UsersRepository</a></li> <li><a href="users/15_Flutter_Implementation_Guide.html#3-csv-parse-in-isolate-preview--validation">3. CSV parse in isolate (preview + validation)</a></li> <li><a href="users/15_Flutter_Implementation_Guide.html#4-progress-ui-long-running-import">4. Progress UI (long-running import)</a></li> <li><a href="users/15_Flutter_Implementation_Guide.html#5-import-wizard-ui">5. Import wizard UI</a></li> <li><a href="users/15_Flutter_Implementation_Guide.html#6-key-screens--widgets">6. Key screens & widgets</a></li> <li><a href="users/15_Flutter_Implementation_Guide.html#7-future-hooks-plannedforward-looking">7. Future hooks (planned/forward-looking)</a></li> <li><a href="users/15_Flutter_Implementation_Guide.html#8-tests">8. Tests</a></li> </ul> <blockquote> <p>Module implementation on top of <a href="users/../00-shared/11_Flutter_App_Architecture.html">00-shared/11_Flutter_App_Architecture.md</a>. Forward-looking (no client repo). Backend constraints cited; CSV parse and progress UI get the deep treatment as required.</p> </blockquote> <hr /> <h2 id="1-module-structure-2"><a class="header" href="#1-module-structure-2">1. Module structure</a></h2> <pre><code>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) </code></pre> <p>DTO→model mapping per <code>00-shared/11 §4</code> (<code>json_serializable</code>); enums mapped from <code>UserStatus</code> strings (<code>user.schema.ts:7-12</code>), unknown → fallback <code>unknown</code> (forward-compat, <code>07 §3</code>).</p> <h2 id="2-usersrepository"><a class="header" href="#2-usersrepository">2. UsersRepository</a></h2> <ul> <li><code>list({page, limit, sort='-createdAt', q})</code> → <code>PagedResult<User></code> from envelope <code>data</code> + <code>meta</code> (<code>pagination-query.dto.ts:32-55</code>).</li> <li><code>create(dto)</code>, <code>update(id, dto)</code>, <code>get(id)</code>, <code>remove(id)</code>, <code>erase(id)</code>, <code>getPreferences(id)</code>, <code>updatePreferences(id, full)</code>, <code>uploadAvatar(id, File)</code> — multipart field <strong><code>file</code></strong> (<code>users.controller.ts:96</code>), timeout 120 s (<code>00-shared/11 §5</code>).</li> <li><code>importUsers(File, {onSendProgress})</code> → <code>ImportResult</code>.</li> <li>Preferences: repository exposes <code>updatePreferences(id, fullObject)</code> — callers always pass the <strong>complete merged object</strong> (full-replace contract, <code>users.service.ts:161-163</code>); repository asserts both groups present in dev.</li> <li>Cache (<code>00-shared/06 §3.3</code>): <code>sl:{tenant}:users:{query}</code> TTL 5 min via Hive/prefs; <code>refresh=true</code> bypasses.</li> <li>Typed errors: <code>ApiException(code, status, fieldDetails)</code> per <code>00-shared/06 §5</code>; 409 exposes <code>message</code> for inline conflict.</li> </ul> <h2 id="3-csv-parse-in-isolate-preview--validation"><a class="header" href="#3-csv-parse-in-isolate-preview--validation">3. CSV parse in isolate (preview + validation)</a></h2> <p>The server's inline parser is naive (<code>split(',')</code> — <code>users.service.ts:251</code>); the client parser is <strong>strict superset</strong> (handles quotes) so the preview is more accurate than the server — warnings surface the mismatch.</p> <pre><code class="language-dart">// 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)); } </code></pre> <ul> <li>Parse semantics mirror server: first non-empty line = header, lowercased, trimmed (<code>users.service.ts:244-247</code>); rows = non-empty lines (<code>:237</code>); required column <code>email</code> (<code>:256-259</code>); name aliases <code>firstname|first_name</code>, <code>lastname|last_name</code> (<code>:265-266</code>); defaults <code>language=en</code>, <code>timezone=UTC</code> (<code>:270-271</code>); fallback names <code>Unknown</code> (<code>:265-266</code>).</li> <li><strong>Warnings</strong> (preview must show, not just parse): <ul> <li>quoted fields present (server will mis-split — OQ-7);</li> <li>BOM in first header cell;</li> <li>in-file duplicate emails (server rejects 2nd occurrence — <code>:260-263</code>);</li> <li>rows that will import as <code>Unknown</code> name;</li> <li>header count mismatch with template.</li> </ul> </li> <li>Row numbers displayed = <strong>physical line</strong> (must match server's <code>Row N</code> — <code>users.service.ts:257</code>; precedent <code>bulk-import.service.ts:46</code>).</li> <li>Isolate pattern: <code>Isolate.run(() => ...)</code> for one-shot parse (Dart 3); progress via <code>onProgress</code> callback only supported with explicit <code>Isolate.spawn</code> + ports — choose <code>Isolate.spawn</code> when progress UI needs updates; <code>Isolate.run</code> when files are small.</li> </ul> <h2 id="4-progress-ui-long-running-import"><a class="header" href="#4-progress-ui-long-running-import">4. Progress UI (long-running import)</a></h2> <p><code>BulkImportCubit</code> machine (<code>13 §6</code>) renders:</p> <div class="table-wrapper"><table><thead><tr><th>Phase</th><th>Widget</th><th>Value source</th></tr></thead><tbody> <tr><td>ParsingLocal</td><td><code>LinearProgressIndicator(value: parsed/total)</code></td><td>isolate progress (real)</td></tr> <tr><td>Uploading</td><td><code>LinearProgressIndicator(value: sent/total)</code></td><td>dio <code>onSendProgress</code> (real)</td></tr> <tr><td>ServerProcessing</td><td>indeterminate bar + elapsed timer</td><td>none — honest "Server is importing N rows" (OQ-6)</td></tr> <tr><td>Succeeded</td><td>result summary: <code>imported</code> / <code>errors.length</code> (<code>users.service.ts:281</code>)</td><td>response</td></tr> <tr><td>Failed</td><td><code>AppErrorState</code> + Retry</td><td><code>ApiException</code></td></tr> </tbody></table> </div> <ul> <li>Upload: <code>dio.FormData</code> with <code>MultipartFile.fromFile(..., field: 'file')</code> (<code>users.controller.ts:104-110</code>); timeout 120 s (<code>00-shared/11 §5</code>).</li> <li><strong>No polling exists</strong> (synchronous endpoint) — do not build a poll loop; when async import lands <code>(planned)</code> (<code>PLAN.md 2.7</code>), swap <code>ServerProcessing</code> for poll-until-done (see §7).</li> <li>Retry: safe — server dedups emails (<code>users.service.ts:260-263</code>); copy: "Already-imported rows will not be duplicated."</li> </ul> <h2 id="5-import-wizard-ui"><a class="header" href="#5-import-wizard-ui">5. Import wizard UI</a></h2> <ul> <li><code>AppStepper</code> (<code>00-shared/03</code>): 1 Upload → 2 Preview → 3 Result.</li> <li>Step 2 <code>CsvPreviewTable</code>: header map chips, row count, first 5 rows, warning banners (<code>AppBanner</code> severity warning), template download (<code>email,firstname,lastname,phone,gender,language,timezone</code>).</li> <li>Step 3 <code>ImportErrorList</code>: mono row numbers + verbatim messages; error CSV export (<code>row,message</code>).</li> <li>File picking: <code>file_picker</code> (mime <code>text/csv</code>); desktop drag-drop via <code>desktop_drop</code> or file_picker's web support; size guard ≤ 2 MB (OQ-10).</li> </ul> <h2 id="6-key-screens--widgets"><a class="header" href="#6-key-screens--widgets">6. Key screens & widgets</a></h2> <ul> <li><strong>UsersListPage:</strong> <code>RefreshIndicator</code> + <code>ListView.builder</code> (required, <code>00-shared/11 §13</code>); <code>ScrollController</code> load-more on <code>hasNext</code>; search via <code>AppSearchBar</code> debounce 300 ms; filter chips (client-side, labeled <code>(planned)</code>); master-detail ≥ 840 dp via <code>LayoutBuilder</code>.</li> <li><strong>UserFormPage:</strong> <code>Form</code> + <code>TextFormField</code>s mirroring <code>CreateUserDto</code>/ <code>UpdateUserDto</code> (<code>08 §1-§2</code>); autofill hints; <code>AppDatePicker</code> for DOB; status <code>AppDropdown</code>; roles <code>RoleChips</code> (step 2 of create); submit sequence user→membership with <code>createdWithoutRoles</code> retry state.</li> <li><strong>UserDetailPage:</strong> header + <code>TabBar</code> (Profile/Membership/Preferences); tabs via keep-alive <code>TabBarView</code>.</li> <li><strong>SelfProfilePage:</strong> <code>:id</code> from <code>SessionCubit.sub</code> (<code>auth.service.ts:461</code>); avatar via <code>AvatarUploader</code> (crop dialog, then <code>uploadAvatar</code>).</li> <li><strong>PreferencesPage:</strong> <code>PreferencesPanel</code>; serialize saves.</li> </ul> <h2 id="7-future-hooks-plannedforward-looking"><a class="header" href="#7-future-hooks-plannedforward-looking">7. Future hooks (planned/forward-looking)</a></h2> <ul> <li>Async import: <code>ImportJobCubit</code> polling <code>GET</code> job-status <code>(planned)</code> — wire today's <code>BulkImportCubit.ServerProcessing</code> to it without UI churn.</li> <li>Invite flow: <code>status=invited</code> + accept endpoint <code>(planned)</code> — badge already renders (<code>07 §3</code>).</li> <li>Bulk users adapter: <code>POST /bulk/import/users</code> <code>(planned)</code> — swap <code>ImportRepository</code> strategy; report UI supports <code>{rowNumber, errors[]}</code> shapes already.</li> <li>Avatars via storage URL: resolve <code>avatarFileId</code> → URL <code>(planned)</code> (E9 returns id only).</li> </ul> <h2 id="8-tests-2"><a class="header" href="#8-tests-2">8. Tests</a></h2> <ul> <li>Unit: <code>csv_service</code> (quotes, BOM, aliases, dup detection, physical line numbers); <code>bulk_import_cubit</code> state machine (every transition incl. cancel/retry); <code>users_repository</code> envelope mapping (paginated meta); formatters (status labels).</li> <li>Widget: 3 states per screen (<code>00-shared/10 §9</code>); golden: <code>StatusBadge</code> ×4, <code>ImportErrorList</code>, <code>UserListTile</code>, <code>PreferencesPanel</code> (light/dark, 3 sizes).</li> <li>Integration: import journey (pick → preview → import → result) against a mocked server; error-CSV download.</li> <li>Golden fonts bundled as test assets (<code>00-shared/11 §12</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="01---product-overview-webhooks-module"><a class="header" href="#01---product-overview-webhooks-module">01 - Product Overview (Webhooks Module)</a></h1> <ul> <li><a href="webhooks/01_Product_Overview.html#1-purpose">1. Purpose</a></li> <li><a href="webhooks/01_Product_Overview.html#2-business-goals">2. Business goals</a></li> <li><a href="webhooks/01_Product_Overview.html#3-user-goals">3. User goals</a></li> <li><a href="webhooks/01_Product_Overview.html#4-scope">4. Scope</a> <ul> <li><a href="webhooks/01_Product_Overview.html#41-in-scope-implemented-backend">4.1 In scope (implemented backend)</a></li> <li><a href="webhooks/01_Product_Overview.html#42-planned-implementation_planmd48">4.2 Planned (IMPLEMENTATION_PLAN.md:48)</a></li> <li><a href="webhooks/01_Product_Overview.html#43-forward-looking-client-roadmap">4.3 Forward-looking (client roadmap)</a></li> <li><a href="webhooks/01_Product_Overview.html#44-proposed-analytics">4.4 Proposed (analytics)</a></li> </ul> </li> <li><a href="webhooks/01_Product_Overview.html#5-non-goals-this-version">5. Non-goals (this version)</a></li> </ul> <blockquote> <p>StudyLyon - multi-tenant ERP / School Management API. This package designs the <strong>Webhooks module client</strong> (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, domain events, permissions and wire contracts are derived directly from <code>src/modules/webhooks/**</code>, <code>src/infrastructure/bullmq/queue.constants.ts</code>, <code>src/infrastructure/bullmq/event-queue-map.ts</code>, <code>src/modules/rbac/permissions.constants.ts</code>, <code>docs/IMPLEMENTATION_PLAN.md</code> and <code>studylyon-blueprint/01-Product/PRODUCT_REQUIREMENTS_DOCUMENT.md</code>. No feature is invented - anything not present in source is flagged <code>(planned)</code> / <code>(proposed)</code> / <code>(forward-looking)</code>.</p> </blockquote> <p>Heads-up: per the PRD, the mobile client is <strong>out of Phase 1</strong> scope (<code>PRODUCT_REQUIREMENTS_DOCUMENT.md:144</code> - "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.</p> <hr /> <h2 id="1-purpose-28"><a class="header" href="#1-purpose-28">1. Purpose</a></h2> <p>The webhooks module lets a tenant subscribe to StudyLyon <strong>domain events</strong> and have them POSTed to an external HTTPS endpoint in near-real-time:</p> <ul> <li><strong>Subscriptions</strong> - named webhooks with a destination <code>url</code>, a free-form <code>events</code> list, a shared <code>secret</code> for HMAC signing, and an <code>enabled</code> flag (<code>webhook.schema.ts:8-30</code>).</li> <li><strong>Delivery pipeline</strong> - every emitted domain event is matched against active subscriptions and enqueued as a BullMQ job on the <code>webhook-deliver</code> queue; a dedicated worker POSTs the payload with an HMAC-SHA256 signature header and records a per-delivery log (<code>webhooks.service.ts:26-86</code>, <code>webhook-delivery.worker.ts:20-105</code>).</li> <li><strong>Observability</strong> - per-webhook delivery logs (last 50), metrics (total/success/failed/pending), manual retry of the latest failed delivery, and a test ping (<code>webhooks.controller.ts:52-76</code>).</li> </ul> <div class="table-wrapper"><table><thead><tr><th>Responsibility</th><th>Source</th></tr></thead><tbody> <tr><td>Webhook CRUD + soft delete</td><td><code>webhooks.controller.ts:21-50</code>, <code>webhooks.service.ts:31-53</code></td></tr> <tr><td>Event fan-out to subscriptions</td><td><code>webhooks.service.ts:26-28, 55-86</code>, <code>webhook.repository.ts:17-29</code></td></tr> <tr><td>Queue registration (<code>webhook-deliver</code>)</td><td><code>queue.constants.ts:14</code>, <code>webhooks.module.ts:22</code></td></tr> <tr><td>Delivery worker (fetch, HMAC, timeout, retry)</td><td><code>webhook-delivery.worker.ts:20-105</code></td></tr> <tr><td>Delivery log + metrics + retry + test</td><td><code>webhooks.service.ts:88-161</code></td></tr> <tr><td>RBAC permissions</td><td><code>permissions.constants.ts:89-92</code></td></tr> </tbody></table> </div> <h2 id="2-business-goals-21"><a class="header" href="#2-business-goals-21">2. Business goals</a></h2> <div class="table-wrapper"><table><thead><tr><th>Goal</th><th>Measure</th></tr></thead><tbody> <tr><td>External systems notified of tenant events</td><td>active webhooks matched per event type (<code>webhook.repository.ts:17-29</code>)</td></tr> <tr><td>Authenticated delivery</td><td>HMAC-SHA256 signature of the raw body (<code>webhook-delivery.worker.ts:70-82, 103-105</code>)</td></tr> <tr><td>No silent losses</td><td>BullMQ retries (3 attempts, exponential backoff 5 s) + delivery log per attempt (<code>webhooks.service.ts:80-83</code>)</td></tr> <tr><td>Tenant isolation</td><td>every query tenant-scoped via <code>BaseRepository.scopedFilter</code> (<code>base.repository.ts:20-30</code>)</td></tr> <tr><td>Operational transparency</td><td>delivery logs, metrics endpoint, manual retry (<code>webhooks.service.ts:88-161</code>)</td></tr> </tbody></table> </div> <h2 id="3-user-goals-18"><a class="header" href="#3-user-goals-18">3. User goals</a></h2> <ul> <li><strong>Tenant developer / system integrator:</strong> register a callback URL, choose which events to receive, manage the signing secret, watch delivery health, retry failures, verify signatures.</li> <li><strong>School admin:</strong> (forward-looking) enable vendor integrations (SMS, attendance hardware, analytics) that consume StudyLyon events - configured by an administrator with <code>webhook.*</code> permissions.</li> </ul> <h2 id="4-scope-3"><a class="header" href="#4-scope-3">4. Scope</a></h2> <h3 id="41-in-scope-implemented-backend-3"><a class="header" href="#41-in-scope-implemented-backend-3">4.1 In scope (implemented backend)</a></h3> <p>Webhook CRUD with <code>webhook.create/read/update/delete</code> permissions (<code>permissions.constants.ts:89-92</code>); event fan-out from the in-process <code>EventBus</code> (<code>event-bus.service.ts:11-22</code>, <code>webhooks.service.ts:26-28</code>); asynchronous delivery via the <code>webhook-deliver</code> BullMQ queue (<code>queue.constants.ts:14</code>) with 3 attempts / exponential 5 s backoff (<code>webhooks.service.ts:80-83</code>); HMAC-SHA256 signature header (<code>webhook-delivery.worker.ts:103-105</code>); per-delivery logs with status pending/success/failed (<code>webhook-delivery-log.schema.ts:18-23</code>); pause/resume, manual retry of latest failure, test ping (<code>WebhookTested</code>), and metrics (<code>webhooks.controller.ts:58-90</code>).</p> <h3 id="42-planned-implementation_planmd48"><a class="header" href="#42-planned-implementation_planmd48">4.2 Planned (IMPLEMENTATION_PLAN.md:48)</a></h3> <p>Unauthenticated <code>public</code> API scope (30 req/min, 1 min window) described as "(webhooks, health)" - an <strong>inbound</strong> webhook receiver for external providers (test-series integration, <code>IMPLEMENTATION_PLAN.md:856</code>) is planned but not implemented. No such controller exists in <code>src/</code> today - marked <code>(planned)</code> throughout this package.</p> <h3 id="43-forward-looking-client-roadmap-3"><a class="header" href="#43-forward-looking-client-roadmap-3">4.3 Forward-looking (client roadmap)</a></h3> <p>Admin console UI for webhook management, delivery-log browser with retry, and event picker; per PRD the mobile client itself is post-Phase 1 (<code>PRODUCT_REQUIREMENTS_DOCUMENT.md:144</code>). Delivery-log pagination and secret rotation UX fall here too (no endpoint today) - <code>(forward-looking)</code>.</p> <h3 id="44-proposed-analytics-3"><a class="header" href="#44-proposed-analytics-3">4.4 Proposed (analytics)</a></h3> <p>Analytics events on screens (<code>webhooks.*.*</code>) per <a href="webhooks/../00-shared/10_QA_Baseline.html">00-shared/10</a> §8 - <code>(proposed)</code>.</p> <h2 id="5-non-goals-this-version-3"><a class="header" href="#5-non-goals-this-version-3">5. Non-goals (this version)</a></h2> <ul> <li><strong>Inbound webhook receiver</strong> (receiving from external systems) - <code>(planned)</code> only, per <code>IMPLEMENTATION_PLAN.md:48</code>.</li> <li><strong>Event whitelist validation</strong> - <code>events</code> is a free-form string array (<code>create-webhook.dto.ts:20-24</code>); any eventType string is accepted.</li> <li><strong>Signature timestamp / replay protection</strong> - signature covers the body only (<code>webhook-delivery.worker.ts:103-105</code>); no <code>X-Webhook-Timestamp</code> header.</li> <li><strong>Attempt bookkeeping</strong> - <code>attemptCount</code> defaults to 0 and is never incremented (<code>webhook-delivery-log.schema.ts:31-32</code>); <code>lastTriggeredAt</code> / <code>failureCount</code> on the webhook doc are never written (<code>webhook.schema.ts:25-29</code>) - flagged in <a href="webhooks/14_QA_Checklist.html">14_QA_Checklist.md</a>.</li> <li><strong>Log pagination / filtering</strong> - logs endpoint hard-limits to 50, newest first (<code>webhooks.service.ts:88-93</code>).</li> <li><strong>Secret masking</strong> - the signing secret is returned in plaintext by CRUD responses (documents returned verbatim, <code>webhooks.service.ts:31-47</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="02---user-personas-webhooks-module"><a class="header" href="#02---user-personas-webhooks-module">02 - User Personas (Webhooks Module)</a></h1> <ul> <li><a href="webhooks/02_User_Personas.html#1-tenant-developer--nadia-primary">1. Tenant Developer — "Nadia" (primary)</a></li> <li><a href="webhooks/02_User_Personas.html#2-school-admin--it-coordinator--mr-adeyemi">2. School Admin / IT Coordinator — "Mr. Adeyemi"</a></li> <li><a href="webhooks/02_User_Personas.html#3-platform--security-reviewer--dr-okafor-forward-looking">3. Platform / Security Reviewer — "Dr. Okafor" <code>(forward-looking)</code></a></li> <li><a href="webhooks/02_User_Personas.html#4-vendor-devops--anjali-forward-looking">4. Vendor DevOps — "Anjali" <code>(forward-looking)</code></a></li> <li><a href="webhooks/02_User_Personas.html#persona---screen-mapping">Persona - screen mapping</a></li> <li><a href="webhooks/02_User_Personas.html#notes">Notes</a></li> </ul> <blockquote> <p>Personas that drive the Webhooks module surfaces. The module is a developer/integration tool: its primary users are technical. Derived from <code>src/modules/webhooks/**</code> capabilities; nothing outside source is asserted.</p> </blockquote> <hr /> <h2 id="1-tenant-developer--nadia-primary"><a class="header" href="#1-tenant-developer--nadia-primary">1. Tenant Developer — "Nadia" (primary)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Role</td><td>Software developer / system integrator at the school (or vendor integrator on behalf of the tenant)</td></tr> <tr><td>Context</td><td>Builds or maintains external systems (attendance hardware, SMS gateway, analytics, BI) that must react to StudyLyon events</td></tr> <tr><td>Tools</td><td>API docs, Swagger (<code>/api/v1/webhooks</code>), webhook signing library, delivery logs</td></tr> <tr><td>Permissions</td><td><code>webhook.create</code>, <code>webhook.read</code>, <code>webhook.update</code>, <code>webhook.delete</code> (<code>permissions.constants.ts:89-92</code>)</td></tr> <tr><td>Goals</td><td>Register an endpoint once; subscribe to exactly the events that matter; verify signatures (<code>X-Webhook-Signature</code>, HMAC-SHA256, <code>webhook-delivery.worker.ts:70-82, 103-105</code>); confirm deliveries land; retry failures</td></tr> <tr><td>Frictions today</td><td>No event-type whitelist - free-form <code>events: string[]</code> (<code>create-webhook.dto.ts:20-24</code>); secret returned in plaintext by every CRUD read (<code>webhooks.service.ts:31-47</code>); logs capped at 50, unpaginated (<code>webhooks.service.ts:88-93</code>)</td></tr> <tr><td>Success metric</td><td>Endpoint receives events reliably; signature verifies first time; delivery log shows green</td></tr> </tbody></table> </div> <h2 id="2-school-admin--it-coordinator--mr-adeyemi"><a class="header" href="#2-school-admin--it-coordinator--mr-adeyemi">2. School Admin / IT Coordinator — "Mr. Adeyemi"</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Role</td><td>Non-technical school administrator with elevated RBAC role</td></tr> <tr><td>Context</td><td>Approves and operates vendor integrations that consume StudyLyon events</td></tr> <tr><td>Permissions</td><td><code>webhook.read</code>, <code>webhook.update</code> (pause/resume)</td></tr> <tr><td>Goals</td><td>See which integrations are active; pause a webhook when a vendor behaves badly (<code>POST /webhooks/:id/pause</code>, <code>webhooks.controller.ts:78-83</code>); view delivery health without reading raw JSON</td></tr> <tr><td>Friction today</td><td>No aggregate health surface beyond per-webhook metrics (<code>webhooks.service.ts:142-156</code>); failure reasons are raw <code>responseBody</code> strings (<code>webhook-delivery-log.schema.ts:28-29</code>)</td></tr> <tr><td>Success metric</td><td>Can explain "why is this integration slow" from the metrics tile alone</td></tr> </tbody></table> </div> <h2 id="3-platform--security-reviewer--dr-okafor-forward-looking"><a class="header" href="#3-platform--security-reviewer--dr-okafor-forward-looking">3. Platform / Security Reviewer — "Dr. Okafor" <code>(forward-looking)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Role</td><td>Tenancy-wide auditor (platform admin bypasses tenant scope, <code>base.repository.ts:20-30</code>)</td></tr> <tr><td>Context</td><td>Reviews signature schemes, secret handling, replay risk</td></tr> <tr><td>Goals</td><td>Secret rotation policy; timestamped signatures</td></tr> <tr><td>Friction today</td><td>Signature has no timestamp / replay window (<code>webhook-delivery.worker.ts:103-105</code>); no rotation endpoint (PATCH only, <code>webhooks.controller.ts:39-43</code>)</td></tr> <tr><td>Status</td><td>Drives the security QA items in <a href="webhooks/14_QA_Checklist.html">14_QA_Checklist.md</a>; persona is a stretch goal, not a shipped surface</td></tr> </tbody></table> </div> <h2 id="4-vendor-devops--anjali-forward-looking"><a class="header" href="#4-vendor-devops--anjali-forward-looking">4. Vendor DevOps — "Anjali" <code>(forward-looking)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Role</td><td>External SaaS provider consuming StudyLyon events (planned inbound <code>public</code> scope, <code>IMPLEMENTATION_PLAN.md:48</code>)</td></tr> <tr><td>Context</td><td>Delivers their webhook <em>to</em> StudyLyon in the future (e.g. test-series integration, <code>IMPLEMENTATION_PLAN.md:856</code>)</td></tr> <tr><td>Status</td><td>No receiver exists in source; surfaced only to scope <code>(planned)</code> work - not a current target persona</td></tr> </tbody></table> </div> <hr /> <h2 id="persona---screen-mapping"><a class="header" href="#persona---screen-mapping">Persona - screen mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Persona</th><th>Screens (<a href="webhooks/05_Screen_Inventory.html">05</a>)</th></tr></thead><tbody> <tr><td>Tenant Developer</td><td>1 list, 2 create (event picker), 3 detail, 4 edit, 5 logs, 6 log detail, 7 test, 8 retry</td></tr> <tr><td>School Admin</td><td>1 list, 3 detail (metrics + pause/resume), 5 logs</td></tr> <tr><td>Security Reviewer</td><td>3 detail (secret), 14 QA items</td></tr> </tbody></table> </div> <h2 id="notes-1"><a class="header" href="#notes-1">Notes</a></h2> <ul> <li>The event picker must present the actual event types emitted by the platform (<code>src/infrastructure/bullmq/event-queue-map.ts:6-43</code>) - the API accepts any string, so the client is responsible for offering valid options (<code>create-webhook.dto.ts:20-24</code>).</li> <li><code>webhook.read</code> gates both the webhook itself and its logs/metrics (<code>webhooks.controller.ts:27-37, 52-56, 72-76</code>) - no separate log permission.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="03---user-journey-webhooks-module"><a class="header" href="#03---user-journey-webhooks-module">03 - User Journey (Webhooks Module)</a></h1> <ul> <li><a href="webhooks/03_User_Journey.html#journey-1--register-and-verify-a-webhook-tenant-developer">Journey 1 — Register and verify a webhook (Tenant Developer)</a></li> <li><a href="webhooks/03_User_Journey.html#journey-2--event-arrives-delivery-fails-manual-retry">Journey 2 — Event arrives, delivery fails, manual retry</a></li> <li><a href="webhooks/03_User_Journey.html#journey-3--pause-edit-delete-school-admin">Journey 3 — Pause, edit, delete (School Admin)</a></li> <li><a href="webhooks/03_User_Journey.html#journey-4--external-vendor-pushes-events-into-studylyon-planned">Journey 4 — External vendor pushes events into StudyLyon <code>(planned)</code></a></li> <li><a href="webhooks/03_User_Journey.html#journey-map-mermaid">Journey map (mermaid)</a></li> </ul> <blockquote> <p>End-to-end journeys against the implemented backend. Wire contracts quoted from <code>src/modules/webhooks/**</code>. Journeys 1-2 are implemented; journey 4 is <code>(planned)</code> (inbound receiver).</p> </blockquote> <hr /> <h2 id="journey-1--register-and-verify-a-webhook-tenant-developer"><a class="header" href="#journey-1--register-and-verify-a-webhook-tenant-developer">Journey 1 — Register and verify a webhook (Tenant Developer)</a></h2> <ol> <li>Developer signs in (web-first surface, post-Phase 1 per <code>PRODUCT_REQUIREMENTS_DOCUMENT.md:144</code>).</li> <li>Opens Webhooks list - <code>GET /api/v1/webhooks</code> (sort newest first, <code>webhooks.service.ts:35-37</code>) - sees existing subscriptions and their enabled/paused state.</li> <li>Taps "New webhook" - <code>POST /api/v1/webhooks</code> (<code>webhooks.controller.ts:21-25</code>) with <code>{ name, url, events[1..n], secret, enabled? }</code> (<code>create-webhook.dto.ts:11-34</code>). URL must be valid (<code>@IsUrl({ require_tld: false })</code>, <code>create-webhook.dto.ts:17-18</code>); events array must have at least one entry (<code>ArrayMinSize(1)</code>, <code>create-webhook.dto.ts:22-23</code>).</li> <li>Fires the test ping - <code>POST /api/v1/webhooks/:id/test</code> (<code>webhooks.controller.ts:65-70</code>) - the worker POSTs <code>{ test: true, webhookId }</code> as event <code>WebhookTested</code> (<code>webhooks.service.ts:130-138</code>).</li> <li>Receiver gets <code>X-Webhook-Signature: <hmac-sha256 hex></code> + <code>X-Webhook-Event: WebhookTested</code> (<code>webhook-delivery.worker.ts:70-82</code>); developer verifies signature with the stored secret.</li> <li>Watches <code>GET /api/v1/webhooks/:id/logs</code> (50 latest, <code>webhooks.service.ts:88-93</code>) and <code>GET /api/v1/webhooks/:id/metrics</code> (<code>webhooks.service.ts:142-156</code>) - <code>total/success/failed/pending</code> counts.</li> <li>Done when the log row shows <code>status: success</code> (<code>recordDelivery</code>, <code>webhooks.service.ts:179-193</code>).</li> </ol> <h2 id="journey-2--event-arrives-delivery-fails-manual-retry"><a class="header" href="#journey-2--event-arrives-delivery-fails-manual-retry">Journey 2 — Event arrives, delivery fails, manual retry</a></h2> <ol> <li>A domain event is emitted on the <code>EventBus</code> (<code>event-bus.service.ts:11-14</code>).</li> <li><code>WebhooksService</code> intercepts via <code>onAny</code> (<code>webhooks.service.ts:26-28</code>) and queries active subscriptions for that event type - tenantId + enabled + not deleted + <code>events: eventType</code> (<code>webhook.repository.ts:17-29</code>).</li> <li>One job per matching webhook is enqueued: <code>deliver</code> on queue <code>webhook-deliver</code> (<code>queue.constants.ts:14</code>) with <code>attempts: 3</code> and exponential backoff starting at 5 s (<code>webhooks.service.ts:69-84</code>).</li> <li>Worker creates a <code>pending</code> delivery log (<code>webhooks.service.ts:163-177</code>), POSTs the payload (<code>webhook-delivery.worker.ts:73-82</code>), and records <code>success</code> on 2xx else <code>failed</code> + rethrows (<code>webhook-delivery.worker.ts:84-100</code>); BullMQ retries.</li> <li>All attempts exhausted - developer sees <code>failed</code> in logs and red in metrics.</li> <li>Developer taps "Retry" - <code>POST /api/v1/webhooks/:id/retry</code> (<code>webhooks.controller.ts:58-63</code>) - the latest failed delivery is re-enqueued with its original payload (<code>webhooks.service.ts:95-120</code>).</li> <li>Retry succeeds - log row flips to <code>success</code> (new row; the failed row stays - one log row per worker attempt, <code>webhooks.service.ts:62-67, 84-89</code>).</li> </ol> <h2 id="journey-3--pause-edit-delete-school-admin"><a class="header" href="#journey-3--pause-edit-delete-school-admin">Journey 3 — Pause, edit, delete (School Admin)</a></h2> <ol> <li>Admin pauses a misbehaving integration - <code>POST /api/v1/webhooks/:id/pause</code> (<code>webhooks.controller.ts:78-83</code>) - fan-out now skips it (<code>webhook.repository.ts:23-28</code>).</li> <li>Later resumes - <code>POST /api/v1/webhooks/:id/resume</code> (<code>webhooks.controller.ts:85-90</code>).</li> <li>Developer edits URL/events/secret - <code>PATCH /api/v1/webhooks/:id</code> with any subset of <code>UpdateWebhookDto</code> (PartialType, <code>update-webhook.dto.ts:4</code>).</li> <li>Decommission - <code>DELETE /api/v1/webhooks/:id</code> - soft delete (<code>webhooks.service.ts:50-53</code>, <code>base.repository.ts:68-74</code>); list no longer shows it.</li> </ol> <h2 id="journey-4--external-vendor-pushes-events-into-studylyon-planned"><a class="header" href="#journey-4--external-vendor-pushes-events-into-studylyon-planned">Journey 4 — External vendor pushes events into StudyLyon <code>(planned)</code></a></h2> <ol> <li>Vendor signs up for the planned unauthenticated <code>public</code> scope (<code>IMPLEMENTATION_PLAN.md:48</code> - "(webhooks, health)", 30 req/min, 1 min window).</li> <li>Vendor POSTs events to the public receiver - <strong>no such endpoint exists in source</strong>; blocked by <code>IMPLEMENTATION_PLAN.md:856</code> (test-series integration via webhooks). Marked <code>(planned)</code>; excluded from all API tables in <a href="webhooks/12_API_Mapping.html">12</a>.</li> <li>Journey ends here until the receiver is implemented.</li> </ol> <hr /> <h2 id="journey-map-mermaid-2"><a class="header" href="#journey-map-mermaid-2">Journey map (mermaid)</a></h2> <pre class="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 </pre> <div style="break-before: page; page-break-before: always;"></div><h1 id="04---information-architecture-webhooks-module"><a class="header" href="#04---information-architecture-webhooks-module">04 - Information Architecture (Webhooks Module)</a></h1> <ul> <li><a href="webhooks/04_Information_Architecture.html#1-ia-map">1. IA map</a></li> <li><a href="webhooks/04_Information_Architecture.html#2-information-entities">2. Information entities</a> <ul> <li><a href="webhooks/04_Information_Architecture.html#21-webhook-webhooks-collection-webhookschemats8-30">2.1 Webhook (<code>webhooks</code> collection, <code>webhook.schema.ts:8-30</code>)</a></li> <li><a href="webhooks/04_Information_Architecture.html#22-webhookdeliverylog-webhook_delivery_logs-collection-webhook-delivery-logschemats7-39">2.2 WebhookDeliveryLog (<code>webhook_delivery_logs</code> collection, <code>webhook-delivery-log.schema.ts:7-39</code>)</a></li> <li><a href="webhooks/04_Information_Architecture.html#23-domain-events-the-events-picker-vocabulary">2.3 Domain events (the <code>events</code> picker vocabulary)</a></li> </ul> </li> <li><a href="webhooks/04_Information_Architecture.html#3-cross-references">3. Cross-references</a></li> <li><a href="webhooks/04_Information_Architecture.html#4-navigation-rules">4. Navigation rules</a></li> </ul> <blockquote> <p>Screens, routes, data entities and cross-references. The module owns two documents: <code>webhooks</code> (subscriptions) and <code>webhook_delivery_logs</code> (attempts). Read alongside <a href="webhooks/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a> (global IA).</p> </blockquote> <hr /> <h2 id="1-ia-map-1"><a class="header" href="#1-ia-map-1">1. IA map</a></h2> <pre><code>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 </code></pre> <p>All routes are client-side; the API is flat at <code>/api/v1/webhooks*</code> (<code>webhooks.controller.ts:16-17</code>).</p> <h2 id="2-information-entities"><a class="header" href="#2-information-entities">2. Information entities</a></h2> <h3 id="21-webhook-webhooks-collection-webhookschemats8-30"><a class="header" href="#21-webhook-webhooks-collection-webhookschemats8-30">2.1 Webhook (<code>webhooks</code> collection, <code>webhook.schema.ts:8-30</code>)</a></h3> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type</th><th>Source</th></tr></thead><tbody> <tr><td><code>name</code></td><td>string, required</td><td><code>webhook.schema.ts:10-11</code></td></tr> <tr><td><code>url</code></td><td>string, required (HTTPS expected)</td><td><code>webhook.schema.ts:13-14</code></td></tr> <tr><td><code>events</code></td><td><code>string[]</code>, default <code>[]</code></td><td><code>webhook.schema.ts:16-17</code></td></tr> <tr><td><code>secret</code></td><td>string, required</td><td><code>webhook.schema.ts:19-20</code></td></tr> <tr><td><code>enabled</code></td><td>boolean, default <code>true</code></td><td><code>webhook.schema.ts:22-23</code></td></tr> <tr><td><code>lastTriggeredAt</code></td><td>Date (unused - never written)</td><td><code>webhook.schema.ts:25-26</code></td></tr> <tr><td><code>failureCount</code></td><td>number, default 0 (unused - never written)</td><td><code>webhook.schema.ts:28-29</code></td></tr> <tr><td><code>tenantId</code>, <code>isDeleted</code>, <code>version</code>, <code>createdAt</code>, <code>updatedAt</code></td><td>inherited</td><td><code>base.schema.ts:10-34</code></td></tr> </tbody></table> </div> <h3 id="22-webhookdeliverylog-webhook_delivery_logs-collection-webhook-delivery-logschemats7-39"><a class="header" href="#22-webhookdeliverylog-webhook_delivery_logs-collection-webhook-delivery-logschemats7-39">2.2 WebhookDeliveryLog (<code>webhook_delivery_logs</code> collection, <code>webhook-delivery-log.schema.ts:7-39</code>)</a></h3> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Type</th><th>Source</th></tr></thead><tbody> <tr><td><code>webhookId</code></td><td>ObjectId ref <code>Webhook</code></td><td><code>webhook-delivery-log.schema.ts:9-10</code></td></tr> <tr><td><code>eventType</code></td><td>string, required</td><td><code>:12-13</code></td></tr> <tr><td><code>payload</code></td><td>object (raw event payload)</td><td><code>:15-16</code></td></tr> <tr><td><code>status</code></td><td>enum <code>pending / success / failed</code>, default <code>pending</code></td><td><code>:18-23</code></td></tr> <tr><td><code>responseCode</code></td><td>number</td><td><code>:25-26</code></td></tr> <tr><td><code>responseBody</code></td><td>string</td><td><code>:28-29</code></td></tr> <tr><td><code>attemptCount</code></td><td>number, default 0 (never incremented)</td><td><code>:31-32</code></td></tr> <tr><td><code>attemptedAt</code></td><td>Date, default now</td><td><code>:34-35</code></td></tr> <tr><td><code>completedAt</code></td><td>Date</td><td><code>:37-38</code></td></tr> </tbody></table> </div> <h3 id="23-domain-events-the-events-picker-vocabulary"><a class="header" href="#23-domain-events-the-events-picker-vocabulary">2.3 Domain events (the <code>events</code> picker vocabulary)</a></h3> <p>Event types are free-form strings on the wire (<code>create-webhook.dto.ts:20-24</code>); the <strong>client-side picker vocabulary</strong> is the set of emitted events. Representative registry: <code>event-queue-map.ts:6-43</code> - <code>UserRegistered</code>, <code>UserLoggedIn</code>, <code>PasswordResetRequested</code>, <code>UserCreated/Updated/Deleted</code>, <code>OrganizationCreated</code>, <code>AttendanceMarked/Updated</code>, <code>HomeworkCreated/Updated/Submitted/Graded/Deleted</code>, <code>ExamResultsPublished</code>, <code>StudentCreated/Updated/Deleted</code>, <code>TeacherCreated/Updated/Deleted</code>, <code>StaffCreated/Updated/Deleted</code>, <code>ParentCreated/Updated/Deleted</code>, <code>FeeStructureCreated</code>, <code>InvoiceIssued</code>, <code>PaymentCompleted</code>. Plus the synthetic test event <code>WebhookTested</code> (<code>webhooks.service.ts:134</code>).</p> <p>Event envelope (what the receiver gets): <code>{ eventType, tenantId, actorId, occurredAt, correlationId, payload }</code> (<code>domain-event.interface.ts:1-8</code>).</p> <h2 id="3-cross-references"><a class="header" href="#3-cross-references">3. Cross-references</a></h2> <div class="table-wrapper"><table><thead><tr><th>Entity</th><th>Referenced by</th><th>Ref</th></tr></thead><tbody> <tr><td>Webhook</td><td><code>WebhookDeliveryLog.webhookId</code></td><td><code>webhook-delivery-log.schema.ts:9-10</code></td></tr> <tr><td>EventBus (any event)</td><td><code>WebhooksService.onAny</code></td><td><code>webhooks.service.ts:26-28</code></td></tr> <tr><td><code>webhook-deliver</code> queue</td><td><code>WebhooksModule</code> + worker</td><td><code>queue.constants.ts:14</code>, <code>webhooks.module.ts:22</code>, <code>webhook-delivery.worker.ts:9</code></td></tr> <tr><td>Event names</td><td><code>eventQueueMap</code></td><td><code>event-queue-map.ts:6-43</code></td></tr> </tbody></table> </div> <h2 id="4-navigation-rules-2"><a class="header" href="#4-navigation-rules-2">4. Navigation rules</a></h2> <ul> <li>S3 is the hub: metrics tile, recent logs preview, actions (edit, test, pause/resume).</li> <li>S5 is reachable from S3 and from any "failed" badge deep link.</li> <li>Pause/resume and test/retry never require leaving the current screen (dialogs).</li> <li>Permission gating: <code>webhook.create</code> (S2), <code>webhook.update</code> (S4, S7, S8, S9), <code>webhook.read</code> (S1, S3, S5, S6) - <code>permissions.constants.ts:89-92</code>.</li> <li><code>(planned)</code> future: inbound public receiver under <code>public</code> scope (<code>IMPLEMENTATION_PLAN.md:48</code>); not in this IA.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05---screen-inventory-webhooks-module"><a class="header" href="#05---screen-inventory-webhooks-module">05 - Screen Inventory (Webhooks Module)</a></h1> <ul> <li><a href="webhooks/05_Screen_Inventory.html#legend">Legend</a></li> <li><a href="webhooks/05_Screen_Inventory.html#1-webhooks-list-webhooks">1. Webhooks List (<code>/webhooks</code>)</a></li> <li><a href="webhooks/05_Screen_Inventory.html#2-create-webhook-webhooksnew">2. Create Webhook (<code>/webhooks/new</code>)</a></li> <li><a href="webhooks/05_Screen_Inventory.html#3-webhook-detail-webhooksid">3. Webhook Detail (<code>/webhooks/:id</code>)</a></li> <li><a href="webhooks/05_Screen_Inventory.html#4-edit-webhook-webhooksidedit">4. Edit Webhook (<code>/webhooks/:id/edit</code>)</a></li> <li><a href="webhooks/05_Screen_Inventory.html#5-delivery-logs-webhooksidlogs">5. Delivery Logs (<code>/webhooks/:id/logs</code>)</a></li> <li><a href="webhooks/05_Screen_Inventory.html#6-delivery-log-detail-bottom-sheet-from-s5">6. Delivery Log Detail (bottom sheet from S5)</a></li> <li><a href="webhooks/05_Screen_Inventory.html#7-test-delivery-dialog-from-s3">7. Test Delivery (dialog, from S3)</a></li> <li><a href="webhooks/05_Screen_Inventory.html#8-retry-delivery-dialog-from-s3s5s6">8. Retry Delivery (dialog, from S3/S5/S6)</a></li> <li><a href="webhooks/05_Screen_Inventory.html#9-pause--resume-inline-confirm-from-s1s3">9. Pause / Resume (inline confirm from S1/S3)</a></li> <li><a href="webhooks/05_Screen_Inventory.html#shared-components-used">Shared components used</a></li> <li><a href="webhooks/05_Screen_Inventory.html#analytics-events-proposed">Analytics events (proposed)</a></li> <li><a href="webhooks/05_Screen_Inventory.html#keyboard-landscape-tablet-desktop">Keyboard, landscape, tablet, desktop</a></li> </ul> <blockquote> <p>Every screen of the Webhooks module, its intent, route, composition, states, permissions, platform behavior and events. Authoritative components in <a href="webhooks/../00-shared/03_Component_Library.html">00-shared/03</a>; this file enumerates which ones each screen uses with module specifics.</p> </blockquote> <hr /> <h2 id="legend-20"><a class="header" href="#legend-20">Legend</a></h2> <p>States = idle / loading / success / empty / error(offline, rate, invalid) / disabled / permission. Analytics events follow <code>{module}.{screen}.{action}</code> (proposed; SDK open - <a href="webhooks/../00-shared/10_QA_Baseline.html">00-shared/10</a> §8).</p> <hr /> <h2 id="1-webhooks-list-webhooks"><a class="header" href="#1-webhooks-list-webhooks">1. Webhooks List (<code>/webhooks</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>All tenant webhooks, newest first</td></tr> <tr><td>Source</td><td><code>GET /api/v1/webhooks</code> (no pagination, sort <code>createdAt: -1</code>, <code>webhooks.service.ts:35-37</code>)</td></tr> <tr><td>Widgets</td><td><code>AppListTile</code> per webhook: name, url (monospace, clipped), events chips (up to 3 + "+n"), trailing <code>AppBadge</code> (active success / paused neutral), <code>AppMenu</code> (Edit / Pause-Resume / Delete)</td></tr> <tr><td>CTA / FAB</td><td><code>AppFAB</code> "New webhook" (<code>webhook.create</code>)</td></tr> <tr><td>States</td><td>loading (skeleton tiles), empty ("No webhooks yet"), error API, permission (<code>webhook.read</code>)</td></tr> <tr><td>Row actions</td><td>tap → detail (S3); delete → <code>AppDialog</code> confirm → <code>DELETE /webhooks/:id</code> (<code>webhooks.controller.ts:45-50</code>), optimistic remove, rollback on 404/500</td></tr> <tr><td>Analytics</td><td><code>webhooks.list.open</code>, <code>webhooks.list.create</code>, <code>webhooks.list.open_detail</code> (proposed)</td></tr> </tbody></table> </div> <h2 id="2-create-webhook-webhooksnew"><a class="header" href="#2-create-webhook-webhooksnew">2. Create Webhook (<code>/webhooks/new</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Register subscription: name, url, events, secret, enabled</td></tr> <tr><td>Source</td><td><code>POST /api/v1/webhooks</code> (<code>webhooks.controller.ts:21-25</code>); DTO <code>create-webhook.dto.ts:11-34</code></td></tr> <tr><td>Inputs</td><td><code>AppTextField</code> name; url (<code>.url</code> keyboard); event picker (chips multi-select from <code>event-queue-map.ts:6-43</code>); secret (obscure + "generate" helper, min-length hint); enabled switch (default on, <code>create-webhook.dto.ts:30-33</code>)</td></tr> <tr><td>Primary CTA</td><td>"Create webhook"</td></tr> <tr><td>Validation</td><td>name/url/secret required; url <code>@IsUrl({ require_tld: false })</code> (<code>create-webhook.dto.ts:17-18</code>); ≥1 event (<code>ArrayMinSize(1)</code>, <code>:22-23</code>)</td></tr> <tr><td>States</td><td>loading, field errors, server error</td></tr> <tr><td>Analytics</td><td><code>webhooks.create.submit</code>, <code>webhooks.create.success</code>, <code>webhooks.create.failure</code> (proposed)</td></tr> <tr><td>Note</td><td>Form is the largest surface - full spec in <a href="webhooks/08_Form_Specifications.html">08_Form_Specifications.md</a></td></tr> </tbody></table> </div> <h2 id="3-webhook-detail-webhooksid"><a class="header" href="#3-webhook-detail-webhooksid">3. Webhook Detail (<code>/webhooks/:id</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Single subscription: config + health in one place</td></tr> <tr><td>Source</td><td><code>GET /api/v1/webhooks/:id</code> (<code>webhooks.controller.ts:33-37</code>); metrics <code>GET /webhooks/:id/metrics</code> (<code>:72-76</code>, <code>webhooks.service.ts:142-156</code>)</td></tr> <tr><td>Layout</td><td>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</td></tr> <tr><td>Actions</td><td>Edit (S4), Test (S7), Retry (S8, only when failed > 0), Pause/Resume (S9)</td></tr> <tr><td>States</td><td>loading skeleton, 404 → <code>AppErrorState</code> + back, error</td></tr> <tr><td>Analytics</td><td><code>webhooks.detail.open</code>, <code>webhooks.detail.tab.{config,logs}</code> (proposed)</td></tr> </tbody></table> </div> <h2 id="4-edit-webhook-webhooksidedit"><a class="header" href="#4-edit-webhook-webhooksidedit">4. Edit Webhook (<code>/webhooks/:id/edit</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Modify url/events/secret/enabled - any subset</td></tr> <tr><td>Source</td><td><code>PATCH /api/v1/webhooks/:id</code> (<code>webhooks.controller.ts:39-43</code>); <code>UpdateWebhookDto</code> = PartialType (all optional, <code>update-webhook.dto.ts:4</code>)</td></tr> <tr><td>Inputs</td><td>same as S2, pre-filled from detail doc; secret shown masked, blank = keep unchanged</td></tr> <tr><td>Primary CTA</td><td>"Save changes"</td></tr> <tr><td>Note</td><td>Secret rotation is a PATCH of <code>secret</code> today - a dedicated rotation flow is <code>(forward-looking)</code></td></tr> </tbody></table> </div> <h2 id="5-delivery-logs-webhooksidlogs"><a class="header" href="#5-delivery-logs-webhooksidlogs">5. Delivery Logs (<code>/webhooks/:id/logs</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Per-attempt delivery history, newest first</td></tr> <tr><td>Source</td><td><code>GET /api/v1/webhooks/:id/logs</code> - hard cap 50, sort <code>createdAt: -1</code> (<code>webhooks.service.ts:88-93</code>)</td></tr> <tr><td>Widgets</td><td><code>AppListTile</code> per log: eventType, status badge (pending amber / success green / failed red), responseCode, attemptedAt; tap → S6</td></tr> <tr><td>Empty</td><td>"No deliveries yet - trigger an event or use Test"</td></tr> <tr><td>Pull-to-refresh</td><td><code>RefreshIndicator</code> re-fetches</td></tr> <tr><td>States</td><td>loading, error, empty</td></tr> <tr><td>Analytics</td><td><code>webhooks.logs.open</code>, <code>webhooks.logs.refresh</code> (proposed)</td></tr> <tr><td>Note</td><td>One row per worker attempt (retries create new rows, <code>webhooks.service.ts:62-67, 84-89</code>); pagination <code>(forward-looking)</code></td></tr> </tbody></table> </div> <h2 id="6-delivery-log-detail-bottom-sheet-from-s5"><a class="header" href="#6-delivery-log-detail-bottom-sheet-from-s5">6. Delivery Log Detail (bottom sheet from S5)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Full attempt payload + response</td></tr> <tr><td>Source</td><td>same doc as S5 (payload, responseCode, responseBody, attemptedAt, completedAt - <code>webhook-delivery-log.schema.ts:12-38</code>)</td></tr> <tr><td>Content</td><td>eventType, status, timing, <code>responseCode</code> + <code>responseBody</code> (collapsed, monospace), full <code>payload</code> JSON (copyable)</td></tr> <tr><td>Actions</td><td>"Retry this webhook" (S8) if failed</td></tr> <tr><td>a11y</td><td>payload/code blocks exposed as selectable text, not image</td></tr> </tbody></table> </div> <h2 id="7-test-delivery-dialog-from-s3"><a class="header" href="#7-test-delivery-dialog-from-s3">7. Test Delivery (dialog, from S3)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Ping the endpoint with a synthetic event</td></tr> <tr><td>Source</td><td><code>POST /api/v1/webhooks/:id/test</code> (<code>webhooks.controller.ts:65-70</code>) → queue job with <code>eventType: 'WebhookTested'</code>, <code>payload: { test: true, webhookId }</code> (<code>webhooks.service.ts:130-138</code>)</td></tr> <tr><td>Flow</td><td>confirm dialog → success snackbar "Test delivery queued" (server returns <code>{ message: 'Test delivery queued' }</code>, <code>:69</code>) → user watches S5</td></tr> <tr><td>Note</td><td>Async: dialog must not claim delivery, only enqueue (queue <code>webhook-deliver</code>, <code>queue.constants.ts:14</code>)</td></tr> </tbody></table> </div> <h2 id="8-retry-delivery-dialog-from-s3s5s6"><a class="header" href="#8-retry-delivery-dialog-from-s3s5s6">8. Retry Delivery (dialog, from S3/S5/S6)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Re-enqueue the latest failed delivery</td></tr> <tr><td>Source</td><td><code>POST /api/v1/webhooks/:id/retry</code> (<code>webhooks.controller.ts:58-63</code>) → 404 <code>'No failed deliveries to retry'</code> if none (<code>webhooks.service.ts:103-108</code>); job reuses the failed log's payload (<code>:110-118</code>)</td></tr> <tr><td>Precondition</td><td>latest log for this webhook has <code>status: 'failed'</code> (<code>webhooks.service.ts:103-106</code>)</td></tr> <tr><td>Flow</td><td>dialog "Retry latest failed delivery?" → snackbar "Retry queued" → S5 refresh</td></tr> <tr><td>Note</td><td><code>correlationId</code> is sent as <code>''</code> on retries (<code>webhooks.service.ts:117</code>)</td></tr> </tbody></table> </div> <h2 id="9-pause--resume-inline-confirm-from-s1s3"><a class="header" href="#9-pause--resume-inline-confirm-from-s1s3">9. Pause / Resume (inline confirm from S1/S3)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Flip <code>enabled</code> without editing the record</td></tr> <tr><td>Source</td><td><code>POST /webhooks/:id/pause</code> → <code>setEnabled(false)</code>; <code>POST /webhooks/:id/resume</code> → <code>setEnabled(true)</code> (<code>webhooks.controller.ts:78-90</code>, <code>webhooks.service.ts:158-161</code>)</td></tr> <tr><td>Behavior</td><td>paused webhooks are skipped by fan-out (<code>webhook.repository.ts:23-28</code>); toggle back via resume</td></tr> <tr><td>Note</td><td>No pause vs disabled distinction in the model - single <code>enabled</code> boolean (<code>webhook.schema.ts:22-23</code>)</td></tr> </tbody></table> </div> <hr /> <h2 id="shared-components-used-25"><a class="header" href="#shared-components-used-25">Shared components used</a></h2> <p><code>AppTextField</code>, <code>AppButton</code>, <code>AppSnackbar</code>, <code>AppCard</code>, <code>AppListTile</code>, <code>AppBottomSheet</code>, <code>AppDialog</code>, <code>AppMenu</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppErrorState</code>, <code>AppOfflineBanner</code>, <code>AppFAB</code>, <code>AppChips</code>, <code>AppBadge</code>, <code>AppSwitch</code>, <code>AppStatTile</code> (metrics). Module-specific: <code>EventPickerChipField</code>, <code>SecretField</code>, <code>DeliveryLogTile</code>, <code>SignatureVerifyCard</code> (developer docs) - defined in <a href="webhooks/07_Component_Library.html">07_Component_Library.md</a>.</p> <h2 id="analytics-events-proposed-22"><a class="header" href="#analytics-events-proposed-22">Analytics events (proposed)</a></h2> <p><code>webhooks.list.{open,create,open_detail}</code>, <code>webhooks.create.{submit,success,failure}</code>, <code>webhooks.detail.{open,pause,resume}</code>, <code>webhooks.edit.{submit,success}</code>, <code>webhooks.logs.{open,refresh,retry,test}</code> (all proposed).</p> <h2 id="keyboard-landscape-tablet-desktop-20"><a class="header" href="#keyboard-landscape-tablet-desktop-20">Keyboard, landscape, tablet, desktop</a></h2> <ul> <li>Forms portrait-first with <code>resizeToAvoidBottomInset</code>; tablet/desktop: list + detail master-detail (S1 → S3), forms centered ≤ 480 dp.</li> <li>Code blocks (url, payload, responseBody) full-width monospace with horizontal scroll; long payloads collapse by default (S6).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="06---screen-specifications-webhooks-module"><a class="header" href="#06---screen-specifications-webhooks-module">06 - Screen Specifications (Webhooks Module)</a></h1> <ul> <li><a href="webhooks/06_Screen_Specifications.html#0-shared-states-every-screen">0. Shared states (every screen)</a></li> <li><a href="webhooks/06_Screen_Specifications.html#1-webhooks-list-webhooks">1. Webhooks List (<code>/webhooks</code>)</a> <ul> <li><a href="webhooks/06_Screen_Specifications.html#layout">Layout</a></li> <li><a href="webhooks/06_Screen_Specifications.html#wire-contract">Wire contract</a></li> <li><a href="webhooks/06_Screen_Specifications.html#states">States</a></li> <li><a href="webhooks/06_Screen_Specifications.html#interactions">Interactions</a></li> <li><a href="webhooks/06_Screen_Specifications.html#a11y--motion">a11y / motion</a></li> <li><a href="webhooks/06_Screen_Specifications.html#analytics-proposed">Analytics (proposed)</a></li> </ul> </li> <li><a href="webhooks/06_Screen_Specifications.html#2-create-webhook-webhooksnew">2. Create Webhook (<code>/webhooks/new</code>)</a> <ul> <li><a href="webhooks/06_Screen_Specifications.html#layout-1">Layout</a></li> <li><a href="webhooks/06_Screen_Specifications.html#wire-contract-1">Wire contract</a></li> <li><a href="webhooks/06_Screen_Specifications.html#states-1">States</a></li> <li><a href="webhooks/06_Screen_Specifications.html#interactions-1">Interactions</a></li> <li><a href="webhooks/06_Screen_Specifications.html#a11y--motion-1">a11y / motion</a></li> <li><a href="webhooks/06_Screen_Specifications.html#analytics-proposed-1">Analytics (proposed)</a></li> </ul> </li> <li><a href="webhooks/06_Screen_Specifications.html#3-webhook-detail-webhooksid">3. Webhook Detail (<code>/webhooks/:id</code>)</a> <ul> <li><a href="webhooks/06_Screen_Specifications.html#layout-2">Layout</a></li> <li><a href="webhooks/06_Screen_Specifications.html#wire-contract-2">Wire contract</a></li> <li><a href="webhooks/06_Screen_Specifications.html#states-2">States</a></li> <li><a href="webhooks/06_Screen_Specifications.html#interactions-2">Interactions</a></li> <li><a href="webhooks/06_Screen_Specifications.html#a11y--motion-2">a11y / motion</a></li> <li><a href="webhooks/06_Screen_Specifications.html#analytics-proposed-2">Analytics (proposed)</a></li> </ul> </li> <li><a href="webhooks/06_Screen_Specifications.html#4-edit-webhook-webhooksidedit">4. Edit Webhook (<code>/webhooks/:id/edit</code>)</a> <ul> <li><a href="webhooks/06_Screen_Specifications.html#layout-3">Layout</a></li> <li><a href="webhooks/06_Screen_Specifications.html#wire-contract-3">Wire contract</a></li> <li><a href="webhooks/06_Screen_Specifications.html#states-3">States</a></li> <li><a href="webhooks/06_Screen_Specifications.html#interactions-3">Interactions</a></li> <li><a href="webhooks/06_Screen_Specifications.html#analytics-proposed-3">Analytics (proposed)</a></li> </ul> </li> <li><a href="webhooks/06_Screen_Specifications.html#5-delivery-logs-webhooksidlogs">5. Delivery Logs (<code>/webhooks/:id/logs</code>)</a> <ul> <li><a href="webhooks/06_Screen_Specifications.html#layout-4">Layout</a></li> <li><a href="webhooks/06_Screen_Specifications.html#wire-contract-4">Wire contract</a></li> <li><a href="webhooks/06_Screen_Specifications.html#states-4">States</a></li> <li><a href="webhooks/06_Screen_Specifications.html#interactions-4">Interactions</a></li> <li><a href="webhooks/06_Screen_Specifications.html#a11y--motion-3">a11y / motion</a></li> <li><a href="webhooks/06_Screen_Specifications.html#analytics-proposed-4">Analytics (proposed)</a></li> </ul> </li> <li><a href="webhooks/06_Screen_Specifications.html#6-delivery-log-detail-bottom-sheet">6. Delivery Log Detail (bottom sheet)</a> <ul> <li><a href="webhooks/06_Screen_Specifications.html#layout-5">Layout</a></li> <li><a href="webhooks/06_Screen_Specifications.html#wire-contract-5">Wire contract</a></li> <li><a href="webhooks/06_Screen_Specifications.html#states-5">States</a></li> <li><a href="webhooks/06_Screen_Specifications.html#analytics-proposed-5">Analytics (proposed)</a></li> </ul> </li> <li><a href="webhooks/06_Screen_Specifications.html#7-test-delivery-dialog">7. Test Delivery (dialog)</a> <ul> <li><a href="webhooks/06_Screen_Specifications.html#layout-6">Layout</a></li> <li><a href="webhooks/06_Screen_Specifications.html#wire-contract-6">Wire contract</a></li> <li><a href="webhooks/06_Screen_Specifications.html#states--interactions">States / interactions</a></li> <li><a href="webhooks/06_Screen_Specifications.html#analytics-proposed-6">Analytics (proposed)</a></li> </ul> </li> <li><a href="webhooks/06_Screen_Specifications.html#8-retry-delivery-dialog">8. Retry Delivery (dialog)</a> <ul> <li><a href="webhooks/06_Screen_Specifications.html#layout-7">Layout</a></li> <li><a href="webhooks/06_Screen_Specifications.html#wire-contract-7">Wire contract</a></li> <li><a href="webhooks/06_Screen_Specifications.html#states--interactions-1">States / interactions</a></li> <li><a href="webhooks/06_Screen_Specifications.html#analytics-proposed-7">Analytics (proposed)</a></li> </ul> </li> <li><a href="webhooks/06_Screen_Specifications.html#9-pause--resume-inline-confirm">9. Pause / Resume (inline confirm)</a> <ul> <li><a href="webhooks/06_Screen_Specifications.html#layout-8">Layout</a></li> <li><a href="webhooks/06_Screen_Specifications.html#wire-contract-8">Wire contract</a></li> <li><a href="webhooks/06_Screen_Specifications.html#states--interactions-2">States / interactions</a></li> <li><a href="webhooks/06_Screen_Specifications.html#analytics-proposed-8">Analytics (proposed)</a></li> </ul> </li> <li><a href="webhooks/06_Screen_Specifications.html#10-inbound-webhook-receiver-planned">10. Inbound Webhook Receiver <code>(planned)</code></a></li> </ul> <blockquote> <p>Detailed specifications per screen: layout, wire contract, states, interactions, errors, permissions, a11y and analytics. Read alongside <a href="webhooks/05_Screen_Inventory.html">05_Screen_Inventory.md</a> and <a href="webhooks/../00-shared/03_Component_Library.html">00-shared/03</a> (components), <a href="webhooks/../00-shared/06_State_Management.html">00-shared/06</a> (state), <a href="webhooks/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a> (a11y).</p> </blockquote> <hr /> <h2 id="0-shared-states-every-screen-1"><a class="header" href="#0-shared-states-every-screen-1">0. Shared states (every screen)</a></h2> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Rendering</th><th>Source of truth</th></tr></thead><tbody> <tr><td>idle/loading</td><td><code>AppSkeleton</code> per block; list rows as skeleton tiles</td><td><a href="webhooks/../00-shared/06_State_Management.html">00-shared/06</a> §3.1</td></tr> <tr><td>error offline</td><td><code>AppOfflineBanner</code> + native retry; data shown stale if cached</td><td><a href="webhooks/../00-shared/10_QA_Baseline.html">00-shared/10</a> §2</td></tr> <tr><td>error API</td><td><code>AppErrorState(code, message, onRetry)</code>; 401 → re-auth; 403 → permission copy</td><td><a href="webhooks/../00-shared/03_Component_Library.html">00-shared/03</a></td></tr> <tr><td>empty</td><td><code>AppEmptyState</code> with CTA</td><td><a href="webhooks/../00-shared/03_Component_Library.html">00-shared/03</a></td></tr> <tr><td>404</td><td>detail screens → <code>AppErrorState</code> + back</td><td><code>webhooks.service.ts:39-43</code></td></tr> <tr><td>403</td><td>hidden actions; if invoked anyway, permission copy</td><td><code>permissions.constants.ts:89-92</code></td></tr> </tbody></table> </div> <p>Error envelope: <code>{ statusCode, message, timestamp, path }</code> per <a href="webhooks/../00-shared/07_API_Conventions.html">00-shared/07</a> §3 (<code>HttpExceptionFilter</code>).</p> <hr /> <h2 id="1-webhooks-list-webhooks-1"><a class="header" href="#1-webhooks-list-webhooks-1">1. Webhooks List (<code>/webhooks</code>)</a></h2> <h3 id="layout-37"><a class="header" href="#layout-37">Layout</a></h3> <ul> <li>AppBar: "Webhooks" + FAB "New webhook" (<code>.create</code>).</li> <li><code>AppListTile</code> rows: leading globe icon; title <code>name</code>; subtitle <code>url</code> (monospace, ellipsized); chips row: event types (max 3 + "+n"); trailing <code>AppBadge</code> (<code>enabled</code> → success "Active", else neutral "Paused") + <code>AppMenu</code> (Edit / Pause or Resume / Delete).</li> <li>Pull-to-refresh; no pagination UI (API returns full list).</li> </ul> <h3 id="wire-contract-11"><a class="header" href="#wire-contract-11">Wire contract</a></h3> <ul> <li><code>GET /api/v1/webhooks</code> (<code>webhooks.controller.ts:27-31</code>); sort <code>createdAt: -1</code> (<code>webhooks.service.ts:35-37</code>).</li> <li>Item shape: <code>{ _id, name, url, events[], secret, enabled, lastTriggeredAt?, failureCount, tenantId, isDeleted, version, createdAt, updatedAt }</code> (<code>webhook.schema.ts:8-30</code>, <code>base.schema.ts:10-34</code>). <strong><code>secret</code> is present in plaintext</strong> - client renders it only in detail; list rows never display it.</li> </ul> <h3 id="states-22"><a class="header" href="#states-22">States</a></h3> <ul> <li>loading: 6 skeleton tiles. empty: "No webhooks yet" + "New webhook" CTA.</li> <li>error per §0.</li> </ul> <h3 id="interactions-10"><a class="header" href="#interactions-10">Interactions</a></h3> <ul> <li>Tap row → S3. Menu Edit → S4; Pause/Resume → S9 confirm; Delete → <code>AppDialog</code> confirm → <code>DELETE /api/v1/webhooks/:id</code> (<code>webhooks.controller.ts:45-50</code>) → optimistic remove, rollback on failure; server replies <code>{ message: 'Webhook deleted' }</code> (<code>:49</code>).</li> <li>FAB → S2.</li> </ul> <h3 id="a11y--motion-4"><a class="header" href="#a11y--motion-4">a11y / motion</a></h3> <ul> <li>Badge read as part of tile semantics: "Active" / "Paused". Stagger fade <code>m-base</code> (<a href="webhooks/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>).</li> </ul> <h3 id="analytics-proposed-12"><a class="header" href="#analytics-proposed-12">Analytics (proposed)</a></h3> <p><code>webhooks.list.open</code>, <code>webhooks.list.create</code>, <code>webhooks.list.open_detail</code>.</p> <hr /> <h2 id="2-create-webhook-webhooksnew-1"><a class="header" href="#2-create-webhook-webhooksnew-1">2. Create Webhook (<code>/webhooks/new</code>)</a></h2> <h3 id="layout-38"><a class="header" href="#layout-38">Layout</a></h3> <ul> <li>AppBar: "New webhook", back.</li> <li><code>AppCard</code> form (see <a href="webhooks/08_Form_Specifications.html">08_Form_Specifications.md</a> §1): <ol> <li>Name (<code>AppTextField</code>, required)</li> <li>URL (<code>AppTextField</code>, <code>.url</code> keyboard, required)</li> <li>Events (<code>EventPickerChipField</code> - multi-select chips, required ≥ 1)</li> <li>Secret (<code>SecretField</code> - obscure + generate, required)</li> <li>Enabled (<code>AppSwitch</code>, default on)</li> </ol> </li> <li>Primary CTA "Create webhook" (full width), disabled while submitting.</li> </ul> <h3 id="wire-contract-12"><a class="header" href="#wire-contract-12">Wire contract</a></h3> <ul> <li><code>POST /api/v1/webhooks</code> (<code>webhooks.controller.ts:21-25</code>); body = <code>CreateWebhookDto</code> (<code>create-webhook.dto.ts:11-34</code>): <ul> <li><code>name: string</code> (<code>@IsString</code>, <code>:13-14</code>)</li> <li><code>url: string</code> (<code>@IsUrl({ require_tld: false })</code>, <code>:17-18</code>)</li> <li><code>events: string[]</code> (<code>@IsArray</code> + <code>@ArrayMinSize(1)</code> + <code>@IsString({each:true})</code>, <code>:20-24</code>)</li> <li><code>secret: string</code> (<code>@IsString</code>, <code>:27-28</code>)</li> <li><code>enabled?: boolean</code> (<code>@IsOptional</code> + <code>@IsBoolean</code>, default true server-side via schema <code>webhook.schema.ts:22-23</code>, <code>:30-33</code>)</li> </ul> </li> <li>Response: created document (envelope <code>{ data }</code>, <a href="webhooks/../00-shared/07_API_Conventions.html">00-shared/07</a> §2).</li> </ul> <h3 id="states-23"><a class="header" href="#states-23">States</a></h3> <ul> <li>submitting → CTA spinner, fields locked.</li> <li>success → pop to S3 (new id).</li> <li>error 400: field-level messages from class-validator (missing/invalid fields).</li> <li>error 401/500: <code>AppErrorState</code> in place of form? No - inline snackbar + form stays intact.</li> </ul> <h3 id="interactions-11"><a class="header" href="#interactions-11">Interactions</a></h3> <ul> <li>Event chips: tap toggles; "Select all" / "Clear" helpers; picker vocabulary from <code>event-queue-map.ts:6-43</code> (see §0 note in <a href="webhooks/08_Form_Specifications.html">08</a>).</li> <li>Secret: "Generate" produces a strong random secret client-side (server has no generator); "Reveal" toggle.</li> <li>Enabled switch defaults on (<code>create-webhook.dto.ts:30-33</code>).</li> </ul> <h3 id="a11y--motion-5"><a class="header" href="#a11y--motion-5">a11y / motion</a></h3> <ul> <li>Labels linked; first invalid field focused on submit (<a href="webhooks/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>); chips have <code>semanticsLabel</code> "Event selected: X".</li> </ul> <h3 id="analytics-proposed-13"><a class="header" href="#analytics-proposed-13">Analytics (proposed)</a></h3> <p><code>webhooks.create.submit</code>, <code>webhooks.create.success</code>, <code>webhooks.create.failure</code>.</p> <hr /> <h2 id="3-webhook-detail-webhooksid-1"><a class="header" href="#3-webhook-detail-webhooksid-1">3. Webhook Detail (<code>/webhooks/:id</code>)</a></h2> <h3 id="layout-39"><a class="header" href="#layout-39">Layout</a></h3> <ul> <li>AppBar: webhook <code>name</code>; menu → Edit / Delete.</li> <li>Header <code>AppCard</code>: status badge (Active/Paused), <code>url</code> (monospace, tappable → copy), events chips, <code>SecretField</code> masked with reveal (server returns plaintext secret; client keeps it in memory only, <code>webhooks.service.ts:39-43</code>).</li> <li>Metrics tile: 4 <code>AppStatTile</code> - Total / Success / Failed / Pending (<code>webhooks.service.ts:142-156</code>).</li> <li>"Recent deliveries" preview: last 5 log rows (<code>DeliveryLogTile</code>), tap → S5.</li> <li>Action row: "Test" (S7), "Retry" (S8, enabled only when <code>failed > 0</code>), "Pause"/"Resume" (S9).</li> </ul> <h3 id="wire-contract-13"><a class="header" href="#wire-contract-13">Wire contract</a></h3> <ul> <li><code>GET /api/v1/webhooks/:id</code> (<code>webhooks.controller.ts:33-37</code>); 404 → <code>NotFound</code> (<code>webhooks.service.ts:39-43</code>).</li> <li><code>GET /api/v1/webhooks/:id/metrics</code> (<code>webhooks.controller.ts:72-76</code>) → <code>{ total, success, failed, pending }</code> via 4 count queries (<code>webhooks.service.ts:149-154</code>).</li> </ul> <h3 id="states-24"><a class="header" href="#states-24">States</a></h3> <ul> <li>loading: skeleton card + tiles. 404: <code>AppErrorState</code> "Webhook not found" + back.</li> <li>metrics tile: independent per-tile load - one failure keeps the rest visible.</li> </ul> <h3 id="interactions-12"><a class="header" href="#interactions-12">Interactions</a></h3> <ul> <li>Retry enabled iff <code>failed > 0</code>; Test always available (<code>.update</code>).</li> <li>Pause/Resume swap based on <code>enabled</code>.</li> <li>Copy url/secret buttons (<code>Clipboard.setData</code>).</li> </ul> <h3 id="a11y--motion-6"><a class="header" href="#a11y--motion-6">a11y / motion</a></h3> <ul> <li>StatTile semantics "Total deliveries: 12" etc.; masked secret announced as "Secret, hidden".</li> </ul> <h3 id="analytics-proposed-14"><a class="header" href="#analytics-proposed-14">Analytics (proposed)</a></h3> <p><code>webhooks.detail.open</code>, <code>webhooks.detail.pause</code>, <code>webhooks.detail.resume</code>.</p> <hr /> <h2 id="4-edit-webhook-webhooksidedit-1"><a class="header" href="#4-edit-webhook-webhooksidedit-1">4. Edit Webhook (<code>/webhooks/:id/edit</code>)</a></h2> <h3 id="layout-40"><a class="header" href="#layout-40">Layout</a></h3> <ul> <li>Same form as S2, pre-filled: url, events, enabled; <code>name</code> optional; <code>secret</code> shown masked with placeholder "Leave blank to keep current secret".</li> <li>CTA "Save changes".</li> </ul> <h3 id="wire-contract-14"><a class="header" href="#wire-contract-14">Wire contract</a></h3> <ul> <li><code>PATCH /api/v1/webhooks/:id</code> (<code>webhooks.controller.ts:39-43</code>); body = <code>UpdateWebhookDto</code> = <code>PartialType(CreateWebhookDto)</code> - every field optional (<code>update-webhook.dto.ts:4</code>). Empty PATCH body is accepted (no-op).</li> <li>Response: updated document or null (<code>webhooks.service.ts:45-48</code>).</li> </ul> <h3 id="states-25"><a class="header" href="#states-25">States</a></h3> <ul> <li>as S2; plus 404 handling (webhook deleted meanwhile) → error + back.</li> </ul> <h3 id="interactions-13"><a class="header" href="#interactions-13">Interactions</a></h3> <ul> <li>Only dirty fields are sent (diff on submit).</li> <li>Secret rotation UX: reveal old → type new → save; dedicated rotation flow with re-verification is <code>(forward-looking)</code>.</li> </ul> <h3 id="analytics-proposed-15"><a class="header" href="#analytics-proposed-15">Analytics (proposed)</a></h3> <p><code>webhooks.edit.submit</code>, <code>webhooks.edit.success</code>, <code>webhooks.edit.rotate_secret</code>.</p> <hr /> <h2 id="5-delivery-logs-webhooksidlogs-1"><a class="header" href="#5-delivery-logs-webhooksidlogs-1">5. Delivery Logs (<code>/webhooks/:id/logs</code>)</a></h2> <h3 id="layout-41"><a class="header" href="#layout-41">Layout</a></h3> <ul> <li>AppBar: "Delivery logs"; subtitle = webhook name.</li> <li>List of <code>DeliveryLogTile</code>: eventType (monospace), status badge (pending / success / failed), <code>responseCode</code> when present, <code>attemptedAt</code> relative time; trailing chevron → S6.</li> <li>Pull-to-refresh; empty state "No deliveries yet - trigger an event or use Test".</li> </ul> <h3 id="wire-contract-15"><a class="header" href="#wire-contract-15">Wire contract</a></h3> <ul> <li><code>GET /api/v1/webhooks/:id/logs</code> (<code>webhooks.controller.ts:52-56</code>); sort <code>createdAt: -1</code>, hard <code>limit: 50</code> (<code>webhooks.service.ts:88-93</code>). No pagination params today.</li> <li>Log shape: <code>{ _id, webhookId, eventType, payload, status, responseCode?, responseBody?, attemptCount, attemptedAt, completedAt?, createdAt, updatedAt }</code> (<code>webhook-delivery-log.schema.ts:7-39</code>).</li> </ul> <h3 id="states-26"><a class="header" href="#states-26">States</a></h3> <ul> <li>loading, empty, error per §0; one row per worker attempt - retries appear as new rows (<code>webhooks.service.ts:62-67, 84-89</code>).</li> </ul> <h3 id="interactions-14"><a class="header" href="#interactions-14">Interactions</a></h3> <ul> <li>Tap row → S6 sheet. Pull-to-refresh re-fetches.</li> <li>Failed rows: "Retry" quick action (S8) without opening the sheet.</li> </ul> <h3 id="a11y--motion-7"><a class="header" href="#a11y--motion-7">a11y / motion</a></h3> <ul> <li>Status communicated by badge + text (never color alone, <a href="webhooks/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>).</li> </ul> <h3 id="analytics-proposed-16"><a class="header" href="#analytics-proposed-16">Analytics (proposed)</a></h3> <p><code>webhooks.logs.open</code>, <code>webhooks.logs.refresh</code>, <code>webhooks.logs.open_detail</code>.</p> <hr /> <h2 id="6-delivery-log-detail-bottom-sheet"><a class="header" href="#6-delivery-log-detail-bottom-sheet">6. Delivery Log Detail (bottom sheet)</a></h2> <h3 id="layout-42"><a class="header" href="#layout-42">Layout</a></h3> <ul> <li>Header: eventType + status badge + timing (attemptedAt → completedAt).</li> <li>Sections: Request (payload JSON, monospace, collapsed by default, copy button); Response (<code>responseCode</code> + <code>responseBody</code>, monospace, collapsed; <code>undefined</code> → "no response recorded"); attempt metadata (attemptCount - always 0 today, <code>webhook-delivery-log.schema.ts:31-32</code>, see <a href="webhooks/14_QA_Checklist.html">14</a>).</li> <li>Footer action: "Retry webhook" (S8) when failed.</li> </ul> <h3 id="wire-contract-16"><a class="header" href="#wire-contract-16">Wire contract</a></h3> <ul> <li>No extra endpoint - data already loaded by S5 list call.</li> </ul> <h3 id="states-27"><a class="header" href="#states-27">States</a></h3> <ul> <li>payload/response rendering errors (huge/non-JSON body) → truncated view + copy raw.</li> </ul> <h3 id="analytics-proposed-17"><a class="header" href="#analytics-proposed-17">Analytics (proposed)</a></h3> <p><code>webhooks.logs.detail_open</code>.</p> <hr /> <h2 id="7-test-delivery-dialog"><a class="header" href="#7-test-delivery-dialog">7. Test Delivery (dialog)</a></h2> <h3 id="layout-43"><a class="header" href="#layout-43">Layout</a></h3> <ul> <li><code>AppDialog</code>: title "Test webhook"; body "Send a test event to <url>?"; primary "Send test", secondary "Cancel".</li> </ul> <h3 id="wire-contract-17"><a class="header" href="#wire-contract-17">Wire contract</a></h3> <ul> <li><code>POST /api/v1/webhooks/:id/test</code> (<code>webhooks.controller.ts:65-70</code>) → server replies <code>{ message: 'Test delivery queued' }</code> immediately (<code>:69</code>); delivery is <strong>async</strong> via queue: job <code>eventType: 'WebhookTested'</code>, <code>payload: { test: true, webhookId }</code> (<code>webhooks.service.ts:130-138</code>).</li> <li>Delivery attempt appears in logs within seconds (<code>webhook-delivery.worker.ts:62-89</code>).</li> </ul> <h3 id="states--interactions-3"><a class="header" href="#states--interactions-3">States / interactions</a></h3> <ul> <li>submitting → spinner on primary; success → close + snackbar "Test delivery queued".</li> <li>After close, navigate to S5 to watch the attempt.</li> </ul> <h3 id="analytics-proposed-18"><a class="header" href="#analytics-proposed-18">Analytics (proposed)</a></h3> <p><code>webhooks.logs.test</code>.</p> <hr /> <h2 id="8-retry-delivery-dialog"><a class="header" href="#8-retry-delivery-dialog">8. Retry Delivery (dialog)</a></h2> <h3 id="layout-44"><a class="header" href="#layout-44">Layout</a></h3> <ul> <li><code>AppDialog</code>: "Retry latest failed delivery?"; body shows the failed eventType + time; primary "Retry", secondary "Cancel".</li> </ul> <h3 id="wire-contract-18"><a class="header" href="#wire-contract-18">Wire contract</a></h3> <ul> <li><code>POST /api/v1/webhooks/:id/retry</code> (<code>webhooks.controller.ts:58-63</code>).</li> <li>Server finds latest <code>failed</code> log (<code>webhooks.service.ts:103-106</code>); if none → <code>NotFoundException('No failed deliveries to retry')</code> (<code>:107-108</code>) - dialog shows "Nothing to retry" state.</li> <li>Job re-queued with the failed attempt's eventType + payload (<code>webhooks.service.ts:110-118</code>); <code>correlationId: ''</code> (<code>:117</code>).</li> <li>Reply: <code>{ message: 'Retry queued' }</code> (<code>webhooks.controller.ts:62</code>).</li> </ul> <h3 id="states--interactions-4"><a class="header" href="#states--interactions-4">States / interactions</a></h3> <ul> <li>success → close + snackbar "Retry queued" → S5 refresh.</li> <li>404 → inline note "No failed deliveries to retry" + close.</li> </ul> <h3 id="analytics-proposed-19"><a class="header" href="#analytics-proposed-19">Analytics (proposed)</a></h3> <p><code>webhooks.logs.retry</code>.</p> <hr /> <h2 id="9-pause--resume-inline-confirm"><a class="header" href="#9-pause--resume-inline-confirm">9. Pause / Resume (inline confirm)</a></h2> <h3 id="layout-45"><a class="header" href="#layout-45">Layout</a></h3> <ul> <li><code>AppDialog</code> or inline <code>AppMenu</code> confirm: "Pause this webhook?" / "Resume?".</li> </ul> <h3 id="wire-contract-19"><a class="header" href="#wire-contract-19">Wire contract</a></h3> <ul> <li><code>POST /api/v1/webhooks/:id/pause</code> → <code>setEnabled(false)</code> (<code>webhooks.controller.ts:78-83</code>, <code>webhooks.service.ts:158-161</code>).</li> <li><code>POST /api/v1/webhooks/:id/resume</code> → <code>setEnabled(true)</code> (<code>webhooks.controller.ts:85-90</code>).</li> <li>Replies: <code>{ message: 'Webhook paused' }</code> / <code>{ message: 'Webhook resumed' }</code>.</li> <li>Effect on fan-out: <code>findActiveByEvent</code> filters <code>enabled: true</code> (<code>webhook.repository.ts:23-28</code>) - paused webhooks stop receiving immediately.</li> </ul> <h3 id="states--interactions-5"><a class="header" href="#states--interactions-5">States / interactions</a></h3> <ul> <li>optimistic badge flip + snackbar; rollback on 404/500.</li> </ul> <h3 id="analytics-proposed-20"><a class="header" href="#analytics-proposed-20">Analytics (proposed)</a></h3> <p><code>webhooks.detail.pause</code>, <code>webhooks.detail.resume</code>.</p> <hr /> <h2 id="10-inbound-webhook-receiver-planned"><a class="header" href="#10-inbound-webhook-receiver-planned">10. Inbound Webhook Receiver <code>(planned)</code></a></h2> <ul> <li>No screen and no endpoint in source. Planned as unauthenticated <code>public</code> scope ("(webhooks, health)", 30 req/min, 1 min window, <code>IMPLEMENTATION_PLAN.md:48</code>).</li> <li>When implemented (e.g. test-series integration, <code>IMPLEMENTATION_PLAN.md:856</code>), this screen would show tenant-scoped receipts of inbound vendor events.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="07---component-library-webhooks-module"><a class="header" href="#07---component-library-webhooks-module">07 - Component Library (Webhooks Module)</a></h1> <ul> <li><a href="webhooks/07_Component_Library.html#1-eventpickerchipfield">1. <code>EventPickerChipField</code></a></li> <li><a href="webhooks/07_Component_Library.html#2-secretfield">2. <code>SecretField</code></a></li> <li><a href="webhooks/07_Component_Library.html#3-deliverylogtile">3. <code>DeliveryLogTile</code></a></li> <li><a href="webhooks/07_Component_Library.html#4-metricstile-4-stat-grid">4. <code>MetricsTile</code> (4-stat grid)</a></li> <li><a href="webhooks/07_Component_Library.html#5-signatureverifycard-developer-helper-forward-looking">5. <code>SignatureVerifyCard</code> (developer helper) <code>(forward-looking)</code></a></li> <li><a href="webhooks/07_Component_Library.html#composition-map-screen--module-widgets">Composition map (screen → module widgets)</a></li> </ul> <blockquote> <p>Module-specific widgets on top of <a href="webhooks/../00-shared/03_Component_Library.html">00-shared/03</a> (authoritative component library). Shared components (<code>AppTextField</code>, <code>AppButton</code>, <code>AppDialog</code>, <code>AppSnackbar</code>, <code>AppCard</code>, <code>AppListTile</code>, <code>AppMenu</code>, <code>AppChips</code>, <code>AppBadge</code>, <code>AppFAB</code>, <code>AppSwitch</code>, <code>AppStatTile</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>, <code>AppErrorState</code>, <code>AppBottomSheet</code>) are reused as-is; this file adds only what the module needs that shared does not define.</p> </blockquote> <hr /> <h2 id="1-eventpickerchipfield"><a class="header" href="#1-eventpickerchipfield">1. <code>EventPickerChipField</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Purpose</td><td>Multi-select of domain event types (the webhook's <code>events</code> array)</td></tr> <tr><td>Data</td><td>vocabulary from <code>event-queue-map.ts:6-43</code> + <code>WebhookTested</code> (<code>webhooks.service.ts:134</code>); stored value <code>string[]</code> (<code>webhook.schema.ts:16-17</code>)</td></tr> <tr><td>Props</td><td><code>selected: Set<String></code>, <code>onChanged</code>, <code>allowCustom: bool = false</code>, <code>maxVisible: int = 12</code></td></tr> <tr><td>Behaviour</td><td><code>AppChips</code> filterable grid; free-text "custom event" input chip when <code>allowCustom</code> (API accepts any string - <code>create-webhook.dto.ts:20-24</code>); grouped by source module (users/attendance/homework/results/students/teachers/staff/parents/fees/payments, <code>event-queue-map.ts:6-43</code>)</td></tr> <tr><td>Validation</td><td>≥ 1 selected (mirror <code>@ArrayMinSize(1)</code>, <code>create-webhook.dto.ts:22-23</code>)</td></tr> <tr><td>a11y</td><td>each chip <code>semanticsLabel</code> "Event X, selected/unselected"; group headers as <code>Semantics</code> headers</td></tr> </tbody></table> </div> <h2 id="2-secretfield"><a class="header" href="#2-secretfield">2. <code>SecretField</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Purpose</td><td>Signing-secret input with generate/reveal (create + edit + detail)</td></tr> <tr><td>Props</td><td><code>value</code>, <code>onChanged</code>, <code>obscured: bool = true</code>, <code>onGenerate</code>, <code>canReveal</code></td></tr> <tr><td>Behaviour</td><td>obscure toggle (eye icon); "Generate" fills a strong random secret (client-side only - no server generator); copy button on detail read-only variant</td></tr> <tr><td>Security note</td><td>secret is stored plaintext and returned by API (<code>webhooks.service.ts:31-47</code>); field never logs; auto-clear clipboard after 30 s <code>(proposed)</code></td></tr> <tr><td>Validation</td><td>required on create (<code>@IsString</code>, <code>create-webhook.dto.ts:27-28</code>); blank = keep on edit</td></tr> </tbody></table> </div> <h2 id="3-deliverylogtile"><a class="header" href="#3-deliverylogtile">3. <code>DeliveryLogTile</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Purpose</td><td>One delivery attempt row (S5 list, S3 preview)</td></tr> <tr><td>Data</td><td>log doc: <code>eventType</code>, <code>status</code>, <code>responseCode?</code>, <code>attemptedAt</code>, <code>completedAt?</code> (<code>webhook-delivery-log.schema.ts:12-38</code>)</td></tr> <tr><td>Layout</td><td>leading status icon (pending clock / success check / failed alert), title <code>eventType</code>, subtitle <code>responseCode · attemptedAt</code>, trailing chevron</td></tr> <tr><td>States</td><td>badge color per status: pending amber, success green, failed red - always with text (<a href="webhooks/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>)</td></tr> <tr><td>Interactions</td><td>tap → detail sheet; optional quick "Retry" on failed rows</td></tr> <tr><td>Note</td><td><code>attemptCount</code> exists on the schema (<code>:31-32</code>) but is always 0 today - tile hides it until the worker increments it (<a href="webhooks/14_QA_Checklist.html">14_QA_Checklist.md</a> G-2)</td></tr> </tbody></table> </div> <h2 id="4-metricstile-4-stat-grid"><a class="header" href="#4-metricstile-4-stat-grid">4. <code>MetricsTile</code> (4-stat grid)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Purpose</td><td>Health summary on S3</td></tr> <tr><td>Data</td><td><code>{ total, success, failed, pending }</code> from <code>GET /webhooks/:id/metrics</code> (<code>webhooks.service.ts:142-156</code>)</td></tr> <tr><td>Layout</td><td>2×2 <code>AppStatTile</code> grid; failed tile highlighted when > 0 with "Retry" affordance</td></tr> <tr><td>States</td><td>per-tile load/error - one failure never blanks the grid</td></tr> </tbody></table> </div> <h2 id="5-signatureverifycard-developer-helper-forward-looking"><a class="header" href="#5-signatureverifycard-developer-helper-forward-looking">5. <code>SignatureVerifyCard</code> (developer helper) <code>(forward-looking)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Value</th></tr></thead><tbody> <tr><td>Purpose</td><td>Client-side verification of a captured signature against a body+secret</td></tr> <tr><td>Algorithm</td><td><code>HMAC-SHA256</code> hex digest of raw request body (<code>webhook-delivery.worker.ts:103-105</code>) - mirrors the worker's <code>sign()</code></td></tr> <tr><td>Use</td><td>paste body + signature + secret → "Valid ✓ / Invalid ✗"</td></tr> <tr><td>Note</td><td>documentation aid; not required by the API - receiver side is external. Signature has no timestamp/replay window today (QA G-4)</td></tr> </tbody></table> </div> <hr /> <h2 id="composition-map-screen--module-widgets"><a class="header" href="#composition-map-screen--module-widgets">Composition map (screen → module widgets)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Screen (<a href="webhooks/05_Screen_Inventory.html">05</a>)</th><th>Module widgets</th></tr></thead><tbody> <tr><td>S2/S4</td><td><code>EventPickerChipField</code>, <code>SecretField</code></td></tr> <tr><td>S3</td><td><code>SecretField</code> (read-only), <code>MetricsTile</code></td></tr> <tr><td>S5</td><td><code>DeliveryLogTile</code></td></tr> <tr><td>S6</td><td>payload/response code blocks (monospace selectable text)</td></tr> <tr><td>S3/S5/S6</td><td><code>SignatureVerifyCard</code> (dev mode)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="08---form-specifications-webhooks-module"><a class="header" href="#08---form-specifications-webhooks-module">08 - Form Specifications (Webhooks Module)</a></h1> <ul> <li><a href="webhooks/08_Form_Specifications.html#1-webhook-form-s2-create--s4-edit">1. Webhook form (S2 create / S4 edit)</a></li> <li><a href="webhooks/08_Form_Specifications.html#2-wire-payloads">2. Wire payloads</a></li> <li><a href="webhooks/08_Form_Specifications.html#3-event-picker-vocabulary-client-side">3. Event picker vocabulary (client-side)</a></li> <li><a href="webhooks/08_Form_Specifications.html#4-client-side-validation-order">4. Client-side validation order</a></li> <li><a href="webhooks/08_Form_Specifications.html#5-submit-behaviour">5. Submit behaviour</a></li> <li><a href="webhooks/08_Form_Specifications.html#6-secret-handling">6. Secret handling</a></li> <li><a href="webhooks/08_Form_Specifications.html#7-pause--resume-non-form-action">7. Pause / Resume (non-form action)</a></li> </ul> <blockquote> <p>The webhook form (create + edit share one model). Exact DTO contracts quoted from <code>create-webhook.dto.ts</code> / <code>update-webhook.dto.ts</code>; validation mirrors class-validator rules so client and server agree.</p> </blockquote> <hr /> <h2 id="1-webhook-form-s2-create--s4-edit"><a class="header" href="#1-webhook-form-s2-create--s4-edit">1. Webhook form (S2 create / S4 edit)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Control</th><th>Required</th><th>Rules (server = source)</th><th>Server source</th></tr></thead><tbody> <tr><td><code>name</code></td><td><code>AppTextField</code></td><td>yes (create) / optional (edit)</td><td>non-empty string</td><td><code>@IsString</code>, <code>create-webhook.dto.ts:13-14</code></td></tr> <tr><td><code>url</code></td><td><code>AppTextField</code> (<code>.url</code> keyboard)</td><td>yes / optional</td><td>valid URL; <code>require_tld: false</code> (local/private hosts allowed)</td><td><code>@IsUrl({ require_tld: false })</code>, <code>:17-18</code></td></tr> <tr><td><code>events</code></td><td><code>EventPickerChipField</code></td><td>yes (≥ 1)</td><td>array of strings, min size 1</td><td><code>@IsArray</code> + <code>@ArrayMinSize(1)</code> + <code>@IsString({ each: true })</code>, <code>:20-24</code></td></tr> <tr><td><code>secret</code></td><td><code>SecretField</code></td><td>yes (create) / blank = keep (edit)</td><td>non-empty string</td><td><code>@IsString</code>, <code>:27-28</code></td></tr> <tr><td><code>enabled</code></td><td><code>AppSwitch</code></td><td>no</td><td>boolean, default <code>true</code></td><td><code>@IsOptional</code> + <code>@IsBoolean</code>, <code>:30-33</code>; schema default <code>webhook.schema.ts:22-23</code></td></tr> </tbody></table> </div> <p>Edit mode: <code>UpdateWebhookDto = PartialType(CreateWebhookDto)</code> - every field optional (<code>update-webhook.dto.ts:4</code>); client sends only dirty fields.</p> <h2 id="2-wire-payloads"><a class="header" href="#2-wire-payloads">2. Wire payloads</a></h2> <pre><code class="language-json">// 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 } </code></pre> <h2 id="3-event-picker-vocabulary-client-side"><a class="header" href="#3-event-picker-vocabulary-client-side">3. Event picker vocabulary (client-side)</a></h2> <p>The API accepts any string, so the picker offers the emitted event registry (<code>event-queue-map.ts:6-43</code>) grouped by module, plus <code>WebhookTested</code> (<code>webhooks.service.ts:134</code>):</p> <div class="table-wrapper"><table><thead><tr><th>Group</th><th>Events (<code>event-queue-map.ts</code>)</th></tr></thead><tbody> <tr><td>Auth / users</td><td><code>UserRegistered</code>, <code>UserLoggedIn</code>, <code>PasswordResetRequested</code>, <code>UserCreated</code>, <code>UserUpdated</code>, <code>UserDeleted</code> (<code>:7-12</code>)</td></tr> <tr><td>Organization</td><td><code>OrganizationCreated</code> (<code>:13</code>)</td></tr> <tr><td>Attendance</td><td><code>AttendanceMarked</code>, <code>AttendanceUpdated</code> (<code>:14-21</code>)</td></tr> <tr><td>Homework</td><td><code>HomeworkCreated</code>, <code>HomeworkUpdated</code>, <code>HomeworkSubmitted</code>, <code>HomeworkGraded</code>, <code>HomeworkDeleted</code> (<code>:22-26</code>)</td></tr> <tr><td>Results</td><td><code>ExamResultsPublished</code> (<code>:27</code>)</td></tr> <tr><td>People</td><td><code>StudentCreated/Updated/Deleted</code>, <code>TeacherCreated/Updated/Deleted</code>, <code>StaffCreated/Updated/Deleted</code>, <code>ParentCreated/Updated/Deleted</code> (<code>:28-39</code>)</td></tr> <tr><td>Fees / payments</td><td><code>FeeStructureCreated</code>, <code>InvoiceIssued</code>, <code>PaymentCompleted</code> (<code>:40-42</code>)</td></tr> <tr><td>Test</td><td><code>WebhookTested</code> (test ping, <code>webhooks.service.ts:134</code>)</td></tr> </tbody></table> </div> <p>"Custom event" input (optional, <code>allowCustom</code>): free text - matching is exact string equality (<code>webhook.repository.ts:27</code>), typos silently never fire.</p> <h2 id="4-client-side-validation-order"><a class="header" href="#4-client-side-validation-order">4. Client-side validation order</a></h2> <ol> <li><code>name</code> empty → "Name is required".</li> <li><code>url</code> not parseable / no scheme → "Enter a valid URL" (mirror <code>@IsUrl</code>, <code>create-webhook.dto.ts:17-18</code>).</li> <li><code>events</code> empty → "Select at least one event" (mirror <code>@ArrayMinSize(1)</code>, <code>:22-23</code>).</li> <li><code>secret</code> empty (create) → "Secret is required" (mirror <code>@IsString</code>, <code>:27-28</code>).</li> <li><code>enabled</code> boolean coercion (switch) - no text validation.</li> <li>Server 400s: map class-validator <code>message</code> arrays to fields; unknown messages → top-of-form error.</li> </ol> <h2 id="5-submit-behaviour"><a class="header" href="#5-submit-behaviour">5. Submit behaviour</a></h2> <ul> <li>Create: <code>POST /api/v1/webhooks</code> (<code>webhooks.controller.ts:21-25</code>) → on success pop to detail (S3); on 400 keep form + field errors; on 401/500 <code>AppErrorState</code>.</li> <li>Edit: <code>PATCH /api/v1/webhooks/:id</code> (<code>webhooks.controller.ts:39-43</code>) → same, pop to S3 with refreshed doc; 404 → error state (webhook deleted elsewhere).</li> <li>Both: submitting lock (CTA spinner, fields disabled); no double submit.</li> </ul> <h2 id="6-secret-handling"><a class="header" href="#6-secret-handling">6. Secret handling</a></h2> <ul> <li>Create: required; "Generate" helper fills a strong random value client-side.</li> <li>Edit: blank = keep unchanged (never echo the stored secret into the field; show placeholder "Leave blank to keep current secret").</li> <li>Detail (S3): masked with reveal; copy-to-clipboard.</li> <li>Security: secret is stored and returned in plaintext by the API (<code>webhooks.service.ts:31-47</code>); rotation = PATCH <code>secret</code>. Dedicated rotation flow with verification event <code>(forward-looking)</code>.</li> </ul> <h2 id="7-pause--resume-non-form-action"><a class="header" href="#7-pause--resume-non-form-action">7. Pause / Resume (non-form action)</a></h2> <ul> <li>No form: <code>POST /webhooks/:id/pause</code> → <code>setEnabled(false)</code>, <code>POST /webhooks/:id/resume</code> → <code>setEnabled(true)</code> (<code>webhooks.controller.ts:78-90</code>; <code>webhooks.service.ts:158-161</code>).</li> <li>Paused ≠ deleted: fan-out filter drops it (<code>webhook.repository.ts:23-28</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="09---user-behaviour-webhooks-module"><a class="header" href="#09---user-behaviour-webhooks-module">09 - User Behaviour (Webhooks Module)</a></h1> <ul> <li><a href="webhooks/09_User_Behaviour.html#1-behavioural-contract-what-the-user-sees-must-match-the-server">1. Behavioural contract (what the user sees must match the server)</a></li> <li><a href="webhooks/09_User_Behaviour.html#2-create--verify-loop-developer">2. Create → verify loop (developer)</a></li> <li><a href="webhooks/09_User_Behaviour.html#3-failure--diagnose--retry-loop">3. Failure → diagnose → retry loop</a></li> <li><a href="webhooks/09_User_Behaviour.html#4-pause--vendor-fix--resume-loop-admin">4. Pause → vendor fix → resume loop (admin)</a></li> <li><a href="webhooks/09_User_Behaviour.html#5-behavioural-gaps-flagged-see-14">5. Behavioural gaps (flagged, see 14)</a></li> </ul> <blockquote> <p>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.</p> </blockquote> <hr /> <h2 id="1-behavioural-contract-what-the-user-sees-must-match-the-server"><a class="header" href="#1-behavioural-contract-what-the-user-sees-must-match-the-server">1. Behavioural contract (what the user sees must match the server)</a></h2> <div class="table-wrapper"><table><thead><tr><th>User-visible fact</th><th>Server source</th></tr></thead><tbody> <tr><td>List is newest-first</td><td><code>findAll</code> sort <code>{ createdAt: -1 }</code> (<code>webhooks.service.ts:35-37</code>)</td></tr> <tr><td>Logs are newest-first, max 50</td><td><code>findLogs</code> limit 50 (<code>webhooks.service.ts:88-93</code>)</td></tr> <tr><td>Statuses: <code>pending</code>, <code>success</code>, <code>failed</code> only</td><td>enum (<code>webhook-delivery-log.schema.ts:18-23</code>)</td></tr> <tr><td>"Active" vs "Paused" = <code>enabled</code></td><td><code>webhook.schema.ts:22-23</code>, pause/resume (<code>webhooks.controller.ts:78-90</code>)</td></tr> <tr><td>Metrics counts match logs</td><td>both from same collection (<code>webhooks.service.ts:142-156</code>)</td></tr> <tr><td>Test/retry replies are "queued", not "delivered"</td><td><code>{ message: 'Test delivery queued' }</code>, <code>{ message: 'Retry queued' }</code> (<code>webhooks.controller.ts:62, 69</code>)</td></tr> <tr><td>Retry works only when a failure exists</td><td><code>NotFoundException('No failed deliveries to retry')</code> (<code>webhooks.service.ts:103-108</code>)</td></tr> <tr><td>Deleting is soft (logs survive)</td><td><code>repo.softDelete</code> (<code>webhooks.service.ts:50-53</code>, <code>base.repository.ts:68-74</code>)</td></tr> </tbody></table> </div> <h2 id="2-create--verify-loop-developer"><a class="header" href="#2-create--verify-loop-developer">2. Create → verify loop (developer)</a></h2> <ul> <li>Form submit → success → test ping → watch logs go <code>pending → success</code>.</li> <li>Pattern: developer expects the <strong>test</strong> attempt within seconds; if the logs stay empty after ~15 s, surface "queued but not yet processed" (queue is async, <code>webhooks.service.ts:69-84</code>).</li> <li>Signature verification happens out-of-band on the receiver side - the app offers <code>SignatureVerifyCard</code> (dev mode) for quick checks (<code>webhook-delivery.worker.ts:103-105</code>).</li> </ul> <h2 id="3-failure--diagnose--retry-loop"><a class="header" href="#3-failure--diagnose--retry-loop">3. Failure → diagnose → retry loop</a></h2> <ol> <li>Metrics show <code>failed > 0</code> or a red log row.</li> <li>Open S6: <code>responseCode</code> + <code>responseBody</code> explain (e.g. <code>404</code> on stale URL).</li> <li>Fix URL (PATCH, <code>webhooks.controller.ts:39-43</code>) → retry (<code>POST /webhooks/:id/retry</code>) → new attempt row.</li> <li>Note: retry re-sends the <strong>old payload</strong> captured at failure time (<code>webhooks.service.ts:110-118</code>) - users should expect historical data, not a fresh event.</li> <li>If retry fails again, BullMQ gave it 3 attempts already (<code>webhooks.service.ts:80-83</code>) - persistent failure = endpoint problem, not a queue problem.</li> </ol> <h2 id="4-pause--vendor-fix--resume-loop-admin"><a class="header" href="#4-pause--vendor-fix--resume-loop-admin">4. Pause → vendor fix → resume loop (admin)</a></h2> <ul> <li>Vendor incidents → pause (stops fan-out immediately, <code>webhook.repository.ts:23-28</code>) → vendor fixes → resume → backfills happen manually via the vendor's own replay (no replay API today - <code>(planned)</code> gap).</li> <li>Users should know: paused webhooks drop events silently; no queue accumulates.</li> </ul> <h2 id="5-behavioural-gaps-flagged-see-14"><a class="header" href="#5-behavioural-gaps-flagged-see-14">5. Behavioural gaps (flagged, see <a href="webhooks/14_QA_Checklist.html">14</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Gap</th><th>Evidence</th><th>User impact</th></tr></thead><tbody> <tr><td>No replay/backfill API</td><td>only retry-latest-failure exists (<code>webhooks.service.ts:95-120</code>)</td><td>missed events during downtime are gone</td></tr> <tr><td>Retry creates a duplicate log rather than updating the failed one</td><td>worker always <code>startDelivery</code> (new pending row, <code>:163-177</code>)</td><td>log history mixes attempts</td></tr> <tr><td><code>attemptCount</code> stuck at 0</td><td>schema default, never incremented (<code>webhook-delivery-log.schema.ts:31-32</code>)</td><td>no per-attempt numbering</td></tr> <tr><td><code>lastTriggeredAt</code> / <code>failureCount</code> never written</td><td>fields exist (<code>webhook.schema.ts:25-29</code>), no writes found</td><td>cannot sort by last activity server-side</td></tr> <tr><td>HTTP-error double record</td><td>worker records failed w/ code then catch records failed w/o (<code>webhook-delivery.worker.ts:84-99</code>)</td><td><code>responseCode</code> may be lost on failure path</td></tr> <tr><td>Events free-form</td><td><code>create-webhook.dto.ts:20-24</code></td><td>typos silently never fire</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="10---interaction-specification-webhooks-module"><a class="header" href="#10---interaction-specification-webhooks-module">10 - Interaction Specification (Webhooks Module)</a></h1> <ul> <li><a href="webhooks/10_Interaction_Specification.html#1-async-action-semantics-the-core-interaction">1. Async action semantics (the core interaction)</a></li> <li><a href="webhooks/10_Interaction_Specification.html#2-screen-transitions">2. Screen transitions</a></li> <li><a href="webhooks/10_Interaction_Specification.html#3-in-screen-interactions">3. In-screen interactions</a></li> <li><a href="webhooks/10_Interaction_Specification.html#4-waiting-states-async-delivery">4. Waiting states (async delivery)</a></li> <li><a href="webhooks/10_Interaction_Specification.html#5-error-interactions">5. Error interactions</a></li> <li><a href="webhooks/10_Interaction_Specification.html#6-a11y--motion-safety">6. a11y & motion-safety</a></li> </ul> <blockquote> <p>Interaction/motion contract for webhook screens. Global motion tokens in <a href="webhooks/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a> (<code>m-fast</code> 150 ms, <code>m-base</code> 250 ms, <code>m-slow</code> 400 ms); this file defines module-specific interactions and the async delivery UX.</p> </blockquote> <hr /> <h2 id="1-async-action-semantics-the-core-interaction"><a class="header" href="#1-async-action-semantics-the-core-interaction">1. Async action semantics (the core interaction)</a></h2> <p>Every destructive/async action (test, retry, pause, resume, delete) follows one pattern because <strong>the server is always async</strong>: the HTTP call enqueues, never delivers.</p> <div class="table-wrapper"><table><thead><tr><th>Action</th><th>HTTP</th><th>Server work (async)</th><th>UX response</th></tr></thead><tbody> <tr><td>Test</td><td><code>POST /webhooks/:id/test</code> (<code>webhooks.controller.ts:65-70</code>)</td><td>worker POSTs <code>WebhookTested</code> (<code>webhooks.service.ts:130-138</code>)</td><td>snackbar "Test delivery queued" → offer "Watch logs"</td></tr> <tr><td>Retry</td><td><code>POST /webhooks/:id/retry</code> (<code>:58-63</code>)</td><td>re-queue failed payload (<code>webhooks.service.ts:110-118</code>)</td><td>snackbar "Retry queued" → open/refresh logs</td></tr> <tr><td>Pause</td><td><code>POST /webhooks/:id/pause</code> (<code>:78-83</code>)</td><td><code>setEnabled(false)</code> (<code>webhooks.service.ts:158-161</code>)</td><td>badge flips instantly, snackbar</td></tr> <tr><td>Resume</td><td><code>POST /webhooks/:id/resume</code> (<code>:85-90</code>)</td><td><code>setEnabled(true)</code></td><td>badge flips instantly, snackbar</td></tr> <tr><td>Delete</td><td><code>DELETE /webhooks/:id</code> (<code>:45-50</code>)</td><td>soft delete (<code>webhooks.service.ts:50-53</code>)</td><td>confirm dialog → optimistic row removal</td></tr> </tbody></table> </div> <p>Rules:</p> <ul> <li>Never claim completion for test/retry (server says "queued", <code>:62, 69</code>).</li> <li>After test/retry, navigate to S5 or show a "Watch delivery" action.</li> <li>Pause/resume are optimistic with rollback on 404/500.</li> </ul> <h2 id="2-screen-transitions-1"><a class="header" href="#2-screen-transitions-1">2. Screen transitions</a></h2> <div class="table-wrapper"><table><thead><tr><th>From</th><th>To</th><th>Trigger</th><th>Motion (<a href="webhooks/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>)</th></tr></thead><tbody> <tr><td>S1 list</td><td>S2 create</td><td>FAB</td><td>push slide <code>m-base</code></td></tr> <tr><td>S1 list</td><td>S3 detail</td><td>tile tap</td><td>push slide <code>m-base</code></td></tr> <tr><td>S3 detail</td><td>S4 edit</td><td>menu/action</td><td>push slide <code>m-base</code></td></tr> <tr><td>S3 detail</td><td>S5 logs</td><td>preview tap / "View all"</td><td>push slide <code>m-base</code></td></tr> <tr><td>S5 logs</td><td>S6 log detail</td><td>tile tap</td><td>bottom sheet, <code>m-base</code> rise</td></tr> <tr><td>S3/S5/S6</td><td>dialogs (S7/S8)</td><td>action button</td><td>fade + scale <code>m-fast</code></td></tr> <tr><td>Any</td><td>snackbar</td><td>async reply</td><td><code>m-fast</code> entrance, auto-dismiss 4 s</td></tr> </tbody></table> </div> <h2 id="3-in-screen-interactions"><a class="header" href="#3-in-screen-interactions">3. In-screen interactions</a></h2> <ul> <li><strong>Event picker (S2/S4):</strong> chip tap toggles with <code>m-fast</code> scale feedback; group expand/collapse <code>m-fast</code>; "Select all" bulk toggle.</li> <li><strong>Secret field:</strong> reveal/obscure icon swap <code>m-fast</code>; generate → text replace with <code>m-fast</code> fade; copy → snackbar "Copied" (<code>m-fast</code>).</li> <li><strong>Logs (S5):</strong> pull-to-refresh; new rows fade-in <code>m-base</code>; failed rows shake briefly on retry success <code>m-fast</code> (subtle, <a href="webhooks/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>).</li> <li><strong>Metrics (S3):</strong> counts tick-animate on refresh (<code>m-base</code>); failed count pulses when > 0 (attention, <a href="webhooks/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a> motion-safe).</li> <li><strong>Status badge transitions:</strong> Active ↔ Paused cross-fade <code>m-fast</code>.</li> </ul> <h2 id="4-waiting-states-async-delivery"><a class="header" href="#4-waiting-states-async-delivery">4. Waiting states (async delivery)</a></h2> <ul> <li>While a test/retry is in flight, the logs screen shows a subtle "processing" indicator only if the newest row is <code>pending</code> (<code>webhook-delivery-log.schema.ts:22-23</code>); no infinite spinners - a <code>pending</code> row is self-explanatory.</li> <li>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).</li> </ul> <h2 id="5-error-interactions"><a class="header" href="#5-error-interactions">5. Error interactions</a></h2> <div class="table-wrapper"><table><thead><tr><th>Error</th><th>Presentation</th><th>Recovery</th></tr></thead><tbody> <tr><td>400 validation</td><td>inline field errors, first invalid focused</td><td>fix + resubmit</td></tr> <tr><td>404 webhook</td><td><code>AppErrorState</code> + back (S3/S4/S5)</td><td>re-open from list</td></tr> <tr><td>404 retry</td><td>dialog note "No failed deliveries to retry" (<code>webhooks.service.ts:107-108</code>)</td><td>dismiss</td></tr> <tr><td>401</td><td>global re-auth flow (<a href="webhooks/../00-shared/06_State_Management.html">00-shared/06</a>)</td><td>sign in</td></tr> <tr><td>403</td><td>actions hidden; if invoked, permission copy</td><td>contact admin</td></tr> <tr><td>500 / offline</td><td><code>AppErrorState</code> / <code>AppOfflineBanner</code></td><td>retry button</td></tr> </tbody></table> </div> <h2 id="6-a11y--motion-safety"><a class="header" href="#6-a11y--motion-safety">6. a11y & motion-safety</a></h2> <ul> <li>All status changes announced via live regions (badge, snackbar, dialog result).</li> <li>Motion-reduce: disable shake/pulse; keep cross-fades (<a href="webhooks/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a> §7, <a href="webhooks/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>).</li> <li>Touch targets ≥ 48 dp; chip hit areas padded.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="11---design-system-mapping-webhooks-module"><a class="header" href="#11---design-system-mapping-webhooks-module">11 - Design System Mapping (Webhooks Module)</a></h1> <ul> <li><a href="webhooks/11_Design_System_Mapping.html#1-semantic-status-mapping-delivery-health">1. Semantic status mapping (delivery health)</a></li> <li><a href="webhooks/11_Design_System_Mapping.html#2-type-ramp">2. Type ramp</a></li> <li><a href="webhooks/11_Design_System_Mapping.html#3-spacing--layout">3. Spacing & layout</a></li> <li><a href="webhooks/11_Design_System_Mapping.html#4-iconography">4. Iconography</a></li> <li><a href="webhooks/11_Design_System_Mapping.html#5-dark-mode--contrast">5. Dark mode / contrast</a></li> <li><a href="webhooks/11_Design_System_Mapping.html#6-motion-tokens-00-shared08">6. Motion tokens (00-shared/08)</a></li> <li><a href="webhooks/11_Design_System_Mapping.html#7-component-mapping-table">7. Component mapping table</a></li> </ul> <blockquote> <p>Module → design-system mapping. Tokens in <a href="webhooks/../00-shared/02_Design_Tokens.html">00-shared/02</a>, components in <a href="webhooks/../00-shared/03_Component_Library.html">00-shared/03</a>, mapping method in <a href="webhooks/../00-shared/04_Design_System_Mapping.html">00-shared/04</a>. This file adds webhook specifics only.</p> </blockquote> <hr /> <h2 id="1-semantic-status-mapping-delivery-health"><a class="header" href="#1-semantic-status-mapping-delivery-health">1. Semantic status mapping (delivery health)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Domain state</th><th>Token role</th><th>Component usage</th></tr></thead><tbody> <tr><td><code>success</code> (<code>webhook-delivery-log.schema.ts:20</code>)</td><td><code>success</code></td><td><code>AppBadge</code> success; metric green</td></tr> <tr><td><code>failed</code> (same)</td><td><code>danger</code></td><td><code>AppBadge</code> danger; metric red + pulse; retry affordance</td></tr> <tr><td><code>pending</code> (same)</td><td><code>warning</code></td><td><code>AppBadge</code> warning amber; metric amber</td></tr> <tr><td>webhook <code>enabled: true</code> (<code>webhook.schema.ts:22-23</code>)</td><td><code>success</code></td><td>badge "Active"</td></tr> <tr><td>webhook <code>enabled: false</code></td><td><code>neutral</code></td><td>badge "Paused"</td></tr> <tr><td>no deliveries yet</td><td><code>neutral</code></td><td>empty state icon</td></tr> </tbody></table> </div> <p>Rules: color never alone - text label always present (<a href="webhooks/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>).</p> <h2 id="2-type-ramp"><a class="header" href="#2-type-ramp">2. Type ramp</a></h2> <div class="table-wrapper"><table><thead><tr><th>Content</th><th>Token (<a href="webhooks/../00-shared/02_Design_Tokens.html">00-shared/02</a>)</th></tr></thead><tbody> <tr><td>Screen titles / AppBar</td><td><code>text-lg / text-xl</code> semibold</td></tr> <tr><td>Webhook name (list, detail)</td><td><code>text-md</code> semibold</td></tr> <tr><td>URL</td><td><code>text-sm</code> monospace (<code>fontMono</code>), muted</td></tr> <tr><td>Event chips</td><td><code>text-sm</code> label in chip</td></tr> <tr><td>Delivery log eventType</td><td><code>text-sm</code> monospace</td></tr> <tr><td>Payload / responseBody code blocks</td><td><code>text-xs</code> monospace, <code>surface-muted</code> background</td></tr> <tr><td>Metrics counts</td><td><code>text-2xl</code> tabular-nums</td></tr> </tbody></table> </div> <h2 id="3-spacing--layout-2"><a class="header" href="#3-spacing--layout-2">3. Spacing & layout</a></h2> <ul> <li>List rows: 16 dp padding, 8 dp between sections.</li> <li>Create/edit form: <code>AppCard</code> 16 dp padding, field spacing 16 dp.</li> <li>Metrics grid: 2 columns (12/24) on phone, 4 on tablet.</li> <li>Dialog width ≤ 360 dp; bottom sheet ≤ 80% height (payload scroll).</li> </ul> <h2 id="4-iconography-13"><a class="header" href="#4-iconography-13">4. Iconography</a></h2> <div class="table-wrapper"><table><thead><tr><th>Concept</th><th>Icon</th></tr></thead><tbody> <tr><td>Webhook (list tile leading)</td><td>globe/webhook glyph</td></tr> <tr><td>Active</td><td>check-circle</td></tr> <tr><td>Paused</td><td>pause-circle</td></tr> <tr><td>Delivery status icons</td><td>clock (pending), check (success), alert (failed)</td></tr> <tr><td>Actions</td><td>send (test), replay (retry), eye/eye-off (secret), copy</td></tr> </tbody></table> </div> <h2 id="5-dark-mode--contrast"><a class="header" href="#5-dark-mode--contrast">5. Dark mode / contrast</a></h2> <ul> <li>Code blocks: <code>surface-muted</code> with <code>text-on-surface</code>; status badges keep token pairs - no raw hex anywhere (<a href="webhooks/../00-shared/02_Design_Tokens.html">00-shared/02</a>).</li> <li>Monospace URL contrast ≥ 4.5:1 on both themes.</li> </ul> <h2 id="6-motion-tokens-00-shared08"><a class="header" href="#6-motion-tokens-00-shared08">6. Motion tokens (<a href="webhooks/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Token</th></tr></thead><tbody> <tr><td>chip toggle, snackbar, badge cross-fade</td><td><code>m-fast</code> (150 ms)</td></tr> <tr><td>screen push, sheet rise, row fade-in</td><td><code>m-base</code> (250 ms)</td></tr> <tr><td>failed-count attention pulse</td><td><code>m-slow</code> (400 ms), disabled on motion-reduce</td></tr> </tbody></table> </div> <h2 id="7-component-mapping-table"><a class="header" href="#7-component-mapping-table">7. Component mapping table</a></h2> <div class="table-wrapper"><table><thead><tr><th>Module widget (<a href="webhooks/07_Component_Library.html">07</a>)</th><th>Base components</th><th>Shared counterpart</th></tr></thead><tbody> <tr><td><code>EventPickerChipField</code></td><td><code>AppChips</code> grid + <code>AppTextField</code> (custom input)</td><td><a href="webhooks/../00-shared/03_Component_Library.html">00-shared/03</a> chips</td></tr> <tr><td><code>SecretField</code></td><td><code>AppTextField</code> + icon buttons</td><td>-</td></tr> <tr><td><code>DeliveryLogTile</code></td><td><code>AppListTile</code> + <code>AppBadge</code></td><td>-</td></tr> <tr><td><code>MetricsTile</code></td><td><code>AppStatTile</code> × 4</td><td><a href="webhooks/../00-shared/03_Component_Library.html">00-shared/03</a> stats</td></tr> <tr><td><code>SignatureVerifyCard</code></td><td><code>AppCard</code> + monospace blocks</td><td>-</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="12---api-mapping-webhooks-module"><a class="header" href="#12---api-mapping-webhooks-module">12 - API Mapping (Webhooks Module)</a></h1> <ul> <li><a href="webhooks/12_API_Mapping.html#1-subscriptions-crud-webhookscontrollerts21-50">1. Subscriptions CRUD (<code>webhooks.controller.ts:21-50</code>)</a></li> <li><a href="webhooks/12_API_Mapping.html#2-operations-webhookscontrollerts52-90">2. Operations (<code>webhooks.controller.ts:52-90</code>)</a></li> <li><a href="webhooks/12_API_Mapping.html#3-delivery-wire-contract-outbound-server--subscriber">3. Delivery wire contract (outbound, server → subscriber)</a></li> <li><a href="webhooks/12_API_Mapping.html#4-request-examples">4. Request examples</a></li> <li><a href="webhooks/12_API_Mapping.html#5-response-shapes">5. Response shapes</a></li> <li><a href="webhooks/12_API_Mapping.html#6-error-map">6. Error map</a></li> <li><a href="webhooks/12_API_Mapping.html#7-planned--not-yet-in-source">7. Planned / not yet in source</a></li> </ul> <blockquote> <p>Exact wire contracts for the Webhooks module. Base path <code>/api/v1</code> (URI versioning, <code>main.ts</code>); endpoints carry <code>@Permissions</code> decorators (<code>webhooks.controller.ts:22, 28, 34, 40, 46, 53, 59, 66, 73, 79, 86</code>) backed by <code>permissions.constants.ts:89-92</code> (guards on endpoints not yet wired - <code>AGENTS.md</code>). Tenant-scoped via <code>BaseRepository</code> (<code>base.repository.ts:20-30</code>). Envelopes per <a href="webhooks/../00-shared/07_API_Conventions.html">00-shared/07</a> §2-3. All paths below are prefixed <code>/api/v1/webhooks</code> unless noted.</p> </blockquote> <hr /> <h2 id="1-subscriptions-crud-webhookscontrollerts21-50"><a class="header" href="#1-subscriptions-crud-webhookscontrollerts21-50">1. Subscriptions CRUD (<code>webhooks.controller.ts:21-50</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Method</th><th>Path</th><th>Permission</th><th>DTO / source</th><th>Description</th></tr></thead><tbody> <tr><td>POST</td><td><code>/api/v1/webhooks</code></td><td><code>webhook.create</code></td><td><code>CreateWebhookDto</code> (<code>create-webhook.dto.ts:11-34</code>)</td><td>Create subscription; returns full doc incl. plaintext <code>secret</code></td></tr> <tr><td>GET</td><td><code>/api/v1/webhooks</code></td><td><code>webhook.read</code></td><td>-</td><td>List all, sort <code>createdAt: -1</code>, unpaginated (<code>webhooks.service.ts:35-37</code>)</td></tr> <tr><td>GET</td><td><code>/api/v1/webhooks/:id</code></td><td><code>webhook.read</code></td><td>-</td><td>Get by id; 404 <code>'Webhook not found'</code> (<code>webhooks.service.ts:39-43</code>)</td></tr> <tr><td>PATCH</td><td><code>/api/v1/webhooks/:id</code></td><td><code>webhook.update</code></td><td><code>UpdateWebhookDto</code> (PartialType - all optional, <code>update-webhook.dto.ts:4</code>)</td><td>Update any subset; 404 if missing (<code>webhooks.service.ts:45-48</code>)</td></tr> <tr><td>DELETE</td><td><code>/api/v1/webhooks/:id</code></td><td><code>webhook.delete</code></td><td>-</td><td>Soft delete; 200 <code>{ message: 'Webhook deleted' }</code> (<code>webhooks.controller.ts:45-50</code>, <code>webhooks.service.ts:50-53</code>)</td></tr> </tbody></table> </div> <h2 id="2-operations-webhookscontrollerts52-90"><a class="header" href="#2-operations-webhookscontrollerts52-90">2. Operations (<code>webhooks.controller.ts:52-90</code>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Method</th><th>Path</th><th>Permission</th><th>Source</th><th>Description</th></tr></thead><tbody> <tr><td>GET</td><td><code>/api/v1/webhooks/:id/logs</code></td><td><code>webhook.read</code></td><td><code>webhooks.service.ts:88-93</code></td><td>Delivery attempts, newest first, hard limit 50; no pagination params</td></tr> <tr><td>POST</td><td><code>/api/v1/webhooks/:id/retry</code></td><td><code>webhook.update</code></td><td><code>webhooks.service.ts:95-120</code></td><td>Re-queue latest <code>failed</code> attempt (404 <code>'No failed deliveries to retry'</code> if none, <code>:107-108</code>); 200 <code>{ message: 'Retry queued' }</code></td></tr> <tr><td>POST</td><td><code>/api/v1/webhooks/:id/test</code></td><td><code>webhook.update</code></td><td><code>webhooks.service.ts:122-140</code></td><td>Enqueue synthetic delivery (<code>eventType: 'WebhookTested'</code>, <code>payload: { test: true, webhookId }</code>); 200 <code>{ message: 'Test delivery queued' }</code></td></tr> <tr><td>GET</td><td><code>/api/v1/webhooks/:id/metrics</code></td><td><code>webhook.read</code></td><td><code>webhooks.service.ts:142-156</code></td><td><code>{ total, success, failed, pending }</code> counts; 404 if webhook missing</td></tr> <tr><td>POST</td><td><code>/api/v1/webhooks/:id/pause</code></td><td><code>webhook.update</code></td><td><code>webhooks.service.ts:158-161</code></td><td><code>setEnabled(false)</code>; 200 <code>{ message: 'Webhook paused' }</code></td></tr> <tr><td>POST</td><td><code>/api/v1/webhooks/:id/resume</code></td><td><code>webhook.update</code></td><td><code>webhooks.service.ts:158-161</code></td><td><code>setEnabled(true)</code>; 200 <code>{ message: 'Webhook resumed' }</code></td></tr> </tbody></table> </div> <h2 id="3-delivery-wire-contract-outbound-server--subscriber"><a class="header" href="#3-delivery-wire-contract-outbound-server--subscriber">3. Delivery wire contract (outbound, server → subscriber)</a></h2> <p>Worker POST (not a client API, but part of the contract consumers must handle):</p> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Value</th><th>Source</th></tr></thead><tbody> <tr><td>Method</td><td><code>POST</code></td><td><code>webhook-delivery.worker.ts:73</code></td></tr> <tr><td><code>Content-Type</code></td><td><code>application/json</code></td><td><code>:76</code></td></tr> <tr><td><code>X-Webhook-Signature</code></td><td><code>HMAC-SHA256</code> hex of raw body using <code>webhook.secret</code></td><td><code>:77, 103-105</code></td></tr> <tr><td><code>X-Webhook-Event</code></td><td><code>eventType</code></td><td><code>:78</code></td></tr> <tr><td>Body</td><td><code>JSON.stringify(payload)</code> (the <code>DomainEvent.payload</code>, <code>domain-event.interface.ts:7</code>)</td><td><code>:70, 80</code></td></tr> <tr><td>Timeout</td><td><code>AbortSignal.timeout(10000)</code> - 10 s</td><td><code>:81</code></td></tr> <tr><td>Retries</td><td>3 attempts, exponential backoff 5 s (job options)</td><td><code>webhooks.service.ts:80-83</code></td></tr> <tr><td>Success</td><td>2xx → log <code>success</code> with <code>responseCode</code> + response body</td><td><code>:84-89</code></td></tr> </tbody></table> </div> <h2 id="4-request-examples"><a class="header" href="#4-request-examples">4. Request examples</a></h2> <pre><code class="language-json">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 } } </code></pre> <h2 id="5-response-shapes"><a class="header" href="#5-response-shapes">5. Response shapes</a></h2> <ul> <li>List/single/created/updated: <code>{ data: <Doc> }</code> (envelope interceptor, <code>00-shared/07</code>). Webhook doc fields: <code>_id, name, url, events, secret, enabled, lastTriggeredAt?, failureCount, tenantId, isDeleted, version, createdAt, updatedAt</code> (<code>webhook.schema.ts:8-30</code>, <code>base.schema.ts:10-34</code>).</li> <li>Logs: <code>{ data: DeliveryLog[] }</code>; fields <code>_id, webhookId, eventType, payload, status, responseCode?, responseBody?, attemptCount, attemptedAt, completedAt?</code> (<code>webhook-delivery-log.schema.ts:7-39</code>).</li> <li>Action endpoints: <code>{ message: string }</code>.</li> </ul> <h2 id="6-error-map"><a class="header" href="#6-error-map">6. Error map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Code</th><th>Meaning</th><th>Source</th></tr></thead><tbody> <tr><td>401</td><td>unauthenticated (JWT guard pending wiring - <code>AGENTS.md</code>)</td><td><code>webhooks.controller.ts:21-90</code></td></tr> <tr><td>403</td><td>missing <code>webhook.*</code> permission</td><td><code>permissions.constants.ts:89-92</code></td></tr> <tr><td>400</td><td>DTO validation (class-validator)</td><td><code>create-webhook.dto.ts:11-34</code></td></tr> <tr><td>404</td><td>webhook missing</td><td><code>webhooks.service.ts:41, 46, 51</code></td></tr> <tr><td>404</td><td>retry with no failed delivery</td><td><code>webhooks.service.ts:107-108</code></td></tr> <tr><td>500</td><td>queue enqueue / DB failure</td><td>-</td></tr> </tbody></table> </div> <h2 id="7-planned--not-yet-in-source-1"><a class="header" href="#7-planned--not-yet-in-source-1">7. Planned / not yet in source</a></h2> <div class="table-wrapper"><table><thead><tr><th>Path / feature</th><th>Status</th><th>Source</th></tr></thead><tbody> <tr><td>Inbound public webhook receiver</td><td><code>(planned)</code></td><td><code>IMPLEMENTATION_PLAN.md:48</code> ("(webhooks, health)", 30 req/min, 1 min window)</td></tr> <tr><td>Logs pagination/filtering (<code>?page&limit&status</code>)</td><td><code>(planned)</code> - hard cap 50 today</td><td><code>webhooks.service.ts:88-93</code></td></tr> <tr><td>Secret rotation endpoint (generate + rotate in one call)</td><td><code>(planned)</code> - PATCH only</td><td><code>webhooks.controller.ts:39-43</code></td></tr> <tr><td>Replay/backfill of missed events</td><td><code>(planned)</code> - only latest-failure retry</td><td><code>webhooks.service.ts:95-120</code></td></tr> <tr><td><code>X-Webhook-Timestamp</code> / replay protection</td><td><code>(planned)</code> - body-only signature</td><td><code>webhook-delivery.worker.ts:103-105</code></td></tr> <tr><td>Test-series vendor integration via webhooks</td><td><code>(planned)</code></td><td><code>IMPLEMENTATION_PLAN.md:856</code></td></tr> <tr><td>Event-type registry endpoint (<code>GET /events</code>)</td><td><code>(planned)</code> - picker vocabulary is static</td><td><code>event-queue-map.ts:6-43</code></td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="13---state-management-webhooks-module"><a class="header" href="#13---state-management-webhooks-module">13 - State Management (Webhooks Module)</a></h1> <ul> <li><a href="webhooks/13_State_Management.html#1-cubit-map">1. Cubit map</a></li> <li><a href="webhooks/13_State_Management.html#2-webhooklistcubit">2. WebhookListCubit</a></li> <li><a href="webhooks/13_State_Management.html#3-webhookdetailcubit-config--metrics">3. WebhookDetailCubit (config + metrics)</a></li> <li><a href="webhooks/13_State_Management.html#4-deliveryretry-state-machine-the-core-diagram">4. Delivery/Retry state machine (the core diagram)</a></li> <li><a href="webhooks/13_State_Management.html#5-deliveryloglistcubit">5. DeliveryLogListCubit</a></li> <li><a href="webhooks/13_State_Management.html#6-webhookformcubit">6. WebhookFormCubit</a></li> <li><a href="webhooks/13_State_Management.html#7-cross-cutting">7. Cross-cutting</a></li> </ul> <blockquote> <p>Per-screen Cubit/Bloc design on top of <a href="webhooks/../00-shared/06_State_Management.html">00-shared/06</a> conventions (stack: <code>flutter_bloc</code> + <code>get_it</code>; server state via dio repository; <code>LoadState</code> = Initial/Loading/Success/Error(ApiException)). Mermaid diagrams included.</p> </blockquote> <hr /> <h2 id="1-cubit-map-10"><a class="header" href="#1-cubit-map-10">1. Cubit map</a></h2> <div class="table-wrapper"><table><thead><tr><th>Cubit</th><th>Screen(s) (<a href="webhooks/05_Screen_Inventory.html">05</a>)</th><th>Data</th></tr></thead><tbody> <tr><td><code>WebhookListCubit</code></td><td>1</td><td><code>List<Webhook></code>, per-row actions</td></tr> <tr><td><code>WebhookFormCubit</code></td><td>2/4</td><td>form model (name, url, events, secret, enabled), field errors, submit</td></tr> <tr><td><code>WebhookDetailCubit</code></td><td>3</td><td><code>Webhook</code>, metrics <code>{total, success, failed, pending}</code></td></tr> <tr><td><code>DeliveryLogListCubit</code></td><td>3 preview / 5</td><td><code>List<DeliveryLog></code> (≤ 50, <code>webhooks.service.ts:88-93</code>)</td></tr> <tr><td><code>WebhookActionCubit</code></td><td>7/8/9</td><td>test / retry / pause / resume async actions</td></tr> </tbody></table> </div> <p>Repositories (<code>WebhookRepository</code>, <code>DeliveryLogRepository</code> in <code>features/webhooks/data/</code>) are the only layer touching HTTP; they map envelopes to models and throw <code>ApiException(status, message)</code> (<a href="webhooks/../00-shared/06_State_Management.html">00-shared/06</a> §2-3). Models: <code>Webhook</code> (<code>webhook.schema.ts:8-30</code>), <code>DeliveryLog</code> (<code>webhook-delivery-log.schema.ts:7-39</code>).</p> <h2 id="2-webhooklistcubit"><a class="header" href="#2-webhooklistcubit">2. WebhookListCubit</a></h2> <pre class="mermaid">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) </pre> <ul> <li>Fetch: <code>GET /api/v1/webhooks</code> - no pagination, sort <code>createdAt: -1</code> (<code>webhooks.service.ts:35-37</code>).</li> <li>Delete flow: confirm dialog → <code>DELETE /api/v1/webhooks/:id</code> (<code>webhooks.controller.ts:45-50</code>) → optimistic row removal; rollback on error.</li> </ul> <h2 id="3-webhookdetailcubit-config--metrics"><a class="header" href="#3-webhookdetailcubit-config--metrics">3. WebhookDetailCubit (config + metrics)</a></h2> <pre class="mermaid">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) </pre> <ul> <li>Independent per-block load: webhook doc and metrics each have their own <code>LoadState</code>; metrics failure never blanks config (<code>webhooks.service.ts:142-156</code>).</li> <li>Pause/resume: <code>WebhookActionCubit</code> → <code>POST /webhooks/:id/pause|resume</code> (<code>webhooks.controller.ts:78-90</code>) → optimistic <code>enabled</code> flip, rollback on error (<code>webhooks.service.ts:158-161</code>).</li> </ul> <h2 id="4-deliveryretry-state-machine-the-core-diagram"><a class="header" href="#4-deliveryretry-state-machine-the-core-diagram">4. Delivery/Retry state machine (the core diagram)</a></h2> <pre class="mermaid">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 </pre> <ul> <li>Test: <code>POST /webhooks/:id/test</code> (<code>webhooks.controller.ts:65-70</code>) - job <code>eventType: 'WebhookTested'</code> (<code>webhooks.service.ts:130-138</code>).</li> <li>Retry: <code>POST /webhooks/:id/retry</code> (<code>:58-63</code>); server requires a latest <code>failed</code> log (<code>webhooks.service.ts:103-108</code>); re-queues that attempt's original payload with <code>correlationId: ''</code> (<code>:110-118</code>).</li> <li>Worker side (server truth): attempts 3 / exponential backoff 5 s (<code>webhooks.service.ts:80-83</code>); 10 s fetch timeout (<code>webhook-delivery.worker.ts:81</code>); status transitions <code>pending → success|failed</code> (<code>webhooks.service.ts:163-193</code>, <code>webhook-delivery-log.schema.ts:18-23</code>).</li> <li>Client implication: a <code>Queued</code> state is terminal for the HTTP action; all delivery transitions are observed via logs polling, not HTTP responses.</li> </ul> <h2 id="5-deliveryloglistcubit"><a class="header" href="#5-deliveryloglistcubit">5. DeliveryLogListCubit</a></h2> <pre class="mermaid">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) </pre> <ul> <li>Logs endpoint: sort <code>createdAt: -1</code>, limit 50 (<code>webhooks.service.ts:88-93</code>).</li> <li>After test/retry the cubit refreshes on a short delay (2-3 s) then again on user pull; a <code>pending</code> newest row keeps a light "processing" indicator (<code>webhook-delivery-log.schema.ts:22-23</code>).</li> </ul> <h2 id="6-webhookformcubit"><a class="header" href="#6-webhookformcubit">6. WebhookFormCubit</a></h2> <pre class="mermaid">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 --> [*] </pre> <ul> <li>Create: <code>POST /api/v1/webhooks</code> (<code>webhooks.controller.ts:21-25</code>) body from <code>CreateWebhookDto</code> (<code>create-webhook.dto.ts:11-34</code>).</li> <li>Edit: <code>PATCH /api/v1/webhooks/:id</code> with dirty fields only (<code>UpdateWebhookDto</code>, <code>update-webhook.dto.ts:4</code>); 404 → error state.</li> <li>Server 400 messages mapped to fields (mirrors class-validator rules, <code>08</code> §4).</li> </ul> <h2 id="7-cross-cutting-1"><a class="header" href="#7-cross-cutting-1">7. Cross-cutting</a></h2> <ul> <li><strong>Cache</strong>: webhook list cached in memory per tenant; detail/logs read cache-first then refresh (offline tolerance, <code>00-shared/10</code> §2).</li> <li><strong>Events as hints</strong>: the client does NOT consume the domain-event stream for webhook UI; server state is the only truth (logs polling).</li> <li><strong>Permission gating</strong>: cubits expose <code>canCreate/canRead/canUpdate/canDelete</code> from RBAC (<code>permissions.constants.ts:89-92</code>); UI hides FAB/actions/menus accordingly (e.g. no "New webhook" without <code>webhook.create</code>).</li> <li><strong>Secret handling</strong>: secret kept only in memory in DetailCubit; never cached to disk; forms never pre-fill it (blank = keep, <code>08</code> §6).</li> <li><strong>Planned cubits</strong>: inbound-receiver list <code>(planned)</code> per <code>IMPLEMENTATION_PLAN.md:48</code> - no data contract yet.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14---qa-checklist-webhooks-module"><a class="header" href="#14---qa-checklist-webhooks-module">14 - QA Checklist (Webhooks Module)</a></h1> <ul> <li><a href="webhooks/14_QA_Checklist.html#1-endpoint-contract-c">1. Endpoint contract (C)</a></li> <li><a href="webhooks/14_QA_Checklist.html#2-delivery-pipeline-c">2. Delivery pipeline (C)</a></li> <li><a href="webhooks/14_QA_Checklist.html#3-signature-verification-c">3. Signature verification (C)</a></li> <li><a href="webhooks/14_QA_Checklist.html#4-retry--test-h">4. Retry & test (H)</a></li> <li><a href="webhooks/14_QA_Checklist.html#5-payload-size--robustness-m">5. Payload size & robustness (M)</a></li> <li><a href="webhooks/14_QA_Checklist.html#6-schemabookkeeping-gaps-m">6. Schema/bookkeeping gaps (M)</a></li> <li><a href="webhooks/14_QA_Checklist.html#7-resilience-m">7. Resilience (M)</a></li> <li><a href="webhooks/14_QA_Checklist.html#8-manual-qa-script-smoke">8. Manual QA script (smoke)</a></li> </ul> <blockquote> <p>Test plan for the implemented backend surface. Baseline conventions in <a href="webhooks/../00-shared/10_QA_Baseline.html">00-shared/10</a>; module items below are specific to webhook delivery semantics. Each item cites its source contract. Severity: C = critical, H = high, M = medium.</p> </blockquote> <hr /> <h2 id="1-endpoint-contract-c"><a class="header" href="#1-endpoint-contract-c">1. Endpoint contract (C)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expected</th><th>Source</th></tr></thead><tbody> <tr><td>E-1</td><td>CRUD happy path</td><td>POST → 201 doc; GET list newest-first; GET :id; PATCH subset; DELETE → <code>{ message: 'Webhook deleted' }</code> and doc gone from list</td><td><code>webhooks.controller.ts:21-50</code></td></tr> <tr><td>E-2</td><td>404s</td><td>GET/PATCH/DELETE unknown id → 404 <code>'Webhook not found'</code></td><td><code>webhooks.service.ts:39-43, 45-48, 50-53</code></td></tr> <tr><td>E-3</td><td>Permission matrix</td><td><code>webhook.create/read/update/delete</code> enforced per endpoint (<code>permissions.constants.ts:89-92</code>)</td><td><code>webhooks.controller.ts:22, 28, 34, 40, 46, 53, 59, 66, 73, 79, 86</code></td></tr> <tr><td>E-4</td><td>Validation</td><td>empty name/url/secret → 400; <code>events: []</code> → 400 (<code>ArrayMinSize(1)</code>); bad url → 400; <code>enabled</code> non-boolean → 400</td><td><code>create-webhook.dto.ts:11-34</code></td></tr> <tr><td>E-5</td><td>Logs</td><td><code>GET :id/logs</code> returns ≤ 50, newest first; includes <code>payload</code>, <code>status</code>, <code>responseCode?</code>, <code>responseBody?</code></td><td><code>webhooks.service.ts:88-93</code>, <code>webhook-delivery-log.schema.ts:12-38</code></td></tr> <tr><td>E-6</td><td>Metrics</td><td>counts reconcile: <code>total = success + failed + pending</code> for same webhook</td><td><code>webhooks.service.ts:149-154</code></td></tr> <tr><td>E-7</td><td>Tenant isolation</td><td>tenant B cannot read/update/delete/pause tenant A webhooks (scoped filter)</td><td><code>base.repository.ts:20-30</code></td></tr> </tbody></table> </div> <h2 id="2-delivery-pipeline-c"><a class="header" href="#2-delivery-pipeline-c">2. Delivery pipeline (C)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expected</th><th>Source</th></tr></thead><tbody> <tr><td>D-1</td><td>Event fan-out</td><td>emitting event X creates one <code>deliver</code> job per enabled webhook subscribed to X</td><td><code>webhooks.service.ts:55-86</code>, <code>webhook.repository.ts:17-29</code></td></tr> <tr><td>D-2</td><td>No fan-out</td><td>disabled / soft-deleted / unsubscribed webhooks receive nothing</td><td><code>webhook.repository.ts:23-28</code></td></tr> <tr><td>D-3</td><td>Timeout</td><td>endpoint that sleeps > 10 s → attempt <code>failed</code> (<code>AbortSignal.timeout(10000)</code>)</td><td><code>webhook-delivery.worker.ts:81</code></td></tr> <tr><td>D-4</td><td>Retry counts</td><td>failing endpoint observed 3 job attempts with exponential backoff (5 s, 10 s)</td><td><code>webhooks.service.ts:80-83</code></td></tr> <tr><td>D-5</td><td>Status recording</td><td>2xx → <code>success</code> with <code>responseCode</code>+body; non-2xx → <code>failed</code>; network error → <code>failed</code></td><td><code>webhook-delivery.worker.ts:84-99</code></td></tr> <tr><td>D-6</td><td>Payload fidelity</td><td>receiver gets exactly <code>JSON.stringify(payload)</code> of the emitted event payload</td><td><code>webhook-delivery.worker.ts:70, 80</code>, <code>domain-event.interface.ts:7</code></td></tr> <tr><td>D-7</td><td>Queue presence</td><td>queue named <code>webhook-deliver</code> registered; worker attached</td><td><code>queue.constants.ts:14</code>, <code>webhooks.module.ts:22</code>, <code>webhook-delivery.worker.ts:9</code></td></tr> </tbody></table> </div> <h2 id="3-signature-verification-c"><a class="header" href="#3-signature-verification-c">3. Signature verification (C)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expected</th><th>Source</th></tr></thead><tbody> <tr><td>S-1</td><td>Signature correctness</td><td>receiver computes <code>HMAC-SHA256(rawBody, secret)</code> hex and matches <code>X-Webhook-Signature</code></td><td><code>webhook-delivery.worker.ts:77, 103-105</code></td></tr> <tr><td>S-2</td><td>Header presence</td><td>every POST has <code>Content-Type: application/json</code>, <code>X-Webhook-Signature</code>, <code>X-Webhook-Event</code></td><td><code>webhook-delivery.worker.ts:76-79</code></td></tr> <tr><td>S-3</td><td>Secret mismatch</td><td>wrong stored secret → signature mismatch (receiver rejects) - expected, verifies scheme</td><td><code>webhook.schema.ts:19-20</code></td></tr> <tr><td>S-4</td><td>Secret rotation</td><td>PATCH secret → subsequent deliveries signed with new secret</td><td><code>update-webhook.dto.ts:4</code>, <code>webhooks.service.ts:45-48</code></td></tr> <tr><td>S-5</td><td>Known gap (H)</td><td>no <code>X-Webhook-Timestamp</code> / replay window - document; verification must not assume one</td><td><code>webhook-delivery.worker.ts:103-105</code></td></tr> </tbody></table> </div> <h2 id="4-retry--test-h"><a class="header" href="#4-retry--test-h">4. Retry & test (H)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expected</th><th>Source</th></tr></thead><tbody> <tr><td>R-1</td><td>Retry no-failure</td><td><code>POST :id/retry</code> with zero failed logs → 404 <code>'No failed deliveries to retry'</code></td><td><code>webhooks.service.ts:103-108</code></td></tr> <tr><td>R-2</td><td>Retry payload</td><td>retry re-sends the latest failed attempt's <code>eventType</code> + <code>payload</code> verbatim</td><td><code>webhooks.service.ts:110-118</code></td></tr> <tr><td>R-3</td><td>Retry correlation</td><td>retried job carries <code>correlationId: ''</code> - receiver must tolerate empty string</td><td><code>webhooks.service.ts:117</code></td></tr> <tr><td>R-4</td><td>Test event</td><td><code>POST :id/test</code> → attempt with <code>eventType: 'WebhookTested'</code>, <code>payload: { test: true, webhookId }</code></td><td><code>webhooks.service.ts:130-138</code></td></tr> <tr><td>R-5</td><td>Async replies</td><td>both endpoints reply <code>{ message: '… queued' }</code> before any delivery happens</td><td><code>webhooks.controller.ts:62, 69</code></td></tr> <tr><td>R-6</td><td>Double-record bug (H)</td><td>on non-2xx the worker records failed twice; second call drops <code>responseCode</code>/<code>responseBody</code> - verify final log has code+body or flag as bug</td><td><code>webhook-delivery.worker.ts:84-99</code></td></tr> </tbody></table> </div> <h2 id="5-payload-size--robustness-m"><a class="header" href="#5-payload-size--robustness-m">5. Payload size & robustness (M)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expected</th><th>Source</th></tr></thead><tbody> <tr><td>P-1</td><td>Large payload</td><td>e.g. 1 MB homework/results payload delivered intact (no truncation)</td><td><code>webhook-delivery.worker.ts:70-82</code></td></tr> <tr><td>P-2</td><td>Non-JSON-safe payload</td><td>payload containing strings/numbers only (<code>Record<string, unknown></code>); verify serialization edge cases</td><td><code>webhook-delivery-log.schema.ts:15-16</code></td></tr> <tr><td>P-3</td><td>Response body capture</td><td>5xx with HTML body - stored raw, client must render escaped</td><td><code>webhook-delivery.worker.ts:88</code></td></tr> <tr><td>P-4</td><td>URL edge cases</td><td><code>require_tld: false</code> allows <code>http://localhost:3000/hook</code> and IPs - keep receiver in same tenant network (SSRF exposure is a security review item, M)</td><td><code>create-webhook.dto.ts:17-18</code></td></tr> </tbody></table> </div> <h2 id="6-schemabookkeeping-gaps-m"><a class="header" href="#6-schemabookkeeping-gaps-m">6. Schema/bookkeeping gaps (M)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expected</th><th>Source</th></tr></thead><tbody> <tr><td>G-1</td><td><code>attemptCount</code></td><td>currently always 0 - assert current behavior, track as bug when worker increments</td><td><code>webhook-delivery-log.schema.ts:31-32</code></td></tr> <tr><td>G-2</td><td><code>lastTriggeredAt</code> / <code>failureCount</code></td><td>never written - surfaces can't sort by activity; track</td><td><code>webhook.schema.ts:25-29</code></td></tr> <tr><td>G-3</td><td>Log-per-attempt</td><td>retries create new log rows (not updates) - verify UI handles duplicate-ish rows</td><td><code>webhooks.service.ts:62-67, 84-89</code></td></tr> <tr><td>G-4</td><td>Free-form events</td><td>typo in <code>events</code> silently never fires (exact match, <code>webhook.repository.ts:27</code>) - client picker mitigates; no server whitelist</td><td><code>create-webhook.dto.ts:20-24</code></td></tr> <tr><td>G-5</td><td>Secret exposure</td><td>plaintext <code>secret</code> in every CRUD response - flag to reviewers; UI must not log it</td><td><code>webhooks.service.ts:31-47</code></td></tr> </tbody></table> </div> <h2 id="7-resilience-m"><a class="header" href="#7-resilience-m">7. Resilience (M)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Check</th><th>Expected</th><th>Source</th></tr></thead><tbody> <tr><td>Q-1</td><td>Redis down at enqueue</td><td><code>queue.add</code> failure surfaces as 500 - webhook creation still persists (fan-out is post-create)</td><td><code>webhooks.service.ts:69-84</code></td></tr> <tr><td>Q-2</td><td>Redis down at delivery</td><td>jobs stay in BullMQ; worker reconnect replays - verify no duplicate double-record corruption</td><td><code>webhooks.module.ts:22</code></td></tr> <tr><td>Q-3</td><td>Idempotency</td><td>duplicate event emission produces duplicate deliveries (no dedup) - document for receivers</td><td><code>webhooks.service.ts:55-86</code></td></tr> <tr><td>Q-4</td><td>Metrics under load</td><td>counts via 4 <code>countDocuments</code> - fine at tenant scale; revisit with pagination <code>(planned)</code></td><td><code>webhooks.service.ts:149-154</code></td></tr> </tbody></table> </div> <h2 id="8-manual-qa-script-smoke"><a class="header" href="#8-manual-qa-script-smoke">8. Manual QA script (smoke)</a></h2> <ol> <li>Create webhook with 2 events + test receiver (echo endpoint).</li> <li>Trigger both events → expect 2 deliveries, <code>X-Webhook-Event</code> matching.</li> <li>Test ping → <code>WebhookTested</code> log row within seconds.</li> <li>Point URL at a 500 endpoint → watch 3 attempts → <code>failed</code>; metrics reconcile.</li> <li><code>POST :id/retry</code> → verify original payload resent.</li> <li>Pause → trigger event → no delivery. Resume → trigger → delivery.</li> <li>Delete → absent from list; logs endpoint 404 (webhook deleted).</li> <li>Cross-tenant: second tenant cannot see or act on the webhook.</li> </ol> <div style="break-before: page; page-break-before: always;"></div><h1 id="15---flutter-implementation-guide-webhooks-module"><a class="header" href="#15---flutter-implementation-guide-webhooks-module">15 - Flutter Implementation Guide (Webhooks Module)</a></h1> <ul> <li><a href="webhooks/15_Flutter_Implementation_Guide.html#1-folder-layout">1. Folder layout</a></li> <li><a href="webhooks/15_Flutter_Implementation_Guide.html#2-models">2. Models</a></li> <li><a href="webhooks/15_Flutter_Implementation_Guide.html#3-repositories">3. Repositories</a></li> <li><a href="webhooks/15_Flutter_Implementation_Guide.html#4-cubits-see-13-for-diagrams">4. Cubits (see <code>13</code> for diagrams)</a></li> <li><a href="webhooks/15_Flutter_Implementation_Guide.html#5-key-implementation-details">5. Key implementation details</a></li> <li><a href="webhooks/15_Flutter_Implementation_Guide.html#6-tests">6. Tests</a></li> <li><a href="webhooks/15_Flutter_Implementation_Guide.html#7-analytics-proposed">7. Analytics (proposed)</a></li> <li><a href="webhooks/15_Flutter_Implementation_Guide.html#8-roadmap-items-not-built-flag-in-code">8. Roadmap items NOT built (flag in code)</a></li> </ul> <blockquote> <p>Build order and concrete Flutter implementation notes for the Webhooks module client, on top of <a href="webhooks/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a> (app architecture) and <a href="webhooks/../00-shared/06_State_Management.html">00-shared/06</a> (state). Reminder: per PRD the native app is post-Phase 1 (<code>PRODUCT_REQUIREMENTS_DOCUMENT.md:144</code>); this guide is the forward-looking build plan.</p> </blockquote> <hr /> <h2 id="1-folder-layout-2"><a class="header" href="#1-folder-layout-2">1. Folder layout</a></h2> <pre><code>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) </code></pre> <h2 id="2-models-1"><a class="header" href="#2-models-1">2. Models</a></h2> <pre><code class="language-dart">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; } </code></pre> <h2 id="3-repositories-3"><a class="header" href="#3-repositories-3">3. Repositories</a></h2> <ul> <li><code>WebhookRepository</code>: <code>list()</code>, <code>getById(id)</code>, <code>create(dto)</code>, <code>update(id, dto)</code>, <code>delete(id)</code>, <code>pause(id)</code>, <code>resume(id)</code>, <code>test(id)</code>, <code>retry(id)</code>, <code>metrics(id)</code> <ul> <li>one method per endpoint in <code>12</code> (no batching; all are single calls).</li> </ul> </li> <li><code>DeliveryLogRepository</code>: <code>list(webhookId)</code> → <code>GET /webhooks/:id/logs</code> (≤ 50 rows, <code>webhooks.service.ts:88-93</code>).</li> <li>Actions return the <code>{ message }</code> envelope; expose <code>message</code> in the cubit state for snackbars ("Test delivery queued", <code>webhooks.controller.ts:69</code>).</li> <li>404 on retry → map to <code>NoFailedDelivery</code> so the dialog can show "Nothing to retry" (<code>webhooks.service.ts:107-108</code>).</li> </ul> <h2 id="4-cubits-see-13-for-diagrams"><a class="header" href="#4-cubits-see-13-for-diagrams">4. Cubits (see <code>13</code> for diagrams)</a></h2> <ol> <li><code>WebhookListCubit</code> - fetch list; optimistic delete with rollback.</li> <li><code>WebhookFormCubit</code> - single model for create/edit; dirty-field diff on edit; client validation mirroring <code>08</code> §4.</li> <li><code>WebhookDetailCubit</code> - parallel <code>getById</code> + <code>metrics</code> with independent <code>LoadState</code>s.</li> <li><code>DeliveryLogListCubit</code> - refresh after test/retry (2-3 s delay), pull to refresh; expose <code>isNewestPending</code>.</li> <li><code>WebhookActionCubit</code> - pause/resume/test/retry; optimistic <code>enabled</code> flip.</li> </ol> <h2 id="5-key-implementation-details-1"><a class="header" href="#5-key-implementation-details-1">5. Key implementation details</a></h2> <ul> <li><strong>Event picker</strong>: statically shipped vocabulary (<code>event-queue-map.ts:6-43</code>) - no endpoint exists; group by module as in <code>08</code> §3; custom chip for free text (exact match, <code>webhook.repository.ts:27</code>).</li> <li><strong>Secret field</strong>: obscure + generate (client-side <code>Random.secure()</code>, 32 bytes base64url); never cache to disk (<code>13</code> §7).</li> <li><strong>Async actions</strong>: after test/retry show "queued" snackbar and navigate to logs; never claim delivery (<code>12</code> §2 replies).</li> <li><strong>Status enum</strong>: parse <code>status</code> strictly; unknown values → neutral render (<code>webhook-delivery-log.schema.ts:18-23</code>).</li> <li><strong>Code blocks</strong>: payload + responseBody in <code>SelectableText</code> monospace with copy; collapse by default on large payloads (<code>06</code> S6).</li> <li><strong>Permission gating</strong>: RBAC perms <code>webhook.create/read/update/delete</code> (<code>permissions.constants.ts:89-92</code>) - hide FAB/actions/menus accordingly.</li> </ul> <h2 id="6-tests-1"><a class="header" href="#6-tests-1">6. Tests</a></h2> <ul> <li>Unit: model fromJson (incl. <code>enabled</code> default), form validation rules, dirty-diff, status parsing.</li> <li>Cubit: list load/delete rollback; retry 404 → <code>NoFailedDelivery</code>; metrics partial failure; action messages surfaced.</li> <li>Widget: event picker toggle + min-1 validation; secret reveal; badge rendering per status.</li> <li>Integration (<code>00-shared/10</code>): against running API - create → test → poll logs until <code>success</code>; signature verified on an echo receiver.</li> </ul> <h2 id="7-analytics-proposed-1"><a class="header" href="#7-analytics-proposed-1">7. Analytics (proposed)</a></h2> <p>Wire <code>webhooks.*.*</code> events from <code>05</code>; no SDK selected yet (<code>00-shared/10</code> §8).</p> <h2 id="8-roadmap-items-not-built-flag-in-code-1"><a class="header" href="#8-roadmap-items-not-built-flag-in-code-1">8. Roadmap items NOT built (flag in code)</a></h2> <ul> <li>Logs pagination - wait for server <code>(planned)</code>, <code>webhooks.service.ts:88-93</code>.</li> <li>Inbound receiver screens - wait for <code>public</code> scope <code>(planned)</code>, <code>IMPLEMENTATION_PLAN.md:48</code>.</li> <li>Secret rotation flow <code>(forward-looking)</code>, replay/backfill <code>(planned)</code>, event registry endpoint <code>(planned)</code> - all per <code>12</code> §7.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="01--product-overview-ws--realtime-module"><a class="header" href="#01--product-overview-ws--realtime-module">01 — Product Overview (WS / Realtime Module)</a></h1> <ul> <li><a href="ws/01_Product_Overview.html#1-what-this-module-is">1. What this module is</a></li> <li><a href="ws/01_Product_Overview.html#2-scope-in--out">2. Scope (in / out)</a></li> <li><a href="ws/01_Product_Overview.html#3-goals">3. Goals</a></li> <li><a href="ws/01_Product_Overview.html#4-non-goals">4. Non-goals</a></li> <li><a href="ws/01_Product_Overview.html#5-success-signals-proposed">5. Success signals (proposed)</a></li> </ul> <blockquote> <p>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 <code>00-shared/*</code>; module specifics here.</p> </blockquote> <hr /> <h2 id="1-what-this-module-is-1"><a class="header" href="#1-what-this-module-is-1">1. What this module is</a></h2> <p>StudyLyon's WebSocket stack delivers <strong>server-pushed domain events</strong> to authenticated clients. Two moving parts in source:</p> <div class="table-wrapper"><table><thead><tr><th>Piece</th><th>Source</th><th>Role</th></tr></thead><tbody> <tr><td><code>WsGateway</code></td><td><code>src/modules/ws/ws.gateway.ts</code></td><td>Socket.io gateway on namespace <code>/ws</code>; JWT handshake, tenant rooms, subscribe/unsubscribe</td></tr> <tr><td><code>WsBridge</code></td><td><code>src/modules/ws/ws-bridge.service.ts</code></td><td>Hooks the in-process <code>EventBus</code> and broadcasts every domain event to the emitting tenant</td></tr> <tr><td><code>WsModule</code></td><td><code>src/modules/ws/ws.module.ts</code></td><td>Module wiring; registers <code>JwtModule</code> for handshake verification (<code>ws.module.ts:7</code>)</td></tr> </tbody></table> </div> <p>End-to-end flow, per source:</p> <pre><code>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 </code></pre> <p>The blueprint defines the scope: <em>"WebSockets under <code>/ws</code> for in-app notifications and live dashboards"</em> (<code>studylyon-blueprint/02-Architecture/API_DESIGN.md:76</code>).</p> <h2 id="2-scope-in--out"><a class="header" href="#2-scope-in--out">2. Scope (in / out)</a></h2> <p><strong>In scope today</strong></p> <ul> <li>Authenticated connection handshake (JWT access token) — <code>ws.gateway.ts:35-57</code>.</li> <li>Implicit tenant room per connection — <code>client.join('tenant:...')</code> (<code>ws.gateway.ts:50</code>).</li> <li>Fan-out of all domain events to the tenant room — <code>ws-bridge.service.ts:16-22</code>.</li> <li>Client-driven <code>subscribe</code> / <code>unsubscribe</code> to additional rooms — <code>ws.gateway.ts:63-74</code>.</li> </ul> <p><strong>Out of scope today (see gaps)</strong></p> <ul> <li>REST surface: none; this module has <strong>no HTTP endpoints</strong> (see <code>12_API_Mapping.md</code>).</li> <li>Per-user rooms, per-room RBAC, event acknowledgement, error channel, heartbeat tuning.</li> <li>Redis pub/sub fan-out for horizontal scale — no socket.io adapter exists in <code>src/infrastructure/redis/</code> (only the shared <code>REDIS_CLIENT</code>, <code>redis.constants.ts:1-2</code>).</li> <li>Native mobile push. <strong>PRD note:</strong> 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 <strong>forward-looking</strong> (<code>docs/IMPLEMENTATION_PLAN.md:231,769,859</code>).</li> </ul> <p><strong>Planned consumers (per implementation plan)</strong></p> <ul> <li>Communication module — realtime message delivery via this <code>WsModule</code> (<code>docs/IMPLEMENTATION_PLAN.md:119</code>).</li> <li>Transport live tracking — <code>docs/IMPLEMENTATION_PLAN.md:229</code> (planned).</li> <li>Notifications preferences/channels/device tokens — <code>docs/IMPLEMENTATION_PLAN.md:231</code> (planned; WebSocket is one channel).</li> </ul> <h2 id="3-goals"><a class="header" href="#3-goals">3. Goals</a></h2> <ol> <li>Deliver domain events to the right tenant with low latency (single-hop in-process).</li> <li>Keep the connection layer invisible to users (reconnect handled by client + socket.io).</li> <li>Stay tenant-isolated: a socket may only receive events for the tenant in its JWT.</li> <li>Survive restarts: bridge is idempotent (<code>onModuleInit</code>, <code>ws-bridge.service.ts:15</code>).</li> </ol> <h2 id="4-non-goals"><a class="header" href="#4-non-goals">4. Non-goals</a></h2> <ul> <li>Chat/messaging protocol design (Communication module owns it — planned).</li> <li>Guaranteed at-least-once delivery / replay; events are ephemeral fire-and-forget.</li> <li>Backfill of missed events while disconnected (clients refetch via REST — eventual consistency).</li> </ul> <h2 id="5-success-signals-proposed"><a class="header" href="#5-success-signals-proposed">5. Success signals (proposed)</a></h2> <ul> <li>Connection success rate ≥ 99.5% on stable networks.</li> <li>p95 event → client render < 1 s.</li> <li>Zero tenant-isolation incidents.</li> <li>10k concurrent connections per instance load-tested (<code>docs/IMPLEMENTATION_PLAN.md:842</code> — plan calls this out as a scale risk).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="02--user-personas-ws--realtime-module"><a class="header" href="#02--user-personas-ws--realtime-module">02 — User Personas (WS / Realtime Module)</a></h1> <ul> <li><a href="ws/02_User_Personas.html#1-org-admin--the-overseer">1. Org Admin — "the overseer"</a></li> <li><a href="ws/02_User_Personas.html#2-teacher--the-worker">2. Teacher — "the worker"</a></li> <li><a href="ws/02_User_Personas.html#3-student--the-consumer">3. Student — "the consumer"</a></li> <li><a href="ws/02_User_Personas.html#4-parent--guardian--the-watcher">4. Parent / Guardian — "the watcher"</a></li> <li><a href="ws/02_User_Personas.html#5-it--operations-admin--the-debugger-proposed">5. IT / Operations Admin — "the debugger" (proposed)</a></li> <li><a href="ws/02_User_Personas.html#cross-persona-patterns">Cross-persona patterns</a></li> </ul> <blockquote> <p>The realtime layer has no screens of its own (except the proposed debug view, <a href="ws/05_Screen_Inventory.html">05</a>). Personas describe who <em>benefits</em> from pushed data on existing screens, and how they perceive the connection. All personas are web clients; native app excluded per PRD (see <a href="ws/01_Product_Overview.html">01</a>).</p> </blockquote> <hr /> <h2 id="1-org-admin--the-overseer"><a class="header" href="#1-org-admin--the-overseer">1. Org Admin — "the overseer"</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Profile</td><td>Principal / owner; monitors school-wide operations from the dashboard</td></tr> <tr><td>Realtime needs</td><td>Live fee-collection totals, admission pipeline changes, attendance % movement; wants the dashboard to update without refresh</td></tr> <tr><td>Connection context</td><td>Desktop web, stable office network; often has the dashboard open for hours</td></tr> <tr><td>Failure tolerance</td><td>Low patience for stale numbers — if the socket drops, they expect a visible offline indicator and auto-recovery</td></tr> <tr><td>Priority events</td><td><code>payment.completed</code>, <code>crm.*</code>, <code>attendance.*</code> (dot-namespace per <code>API_STANDARDS.md:642</code>)</td></tr> <tr><td>Quote</td><td>"If the number is wrong I reload the page — please just tell me when it's stale."</td></tr> </tbody></table> </div> <h2 id="2-teacher--the-worker"><a class="header" href="#2-teacher--the-worker">2. Teacher — "the worker"</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Profile</td><td>Takes attendance, posts homework, handles leave requests</td></tr> <tr><td>Realtime needs</td><td>New message / announcement toasts while working; live refresh of lists after colleagues submit</td></tr> <tr><td>Connection context</td><td>Mixed: school wifi, sometimes a classroom AP with NAT; short sessions between classes</td></tr> <tr><td>Failure tolerance</td><td>High for disconnects (they close laptops), low for <strong>missed</strong> messages — mis-delivery is worse than no delivery</td></tr> <tr><td>Priority events</td><td><code>notification.created</code>, <code>communication.*</code> (planned), <code>leave.*</code> (planned)</td></tr> <tr><td>Quote</td><td>"Tell me once, in the corner. Don't make me hunt for it."</td></tr> </tbody></table> </div> <h2 id="3-student--the-consumer"><a class="header" href="#3-student--the-consumer">3. Student — "the consumer"</a></h2> <div class="table-wrapper"><table><thead><tr><th>Attribute</th><th>Detail</th></tr></thead><tbody> <tr><td>Profile</td><td>Receives announcements, homework, results; watches live class/DPP content later (forward-looking)</td></tr> <tr><td>Realtime needs</td><td>Instant announcements; homework-published nudge; result-published nudge</td></tr> <tr><td>Connection context</td><td>Phone browser on school wifi / mobile data; tab often backgrounded → throttled</td></tr> <tr><td>Failure tolerance</td><td>High; background tabs lose the socket — badge must be reconciled via REST on resume</td></tr> <tr><td>Priority events</td><td><code>homework.published</code>, <code>notification.created</code>, <code>examination.*</code> (planned)</td></tr> <tr><td>Quote</td><td>"If the app was closed, at least show me the badge when I open it."</td></tr> </tbody></table> </div> <h2 id="4-parent--guardian--the-watcher"><a class="header" href="#4-parent--guardian--the-watcher">4. Parent / Guardian — "the watcher"</a></h2> <p>| Profile | Receives fee receipts, attendance alerts, transport status (planned) | | Realtime needs | Alerts only — no dashboard residency; expects reliability of <em>delivery</em>, not of <em>live</em> updates | | Priority events | <code>payment.*</code>, <code>attendance.updated</code> alerts, <code>transport.*</code> (planned) | | Failure tolerance | Lowest of all — an undelivered alert is an incident. REST refetch on resume is mandatory |</p> <h2 id="5-it--operations-admin--the-debugger-proposed"><a class="header" href="#5-it--operations-admin--the-debugger-proposed">5. IT / Operations Admin — "the debugger" (proposed)</a></h2> <p>| 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 <code>(proposed)</code> (<a href="ws/05_Screen_Inventory.html">05</a> §5) | | Priority events | Any; wants the event log, not the domain semantics |</p> <hr /> <h2 id="cross-persona-patterns"><a class="header" href="#cross-persona-patterns">Cross-persona patterns</a></h2> <ul> <li><strong>Passive consumers:</strong> nobody <em>sends</em> realtime messages today; the only client→server traffic is <code>subscribe</code>/<code>unsubscribe</code> (<code>ws.gateway.ts:63-74</code>). The API must stay this way until the Communication module defines its protocol <code>(planned)</code>.</li> <li><strong>Multi-tab reality:</strong> every persona may have 2+ tabs open → one socket per tab; the server broadcasts to all sockets in the tenant room (<code>ws.gateway.ts:50,76-78</code>), so clients must deduplicate (see <a href="ws/10_Interaction_Specification.html">10_Interaction_Specification.md</a>).</li> <li><strong>Roles exist on the socket</strong> (<code>client.roles</code>, <code>ws.gateway.ts:17,48</code>) but are <strong>not yet enforced</strong> for room access — see gap in <a href="ws/12_API_Mapping.html">12_API_Mapping.md</a>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="03--user-journey-ws--realtime-module"><a class="header" href="#03--user-journey-ws--realtime-module">03 — User Journey (WS / Realtime Module)</a></h1> <ul> <li><a href="ws/03_User_Journey.html#j1--login-to-connected-all-personas">J1 — Login to connected (all personas)</a></li> <li><a href="ws/03_User_Journey.html#j2--live-dashboard-update-org-admin">J2 — Live dashboard update (Org Admin)</a></li> <li><a href="ws/03_User_Journey.html#j3--notification-receipt-teacher--student--parent">J3 — Notification receipt (Teacher / Student / Parent)</a></li> <li><a href="ws/03_User_Journey.html#j4--network-flap-all-personas">J4 — Network flap (all personas)</a></li> <li><a href="ws/03_User_Journey.html#j5--message-delivery-planned">J5 — Message delivery (planned)</a></li> <li><a href="ws/03_User_Journey.html#j6--realtime-admin-debug-proposed">J6 — Realtime admin debug (proposed)</a></li> <li><a href="ws/03_User_Journey.html#journey-rules">Journey rules</a></li> </ul> <blockquote> <p>Journeys across the <em>connection lifecycle</em>, not screens — the realtime layer's only native moments are connect, event, and reconnect. Source-grounded: handshake (<code>ws.gateway.ts:35-57</code>), fan-out (<code>ws-bridge.service.ts:16-22</code>), rooms (<code>ws.gateway.ts:50,63-74</code>).</p> </blockquote> <hr /> <h2 id="j1--login-to-connected-all-personas"><a class="header" href="#j1--login-to-connected-all-personas">J1 — Login to connected (all personas)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Step</th><th>Actor</th><th>System (source)</th></tr></thead><tbody> <tr><td>1</td><td>User signs in; REST returns access token</td><td>Auth module (token subject/tenant/roles payload)</td></tr> <tr><td>2</td><td>Client opens socket to <code>/ws</code> with token</td><td><code>namespace: '/ws'</code> (<code>ws.gateway.ts:20-23</code>); token via <code>handshake.auth.token</code> or <code>handshake.query.token</code> (<code>ws.gateway.ts:37-39</code>)</td></tr> <tr><td>3</td><td>Server verifies token against <code>JWT_ACCESS_SECRET</code></td><td><code>ws.gateway.ts:42-44</code></td></tr> <tr><td>4</td><td>Server joins socket to <code>tenant:{tenantId}</code> room</td><td><code>ws.gateway.ts:50</code></td></tr> <tr><td>5</td><td>Client shows connected indicator; subscribes to extra rooms if needed</td><td><code>subscribe</code> event (<code>ws.gateway.ts:63-68</code>)</td></tr> <tr><td>6</td><td>Server begins delivering tenant events</td><td>bridge active (<code>ws-bridge.service.ts:23</code>)</td></tr> </tbody></table> </div> <p>Failure paths: missing token → <code>UnauthorizedException</code> → <code>client.disconnect()</code> (<code>ws.gateway.ts:40,54-56</code>). Invalid/expired token → same disconnect. The client must re-drive this journey after any reconnect.</p> <h2 id="j2--live-dashboard-update-org-admin"><a class="header" href="#j2--live-dashboard-update-org-admin">J2 — Live dashboard update (Org Admin)</a></h2> <ol> <li>Admin has dashboard open on a fee page.</li> <li>Cashier marks payment complete → <code>payments.*</code> domain event emitted (<code>EventBus.emit</code>, <code>events/event-bus.service.ts:11</code>).</li> <li><code>WsBridge.onAny</code> catches it (<code>ws-bridge.service.ts:16</code>) → <code>broadcastToTenant</code> (<code>ws.gateway.ts:76</code>) → all admin sockets in <code>tenant:{id}</code>.</li> <li>Client receives envelope <code>{eventType, occurredAt, payload}</code> (<code>ws-bridge.service.ts:17-21</code>).</li> <li>Dashboard tile animates the new total <strong>without a page reload</strong>.</li> </ol> <h2 id="j3--notification-receipt-teacher--student--parent"><a class="header" href="#j3--notification-receipt-teacher--student--parent">J3 — Notification receipt (Teacher / Student / Parent)</a></h2> <ol> <li>User is on any module screen.</li> <li>Announcement published → <code>notification.created</code> event (dot-namespace convention, <code>API_STANDARDS.md:642-658</code>).</li> <li>Toast slides in (<code>m-fast</code>, <a href="ws/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>), bell badge increments.</li> <li>User taps → navigates to the detail; list shows the item (reconciled with REST).</li> </ol> <h2 id="j4--network-flap-all-personas"><a class="header" href="#j4--network-flap-all-personas">J4 — Network flap (all personas)</a></h2> <ol> <li>WiFi drops. Socket.io client detects ping timeout (server-side heartbeat defaults).</li> <li>Client enters reconnecting state; UI shows offline banner (component in <a href="ws/07_Component_Library.html">07</a>).</li> <li>Backoff retries re-establish the connection (client-side; see <a href="ws/15_Flutter_Implementation_Guide.html">15</a>).</li> <li>On reconnect: handshake runs again — <strong>token must still be valid</strong> (see <a href="ws/14_QA_Checklist.html">14_QA_Checklist.md</a> "auth on reconnect"). If the access token expired, the client refreshes via REST and reopens the socket.</li> <li>Missed events are <strong>not replayed</strong> (fire-and-forget bridge). Client refetches affected lists via REST for eventual consistency — mandatory reconciliation rule (<a href="ws/10_Interaction_Specification.html">10</a> §6).</li> </ol> <h2 id="j5--message-delivery-planned"><a class="header" href="#j5--message-delivery-planned">J5 — Message delivery (planned)</a></h2> <ol> <li>Teacher sends message → Communication module <code>(planned)</code>, delivered via this <code>WsModule</code> (<code>docs/IMPLEMENTATION_PLAN.md:119</code>).</li> <li>Recipient sockets receive the message event; thread list updates live.</li> <li>Until Communication lands, this journey is a contract placeholder, not a feature.</li> </ol> <h2 id="j6--realtime-admin-debug-proposed"><a class="header" href="#j6--realtime-admin-debug-proposed">J6 — Realtime admin debug (proposed)</a></h2> <ol> <li>IT admin opens Realtime Debug view <code>(proposed)</code> — <a href="ws/05_Screen_Inventory.html">05</a> §5.</li> <li>Sees connection health, live event stream, room membership for own tenant.</li> <li>Uses it to verify fan-out after a deployment.</li> </ol> <hr /> <h2 id="journey-rules"><a class="header" href="#journey-rules">Journey rules</a></h2> <ul> <li>Every reconnect restarts at step 2 of J1 — there is <strong>no session resumption</strong> in source.</li> <li>No journey requires the user to act on the connection itself; failure UX is banner-level (see <a href="ws/06_Screen_Specifications.html">06</a> §1) and non-blocking.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="04--information-architecture-ws--realtime-module"><a class="header" href="#04--information-architecture-ws--realtime-module">04 — Information Architecture (WS / Realtime Module)</a></h1> <ul> <li><a href="ws/04_Information_Architecture.html#1-transport">1. Transport</a></li> <li><a href="ws/04_Information_Architecture.html#2-identity-model">2. Identity model</a></li> <li><a href="ws/04_Information_Architecture.html#3-room-model">3. Room model</a></li> <li><a href="ws/04_Information_Architecture.html#4-event-taxonomy">4. Event taxonomy</a></li> <li><a href="ws/04_Information_Architecture.html#5-consumer-mapping-web-ui">5. Consumer mapping (web UI)</a></li> <li><a href="ws/04_Information_Architecture.html#6-naming-conventions-for-the-web-client">6. Naming conventions for the web client</a></li> </ul> <blockquote> <p>The transport contract of the realtime layer: namespaces, rooms, envelope, naming. Mirrors <a href="ws/../00-shared/05_Global_Information_Architecture.html">00-shared/05</a> at the wire level; module facts here are all source-grounded.</p> </blockquote> <hr /> <h2 id="1-transport"><a class="header" href="#1-transport">1. Transport</a></h2> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Value</th><th>Source</th></tr></thead><tbody> <tr><td>Protocol</td><td>Socket.io over WebSocket (engine.io fallback transports)</td><td><code>ws.gateway.ts:10-11</code></td></tr> <tr><td>Namespace</td><td><code>/ws</code></td><td><code>ws.gateway.ts:21</code></td></tr> <tr><td>CORS</td><td><code>origin: '*'</code>, <code>credentials: true</code></td><td><code>ws.gateway.ts:22</code></td></tr> <tr><td>Payload wire format</td><td>JSON (socket.io default)</td><td>—</td></tr> <tr><td>Heartbeat</td><td>socket.io defaults (<code>pingInterval</code> 25 s / <code>pingTimeout</code> 20 s) — <strong>not tuned in source</strong></td><td>gap → <a href="ws/14_QA_Checklist.html">14_QA_Checklist.md</a></td></tr> </tbody></table> </div> <h2 id="2-identity-model"><a class="header" href="#2-identity-model">2. Identity model</a></h2> <p>A connected socket carries (source <code>ws.gateway.ts:14-18,46-48</code>):</p> <pre><code>AuthenticatedSocket { userId, tenantId, roles[] } ← from verified JWT (payload.sub, tenantId, roles) </code></pre> <ul> <li><strong>Tenant scope is fixed at connect time</strong> by the JWT (<code>ws.gateway.ts:47</code>); it is never supplied by the client on the wire. This is the security boundary of the whole module.</li> <li><code>roles[]</code> are carried but <strong>unused in routing</strong> today (gap; see <code>12</code>).</li> </ul> <h2 id="3-room-model"><a class="header" href="#3-room-model">3. Room model</a></h2> <div class="table-wrapper"><table><thead><tr><th>Room</th><th>Created</th><th>Who joins</th><th>Purpose</th><th>Source</th></tr></thead><tbody> <tr><td><code>tenant:{tenantId}</code></td><td>implicit, at connect</td><td>every socket of the tenant</td><td>tenant-wide fan-out of domain events</td><td><code>ws.gateway.ts:50</code></td></tr> <tr><td>arbitrary room string</td><td>on <code>subscribe</code></td><td>any authenticated socket</td><td>entity-level delivery (e.g. <code>class:10-A</code>, <code>user:{id}</code>)</td><td><code>ws.gateway.ts:63-68</code></td></tr> </tbody></table> </div> <p>Rules:</p> <ul> <li><code>subscribe</code>/<code>unsubscribe</code> accept <strong>any string</strong> (type-checked only: <code>typeof room !== 'string'</code> returns silently — <code>ws.gateway.ts:65,71</code>). No allow-list, no RBAC check → <strong>gap</strong> (see <code>12</code>).</li> <li>Rooms are per-connection and die with the socket; clients must re-<code>subscribe</code> after every reconnect (<code>13_State_Management.md</code>).</li> <li>There is <strong>no per-user room</strong> in source; user-scoped delivery today = tenant room + client-side filter, or an explicit <code>subscribe</code> to a convention like <code>user:{id}</code> <strong>(planned convention, not enforced)</strong>.</li> </ul> <h2 id="4-event-taxonomy"><a class="header" href="#4-event-taxonomy">4. Event taxonomy</a></h2> <p>Server→client events are <strong>domain event types</strong> relayed by the bridge, wrapped in an envelope (<code>ws-bridge.service.ts:17-21</code>):</p> <pre><code class="language-json">{ "eventType": "notification.created", "occurredAt": "2026-08-03T09:00:00.000Z", "payload": { } } </code></pre> <ul> <li>Names use <strong>dot notation</strong> — blueprint convention: <code>attendance.updated</code>, <code>payment.completed</code>, <code>notification.created</code>, <code>homework.published</code> (<code>API_STANDARDS.md:642-658</code>).</li> <li>The underlying <code>DomainEvent</code> also carries <code>actorId</code> and <code>correlationId</code> (<code>events/domain-event.interface.ts:1-8</code>) — <strong>not forwarded</strong> by the bridge today.</li> <li>Client→server events in source: exactly <code>subscribe</code> and <code>unsubscribe</code> (<code>ws.gateway.ts:63-74</code>).</li> </ul> <h2 id="5-consumer-mapping-web-ui"><a class="header" href="#5-consumer-mapping-web-ui">5. Consumer mapping (web UI)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Domain event family (examples from blueprint §28)</th><th>UI consumer</th><th>Status</th></tr></thead><tbody> <tr><td><code>notification.created</code></td><td>bell badge, toast</td><td>convention — modules to emit (planned)</td></tr> <tr><td><code>payment.completed</code></td><td>fee dashboard tiles</td><td>planned</td></tr> <tr><td><code>attendance.updated</code></td><td>attendance lists/dashboard</td><td>planned</td></tr> <tr><td><code>homework.published</code></td><td>homework lists</td><td>planned</td></tr> <tr><td><code>communication.*</code></td><td>message threads</td><td>planned (<code>IMPLEMENTATION_PLAN.md:119</code>)</td></tr> <tr><td><code>transport.*</code></td><td>live tracking</td><td>planned (<code>IMPLEMENTATION_PLAN.md:229</code>)</td></tr> <tr><td>job progress</td><td>async-op status (<code>API_STANDARDS.md:579</code>)</td><td>planned</td></tr> </tbody></table> </div> <h2 id="6-naming-conventions-for-the-web-client"><a class="header" href="#6-naming-conventions-for-the-web-client">6. Naming conventions for the web client</a></h2> <ul> <li>Channel constants in one file (<code>realtime_channels.dart</code>) — mirror server room strings.</li> <li>Event constants mirror server eventType strings 1:1 (no client-side renaming).</li> <li>Envelope is the only accepted server→client shape; anything else is a protocol violation.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="05--screen-inventory-ws--realtime-module"><a class="header" href="#05--screen-inventory-ws--realtime-module">05 — Screen Inventory (WS / Realtime Module)</a></h1> <ul> <li><a href="ws/05_Screen_Inventory.html#1-global-connection-status-indicator-on-every-authenticated-screen">1. Global Connection Status Indicator (on every authenticated screen)</a></li> <li><a href="ws/05_Screen_Inventory.html#2-notification-bell--toast-on-every-authenticated-screen">2. Notification Bell + Toast (on every authenticated screen)</a></li> <li><a href="ws/05_Screen_Inventory.html#3-live-updating-lists--dashboard-tiles-module-screens">3. Live-updating lists & dashboard tiles (module screens)</a></li> <li><a href="ws/05_Screen_Inventory.html#4-connection-status-detail-sheet-proposed">4. Connection Status Detail Sheet <code>(proposed)</code></a></li> <li><a href="ws/05_Screen_Inventory.html#5-realtime-admin-debug-view-adminrealtime-proposed">5. Realtime Admin Debug View <code>/admin/realtime</code> <code>(proposed)</code></a></li> <li><a href="ws/05_Screen_Inventory.html#component-usage-summary">Component usage summary</a></li> <li><a href="ws/05_Screen_Inventory.html#keyboard--landscape--tablet">Keyboard / landscape / tablet</a></li> </ul> <blockquote> <p>The realtime layer has <strong>no standalone screens</strong> — it manifests as connection state and live-update behavior <strong>on existing screens</strong>. This inventory is UI-context only: it enumerates every surface where realtime is visible, plus the one proposed debug screen. Authoritative components in <a href="ws/../00-shared/03_Component_Library.html">00-shared/03</a>; this file names which each surface uses.</p> </blockquote> <blockquote> <p>Legend — States: idle / connecting / connected / reconnecting / offline(disconnected) / error(auth) / stale-data. Analytics: <code>{module}.{screen}.{action}</code> (proposed; SDK open — <a href="ws/../00-shared/10_QA_Baseline.html">00-shared/10 §8</a>).</p> </blockquote> <hr /> <h2 id="1-global-connection-status-indicator-on-every-authenticated-screen"><a class="header" href="#1-global-connection-status-indicator-on-every-authenticated-screen">1. Global Connection Status Indicator (on every authenticated screen)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Show whether live updates are flowing; never block work</td></tr> <tr><td>Location</td><td>Top app bar trailing slot / app-level banner region (below app bar on mobile)</td></tr> <tr><td>Entry</td><td>auto with app shell; state from socket lifecycle</td></tr> <tr><td>Exit</td><td>none (persistent); tap → status sheet (§4) on desktop</td></tr> <tr><td>Components</td><td><code>AppRealtimeStatusDot</code>, <code>AppConnectionBanner</code> (offline only), <code>AppSnackbar</code></td></tr> <tr><td>States</td><td>connected (dot, tooltip "Live"), connecting, reconnecting (pulsing dot), offline (banner + dot), error-auth (banner: "Session expired — reconnecting")</td></tr> <tr><td>Analytics</td><td><code>app.ws.{connect,reconnect,offline,error}</code> (proposed)</td></tr> <tr><td>a11y</td><td>status announced via live region on state change; dot carries semantic label, never color-only</td></tr> <tr><td>Motion</td><td><code>m-fast</code> fade/pulse (<a href="ws/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>)</td></tr> </tbody></table> </div> <h2 id="2-notification-bell--toast-on-every-authenticated-screen"><a class="header" href="#2-notification-bell--toast-on-every-authenticated-screen">2. Notification Bell + Toast (on every authenticated screen)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Surface <code>notification.created</code> without navigating</td></tr> <tr><td>Entry</td><td>app shell</td></tr> <tr><td>Behavior</td><td>bell badge increments on event; toast (snackbar) shows for high-priority types only</td></tr> <tr><td>Exit</td><td>tap bell → notifications list (REST); tap toast → target route</td></tr> <tr><td>Components</td><td><code>AppBadge</code>, <code>AppSnackbar</code>, <code>AppListTile</code></td></tr> <tr><td>States</td><td>idle, toast-expanded, badge-count overflow (99+)</td></tr> <tr><td>Analytics</td><td><code>notif.received.{type}</code>, <code>notif.tap</code> (proposed)</td></tr> </tbody></table> </div> <h2 id="3-live-updating-lists--dashboard-tiles-module-screens"><a class="header" href="#3-live-updating-lists--dashboard-tiles-module-screens">3. Live-updating lists & dashboard tiles (module screens)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Refresh list rows / KPI tiles in place when a matching event arrives</td></tr> <tr><td>Host screens</td><td>Dashboard overview, Fees, Attendance, Homework, Results, CRM (per module design docs)</td></tr> <tr><td>Pattern</td><td><code>AppLiveList</code> wrapper (component in <a href="ws/07_Component_Library.html">07</a>); event → update row by id, else schedule REST refetch</td></tr> <tr><td>States</td><td>idle, live-updating (row flash <code>m-fast</code>), stale (offline banner, <code>AppSkeleton</code> for loading)</td></tr> <tr><td>Rule</td><td>never mutate a list while the user is editing within it (see <a href="ws/10_Interaction_Specification.html">10</a> §2)</td></tr> </tbody></table> </div> <h2 id="4-connection-status-detail-sheet-proposed"><a class="header" href="#4-connection-status-detail-sheet-proposed">4. Connection Status Detail Sheet <code>(proposed)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Show user-facing diagnostics: state, server time delta, last event received</td></tr> <tr><td>Entry</td><td>tap status dot (desktop) / long-press (mobile)</td></tr> <tr><td>Content</td><td>state chip, "last event: <code>{eventType}</code> <code>{occurredAt}</code>", "reconnect attempt #n", settings shortcut</td></tr> <tr><td>Components</td><td><code>AppBottomSheet</code>, <code>AppListTile</code>, <code>AppChips</code></td></tr> <tr><td>Analytics</td><td><code>app.ws.status_open</code> (proposed)</td></tr> </tbody></table> </div> <h2 id="5-realtime-admin-debug-view-adminrealtime-proposed"><a class="header" href="#5-realtime-admin-debug-view-adminrealtime-proposed">5. Realtime Admin Debug View <code>/admin/realtime</code> <code>(proposed)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>IT/admin verification: see the event stream and connection health for the tenant</td></tr> <tr><td>Route</td><td><code>/admin/realtime</code> (admin-gated; <code>rbac</code> perms — none exist yet for ws, see <a href="ws/12_API_Mapping.html">12</a>)</td></tr> <tr><td>Content</td><td>live event log (eventType, occurredAt, payload size), socket count, room list, fan-out errors; clear/pause controls (client-side pause only)</td></tr> <tr><td>Components</td><td><code>AppEventLog</code>, <code>AppDataTable</code>, <code>AppSegmentedButton</code></td></tr> <tr><td>States</td><td>live, paused, empty ("waiting for events…"), filtered by eventType</td></tr> <tr><td>Analytics</td><td><code>admin.ws.{open,pause,filter}</code> (proposed)</td></tr> <tr><td>Note</td><td>Server-side counterpart (per-socket visibility) is <strong>forward-looking</strong></td></tr> </tbody></table> </div> <hr /> <h2 id="component-usage-summary"><a class="header" href="#component-usage-summary">Component usage summary</a></h2> <p><code>AppRealtimeStatusDot</code>, <code>AppConnectionBanner</code>, <code>AppLiveList</code>, <code>AppEventLog</code>, <code>AppBadge</code>, <code>AppSnackbar</code>, <code>AppBottomSheet</code>, <code>AppListTile</code>, <code>AppSkeleton</code>, <code>AppEmptyState</code>. Module-specific: <code>AppRealtimeStatusDot</code> and <code>AppLiveList</code> are defined in <a href="ws/07_Component_Library.html">07_Component_Library.md</a>.</p> <h2 id="keyboard--landscape--tablet"><a class="header" href="#keyboard--landscape--tablet">Keyboard / landscape / tablet</a></h2> <ul> <li>Status dot + banner are fixed-position surfaces: safe-area inset aware, never overlap scroll content; landscape keeps banner ≤ 1 line.</li> <li>Debug view: tablet/desktop two-column (log + metrics); mobile single column.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="06--screen-specifications-ws--realtime-module"><a class="header" href="#06--screen-specifications-ws--realtime-module">06 — Screen Specifications (WS / Realtime Module)</a></h1> <ul> <li><a href="ws/06_Screen_Specifications.html#1-connection-status-indicator">§1 Connection Status Indicator</a> <ul> <li><a href="ws/06_Screen_Specifications.html#11-placement--anatomy">1.1 Placement & anatomy</a></li> <li><a href="ws/06_Screen_Specifications.html#12-state-machine-client">1.2 State machine (client)</a></li> <li><a href="ws/06_Screen_Specifications.html#13-rules">1.3 Rules</a></li> <li><a href="ws/06_Screen_Specifications.html#14-source-grounding">1.4 Source grounding</a></li> </ul> </li> <li><a href="ws/06_Screen_Specifications.html#2-notification-bell--toast">§2 Notification Bell + Toast</a> <ul> <li><a href="ws/06_Screen_Specifications.html#21-bell-badge">2.1 Bell badge</a></li> <li><a href="ws/06_Screen_Specifications.html#22-toast">2.2 Toast</a></li> <li><a href="ws/06_Screen_Specifications.html#23-edge-cases">2.3 Edge cases</a></li> </ul> </li> <li><a href="ws/06_Screen_Specifications.html#3-live-updating-lists--dashboard-tiles">§3 Live-updating Lists & Dashboard Tiles</a> <ul> <li><a href="ws/06_Screen_Specifications.html#31-the-applivelist-contract">3.1 The <code>AppLiveList</code> contract</a></li> <li><a href="ws/06_Screen_Specifications.html#32-per-screen-event-mapping-proposed-defaults-align-with-each-module-doc">3.2 Per-screen event mapping (proposed defaults; align with each module doc)</a></li> <li><a href="ws/06_Screen_Specifications.html#33-rules">3.3 Rules</a></li> </ul> </li> <li><a href="ws/06_Screen_Specifications.html#4-connection-status-detail-sheet-proposed">§4 Connection Status Detail Sheet <code>(proposed)</code></a></li> <li><a href="ws/06_Screen_Specifications.html#5-realtime-admin-debug-view-adminrealtime-proposed">§5 Realtime Admin Debug View <code>/admin/realtime</code> <code>(proposed)</code></a> <ul> <li><a href="ws/06_Screen_Specifications.html#51-layout">5.1 Layout</a></li> <li><a href="ws/06_Screen_Specifications.html#52-behavior">5.2 Behavior</a></li> <li><a href="ws/06_Screen_Specifications.html#53-notes">5.3 Notes</a></li> </ul> </li> <li><a href="ws/06_Screen_Specifications.html#6-cross-cutting-specifications">§6 Cross-cutting specifications</a></li> </ul> <blockquote> <p>Detailed specification of every realtime surface from <a href="ws/05_Screen_Inventory.html">05</a>. 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.</p> </blockquote> <hr /> <h2 id="1-connection-status-indicator"><a class="header" href="#1-connection-status-indicator">§1 Connection Status Indicator</a></h2> <h3 id="11-placement--anatomy"><a class="header" href="#11-placement--anatomy">1.1 Placement & anatomy</a></h3> <ul> <li>Top app bar trailing slot on mobile; app-bar region on desktop; offline state promotes to a full-width <code>AppConnectionBanner</code> under the app bar (see <a href="ws/../00-shared/03_Component_Library.html">00-shared/03</a> for primitives).</li> <li>Contains: status dot (8 dp) + optional label "Live" / "Offline — retrying…".</li> </ul> <h3 id="12-state-machine-client"><a class="header" href="#12-state-machine-client">1.2 State machine (client)</a></h3> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Trigger</th><th>Visual</th><th>Behavior</th></tr></thead><tbody> <tr><td><code>idle</code></td><td>app start, before first socket open</td><td>dot grey</td><td>nothing</td></tr> <tr><td><code>connecting</code></td><td>socket open initiated</td><td>dot amber, pulsing</td><td>no banner</td></tr> <tr><td><code>connected</code></td><td>handshake OK — server joined tenant room (<code>ws.gateway.ts:50</code>)</td><td>dot green</td><td>tooltip "Live"; banner hidden</td></tr> <tr><td><code>reconnecting</code></td><td>socket.io reconnect attempt</td><td>dot amber pulse</td><td>banner only after 1st failed attempt</td></tr> <tr><td><code>offline</code></td><td>heartbeat loss / transport error, retries exhausted</td><td>dot red + banner "Live updates paused"</td><td>manual "Retry now" affordance + auto backoff continues</td></tr> <tr><td><code>error-auth</code></td><td>handshake rejected → server <code>client.disconnect()</code> (<code>ws.gateway.ts:54-56</code>)</td><td>dot red + banner "Session expired"</td><td>client refreshes access token via REST, reopens socket; if refresh fails → sign-out flow</td></tr> </tbody></table> </div> <h3 id="13-rules"><a class="header" href="#13-rules">1.3 Rules</a></h3> <ul> <li>Status is <strong>global</strong> (one app-level state), never per-screen.</li> <li>No modal ever blocks on connection state; the layer is passive (see <a href="ws/09_User_Behaviour.html">09</a>).</li> <li>Stale-data rule: while <code>offline</code>/<code>reconnecting</code>, any screen showing live-fed values renders the offline banner; values are not cleared (graceful degradation).</li> <li>a11y: every state change announces via <code>SemanticsService.liveRegion</code>; dot never color-only (<a href="ws/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>).</li> </ul> <h3 id="14-source-grounding"><a class="header" href="#14-source-grounding">1.4 Source grounding</a></h3> <ul> <li>Auth failure path: missing token → <code>UnauthorizedException('Missing token')</code>, catch → <code>client.disconnect()</code> (<code>ws.gateway.ts:37-40,54-56</code>).</li> <li>No server session resumption: reconnect = full re-handshake (see <a href="ws/03_User_Journey.html">03</a> J4).</li> </ul> <hr /> <h2 id="2-notification-bell--toast"><a class="header" href="#2-notification-bell--toast">§2 Notification Bell + Toast</a></h2> <h3 id="21-bell-badge"><a class="header" href="#21-bell-badge">2.1 Bell badge</a></h3> <ul> <li>Badge counts <code>notification.created</code> events since last list open (client-side count).</li> <li>Cap display at <code>99+</code>.</li> <li>On bell open → fetch list via REST (authoritative) and reset count — <strong>events are ephemeral; the list is source of truth</strong> (bridge is fire-and-forget, <code>ws-bridge.service.ts:16-22</code>).</li> </ul> <h3 id="22-toast"><a class="header" href="#22-toast">2.2 Toast</a></h3> <ul> <li>Shown for notification types marked high-priority (module config, <code>(planned)</code> — the notifications module owns priority semantics, <code>IMPLEMENTATION_PLAN.md:231</code>).</li> <li>One toast at a time; queue others; auto-dismiss 5 s; tap → navigate to target route.</li> <li>While the user is typing/editing: toast never steals focus (see <a href="ws/10_Interaction_Specification.html">10</a> §2).</li> </ul> <h3 id="23-edge-cases"><a class="header" href="#23-edge-cases">2.3 Edge cases</a></h3> <div class="table-wrapper"><table><thead><tr><th>Case</th><th>Behavior</th></tr></thead><tbody> <tr><td>Duplicate event (multi-tab, <a href="ws/02_User_Personas.html">02</a> §cross-persona)</td><td>dedupe by <code>payload.{entityId}+eventType</code> within a short window; single toast</td></tr> <tr><td>Event for soft-deleted entity</td><td>toast still shows; tap → REST 404 → snackbar "No longer available"</td></tr> <tr><td>Arrived while offline</td><td>not delivered (no replay); bell reconciled on REST fetch</td></tr> <tr><td>Malformed envelope</td><td>ignore + log client-side (defensive; see <a href="ws/14_QA_Checklist.html">14</a> §Q8)</td></tr> </tbody></table> </div> <hr /> <h2 id="3-live-updating-lists--dashboard-tiles"><a class="header" href="#3-live-updating-lists--dashboard-tiles">§3 Live-updating Lists & Dashboard Tiles</a></h2> <h3 id="31-the-applivelist-contract"><a class="header" href="#31-the-applivelist-contract">3.1 The <code>AppLiveList</code> contract</a></h3> <p>Applies to: Dashboard overview, Fees, Attendance, Homework, Results, CRM lists.</p> <ol> <li>Screen subscribes (via <code>RealtimeClient</code> stream, <a href="ws/13_State_Management.html">13</a>) to the event families it renders.</li> <li>On event: match <code>payload.entityId</code> against loaded rows. <ul> <li><strong>Hit</strong> → update that row in place (<code>m-fast</code> highlight flash).</li> <li><strong>Miss</strong> (not loaded or filtered out) → decide by screen rule: if the entity belongs to the current filter, insert; else ignore.</li> </ul> </li> <li>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).</li> <li>Sorting/aggregation (e.g. dashboard totals) → recompute locally, do <strong>not</strong> refetch.</li> <li>Offline state: keep last known data, banner on; on reconnect → one refetch per visible live screen (eventual consistency, <a href="ws/03_User_Journey.html">03</a> J4 step 5).</li> </ol> <h3 id="32-per-screen-event-mapping-proposed-defaults-align-with-each-module-doc"><a class="header" href="#32-per-screen-event-mapping-proposed-defaults-align-with-each-module-doc">3.2 Per-screen event mapping (proposed defaults; align with each module doc)</a></h3> <div class="table-wrapper"><table><thead><tr><th>Screen</th><th>Events to render</th><th>Action</th></tr></thead><tbody> <tr><td>Dashboard — fees tile</td><td><code>payment.completed</code></td><td>increment collected total</td></tr> <tr><td>Dashboard — attendance tile</td><td><code>attendance.updated</code></td><td>refresh today's %</td></tr> <tr><td>Fees — invoice list</td><td><code>payment.*</code></td><td>row status update</td></tr> <tr><td>Attendance — today list</td><td><code>attendance.updated</code></td><td>update rows by studentId</td></tr> <tr><td>Homework — list</td><td><code>homework.published</code></td><td>insert row at top</td></tr> <tr><td>CRM — leads</td><td><code>crm.lead.*</code></td><td>row/status update</td></tr> </tbody></table> </div> <h3 id="33-rules"><a class="header" href="#33-rules">3.3 Rules</a></h3> <ul> <li>Live events <strong>never</strong> replace the primary load; they mutate an existing snapshot.</li> <li>Never write user-visible "confetti" or full-screen overlays on events — <a href="ws/09_User_Behaviour.html">09</a> keeps the layer passive.</li> <li>Pull-to-refresh stays available and forces a REST refetch regardless of socket state.</li> </ul> <hr /> <h2 id="4-connection-status-detail-sheet-proposed-1"><a class="header" href="#4-connection-status-detail-sheet-proposed-1">§4 Connection Status Detail Sheet <code>(proposed)</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Spec</th></tr></thead><tbody> <tr><td>Entry</td><td>tap status dot (desktop) / long-press dot (mobile)</td></tr> <tr><td>Content</td><td>state chip; "last event: <code>{eventType}</code> at <code>{occurredAt}</code>" (from last received envelope, <code>ws-bridge.service.ts:17-21</code>); reconnect attempt count; server clock delta</td></tr> <tr><td>Actions</td><td>"Retry now" (if offline), "Open debug view" (admin only, §5)</td></tr> <tr><td>Empty</td><td>—</td></tr> <tr><td>Motion</td><td>sheet slides <code>m-base</code>; content fades in <code>m-fast</code></td></tr> <tr><td>a11y</td><td>sheet is a dialog region; focus first action</td></tr> </tbody></table> </div> <hr /> <h2 id="5-realtime-admin-debug-view-adminrealtime-proposed-1"><a class="header" href="#5-realtime-admin-debug-view-adminrealtime-proposed-1">§5 Realtime Admin Debug View <code>/admin/realtime</code> <code>(proposed)</code></a></h2> <h3 id="51-layout-2"><a class="header" href="#51-layout-2">5.1 Layout</a></h3> <ul> <li>Header: connection state + socket count for this tenant (server-provided — see note), pause/resume toggle, eventType filter chips.</li> <li>Body: <code>AppEventLog</code> — monospace rows: <code>time | eventType | payload.size | entityId</code>.</li> <li>Footer: session token expiry countdown (client-side check against <code>JWT_ACCESS_SECRET</code> expiry from the auth store).</li> </ul> <h3 id="52-behavior"><a class="header" href="#52-behavior">5.2 Behavior</a></h3> <ul> <li>Log buffers last 500 events client-side; pause stops render (not capture).</li> <li>Filter by eventType substring; clear button.</li> <li>Row tap → payload pretty-printed in a bottom sheet.</li> </ul> <h3 id="53-notes"><a class="header" href="#53-notes">5.3 Notes</a></h3> <ul> <li><strong>Client-side only</strong> today: it renders what the socket receives. Server-side metrics (per-connection views, room members) are <strong>forward-looking</strong> — the gateway logs connect/disconnect only (<code>ws.gateway.ts:51-53,59-61</code>).</li> <li>Gate with admin role: reuse existing RBAC admin permission (<code>rbac</code> module); no <code>ws.*</code> permission exists yet (<code>permissions.constants.ts:1-97</code> — verified absent, see <a href="ws/12_API_Mapping.html">12</a>).</li> </ul> <hr /> <h2 id="6-cross-cutting-specifications"><a class="header" href="#6-cross-cutting-specifications">§6 Cross-cutting specifications</a></h2> <div class="table-wrapper"><table><thead><tr><th>Topic</th><th>Spec</th></tr></thead><tbody> <tr><td>Event ordering</td><td>socket.io preserves per-connection order; across reconnects there is no ordering guarantee — reconcile with REST</td></tr> <tr><td>Envelope validation</td><td>client must validate <code>{eventType: string, occurredAt: string, payload: object}</code>; reject anything else (<code>ws-bridge.service.ts:17-21</code>)</td></tr> <tr><td>Token refresh mid-session</td><td>before reconnect, check access-token expiry; refresh via REST (<code>/auth/refresh</code>) then open socket (source: handshake requires valid token, <code>ws.gateway.ts:42-44</code>)</td></tr> <tr><td>Rooms to re-join on reconnect</td><td>tenant room is automatic (<code>ws.gateway.ts:50</code>); extra rooms must be re-<code>subscribe</code>d by client (<code>ws.gateway.ts:63-68</code>)</td></tr> <tr><td>Multi-tab</td><td>one socket per tab; dedupe events by envelope (eventType + correlationId — note correlationId is NOT forwarded; use entityId until server forwards it)</td></tr> <tr><td>Background tab</td><td>socket stays; if OS suspends, reconnect flow handles it; badge reconciled on resume</td></tr> <tr><td>Debug/data</td><td>never log payload contents; log eventType + occurredAt only (<a href="ws/10_Interaction_Specification.html">10</a> §8)</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="07--component-library-ws--realtime-module"><a class="header" href="#07--component-library-ws--realtime-module">07 — Component Library (WS / Realtime Module)</a></h1> <ul> <li><a href="ws/07_Component_Library.html#1-apprealtimestatusdot">1. <code>AppRealtimeStatusDot</code></a></li> <li><a href="ws/07_Component_Library.html#2-appconnectionbanner">2. <code>AppConnectionBanner</code></a></li> <li><a href="ws/07_Component_Library.html#3-applivelist">3. <code>AppLiveList</code></a></li> <li><a href="ws/07_Component_Library.html#4-appnotificationtoast">4. <code>AppNotificationToast</code></a></li> <li><a href="ws/07_Component_Library.html#5-appeventlog-debug-proposed">5. <code>AppEventLog</code> (debug, proposed)</a></li> <li><a href="ws/07_Component_Library.html#6-realtimeclient-non-visual-core">6. <code>RealtimeClient</code> (non-visual core)</a></li> <li><a href="ws/07_Component_Library.html#composition-rules">Composition rules</a></li> </ul> <blockquote> <p>Components the realtime layer introduces. Base primitives come from <a href="ws/../00-shared/03_Component_Library.html">00-shared/03</a>; these are module-level compositions with the wire contract baked in. Shared design-system mapping lives in <a href="ws/11_Design_System_Mapping.html">11</a> and <a href="ws/../00-shared/04_Design_System_Mapping.html">00-shared/04</a>.</p> </blockquote> <hr /> <h2 id="1-apprealtimestatusdot"><a class="header" href="#1-apprealtimestatusdot">1. <code>AppRealtimeStatusDot</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>One-glance connection state, non-interactive by default</td></tr> <tr><td>Props</td><td><code>state: RealtimeState</code> (idle/connecting/connected/reconnecting/offline/error-auth)</td></tr> <tr><td>Anatomy</td><td>8 dp circle + optional 12 dp label</td></tr> <tr><td>Colors</td><td>from <a href="ws/../00-shared/02_Design_Tokens.html">00-shared/02</a> status tokens (success/amber-warning/danger/neutral)</td></tr> <tr><td>Behavior</td><td>pulse animation while connecting/reconnecting (<code>m-fast</code>); tooltip "Live" when connected</td></tr> <tr><td>a11y</td><td>semantic label from state text; never color-only (<a href="ws/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>)</td></tr> <tr><td>Wire-up</td><td>consumes <code>RealtimeBloc</code> state (<a href="ws/13_State_Management.html">13</a>); no direct socket access</td></tr> </tbody></table> </div> <h2 id="2-appconnectionbanner"><a class="header" href="#2-appconnectionbanner">2. <code>AppConnectionBanner</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Non-blocking offline/error surface under the app bar</td></tr> <tr><td>Props</td><td><code>state</code> (offline / error-auth), <code>onRetry</code> (optional)</td></tr> <tr><td>Anatomy</td><td>1-line banner: icon + message + optional "Retry" text button</td></tr> <tr><td>States</td><td>offline: "Live updates paused"; error-auth: "Session expired — reconnecting"</td></tr> <tr><td>Motion</td><td>slide-in <code>m-fast</code>; never blocks scroll content (fixed slot)</td></tr> <tr><td>a11y</td><td>live-region announcement on show/hide</td></tr> <tr><td>Rules</td><td>shown only in <code>offline</code>/<code>error-auth</code>; hidden otherwise (no persistent banner)</td></tr> </tbody></table> </div> <h2 id="3-applivelist"><a class="header" href="#3-applivelist">3. <code>AppLiveList</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Wrapper that merges socket events into an existing paginated list (<a href="ws/06_Screen_Specifications.html">06</a> §3.1)</td></tr> <tr><td>Props</td><td><code>items</code>, <code>eventStream</code> (filtered), <code>entityIdOf(item)</code>, <code>onRowEvent(item, event)</code>, <code>refetch</code></td></tr> <tr><td>Behavior</td><td>row-level in-place update + flash; miss → optional insert or bounded refetch; sort/aggregate locally</td></tr> <tr><td>Limits</td><td>bounded refetch: 1 / 5 s / screen; never mutates during user edit (<a href="ws/10_Interaction_Specification.html">10</a> §2)</td></tr> <tr><td>Reuse</td><td>wraps <a href="ws/../00-shared/03_Component_Library.html">00-shared/03</a> list + skeleton primitives</td></tr> <tr><td>Testing</td><td>pure Dart logic (match/insert/refetch) — unit-testable without socket</td></tr> </tbody></table> </div> <h2 id="4-appnotificationtoast"><a class="header" href="#4-appnotificationtoast">4. <code>AppNotificationToast</code></a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Transient high-priority notification surface</td></tr> <tr><td>Props</td><td><code>event</code> (envelope), <code>onTap</code></td></tr> <tr><td>Anatomy</td><td><code>AppSnackbar</code>-based: icon + title + entity snippet; queueing; 5 s auto-dismiss</td></tr> <tr><td>Dedup</td><td>by <code>eventType+entityId</code> within 30 s window (multi-tab, <a href="ws/02_User_Personas.html">02</a>)</td></tr> <tr><td>Rules</td><td>never steals focus; max 1 visible</td></tr> </tbody></table> </div> <h2 id="5-appeventlog-debug-proposed"><a class="header" href="#5-appeventlog-debug-proposed">5. <code>AppEventLog</code> (debug, proposed)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Monospace stream of received envelopes for the admin debug view (<a href="ws/06_Screen_Specifications.html">06</a> §5)</td></tr> <tr><td>Props</td><td><code>events</code> (last 500), <code>filter</code></td></tr> <tr><td>Anatomy</td><td>rows: `HH:mm:ss.SSS</td></tr> <tr><td>Behavior</td><td>pause render (not capture); clear; substring filter</td></tr> <tr><td>Rules</td><td>debug-only; guarded by admin route gate</td></tr> </tbody></table> </div> <h2 id="6-realtimeclient-non-visual-core"><a class="header" href="#6-realtimeclient-non-visual-core">6. <code>RealtimeClient</code> (non-visual core)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody> <tr><td>Purpose</td><td>Owns the socket lifecycle, handshake, room membership, event stream (<a href="ws/13_State_Management.html">13</a>)</td></tr> <tr><td>API</td><td><code>connect(token)</code>, <code>disconnect()</code>, <code>subscribe(room)</code>, <code>unsubscribe(room)</code>, <code>Stream<WsEnvelope> events</code>, <code>RealtimeState state</code></td></tr> <tr><td>Contract</td><td>handshake token → <code>auth.token</code> or <code>query.token</code> (<code>ws.gateway.ts:37-39</code>); subscribe message = room string (<code>ws.gateway.ts:63-68</code>)</td></tr> <tr><td>Security</td><td>token from secure store only; never logged (<a href="ws/15_Flutter_Implementation_Guide.html">15</a> §8)</td></tr> <tr><td>Reuse</td><td>one instance per app run (singleton scope), injected via DI</td></tr> </tbody></table> </div> <hr /> <h2 id="composition-rules-1"><a class="header" href="#composition-rules-1">Composition rules</a></h2> <ul> <li>Visual components never import the socket library; they consume <code>RealtimeClient</code>/ <code>RealtimeBloc</code> streams only.</li> <li><code>AppLiveList</code> is the only list component aware of events; plain lists stay REST-only.</li> <li>All components work with zero server changes — they render the existing bridge envelope (<code>ws-bridge.service.ts:17-21</code>).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="08--form-specifications-ws--realtime-module"><a class="header" href="#08--form-specifications-ws--realtime-module">08 — Form Specifications (WS / Realtime Module)</a></h1> <ul> <li><a href="ws/08_Form_Specifications.html#1-handshake-inputs-not-a-form-but-a-contract">1. Handshake inputs (not a form, but a contract)</a></li> <li><a href="ws/08_Form_Specifications.html#2-clientserver-messages">2. Client→server messages</a></li> <li><a href="ws/08_Form_Specifications.html#3-what-the-client-must-validate-proposed">3. What the client must validate (proposed)</a></li> <li><a href="ws/08_Form_Specifications.html#4-serverclient-responses-informational-not-form-errors">4. Server→client "responses" (informational, not form errors)</a></li> <li><a href="ws/08_Form_Specifications.html#5-future-wire-inputs-planned">5. Future wire inputs (planned)</a></li> </ul> <blockquote> <p>The realtime module has <strong>no forms</strong> — 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: <a href="ws/../00-shared/07_API_Conventions.html">00-shared/07</a> (API conventions) and <a href="ws/../00-shared/03_Component_Library.html">00-shared/03</a> (form primitives).</p> </blockquote> <hr /> <h2 id="1-handshake-inputs-not-a-form-but-a-contract"><a class="header" href="#1-handshake-inputs-not-a-form-but-a-contract">1. Handshake inputs (not a form, but a contract)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Input</th><th>Where</th><th>Rules</th><th>Source</th></tr></thead><tbody> <tr><td>Access token</td><td><code>handshake.auth.token</code> <strong>or</strong> <code>handshake.query.token</code></td><td>required; JWT verified against <code>JWT_ACCESS_SECRET</code>; missing → <code>UnauthorizedException('Missing token')</code>; any verify failure → socket closed</td><td><code>ws.gateway.ts:37-44,54-56</code></td></tr> <tr><td>Namespace</td><td>URL path <code>/ws</code></td><td>fixed</td><td><code>ws.gateway.ts:20-23</code></td></tr> </tbody></table> </div> <p>Client rules:</p> <ul> <li>Prefer <code>auth.token</code> (not in URL/logs); <code>query.token</code> exists for non-engine.io clients.</li> <li>Token must be the same family as REST (<code>JWT_ACCESS_SECRET</code>), so refresh flow is shared.</li> <li>No other handshake fields (no tenantId — server takes it from the token, <code>ws.gateway.ts:47</code>).</li> </ul> <h2 id="2-clientserver-messages"><a class="header" href="#2-clientserver-messages">2. Client→server messages</a></h2> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Payload</th><th>Validation (source)</th><th>Effect</th></tr></thead><tbody> <tr><td><code>subscribe</code></td><td><code>room: string</code></td><td><code>typeof room !== 'string'</code> → silently ignored (<code>ws.gateway.ts:65</code>)</td><td>join room → future <code>broadcastToRoom</code> delivery (<code>ws.gateway.ts:80-82</code>)</td></tr> <tr><td><code>unsubscribe</code></td><td><code>room: string</code></td><td><code>typeof room !== 'string'</code> → silently ignored (<code>ws.gateway.ts:71</code>)</td><td>leave room</td></tr> </tbody></table> </div> <p>Semantics:</p> <ul> <li>Idempotent: subscribing twice is safe (socket.io joins are idempotent).</li> <li>No acknowledgment message is sent (server does not reply — no ack/error channel in source; verified: gateway handlers return <code>void</code>).</li> <li>No validation of <em>room name content</em>: any string accepted → <strong>gap</strong> (allow-list + RBAC recommended, <a href="ws/12_API_Mapping.html">12</a> §5).</li> </ul> <h2 id="3-what-the-client-must-validate-proposed"><a class="header" href="#3-what-the-client-must-validate-proposed">3. What the client must validate (proposed)</a></h2> <ul> <li><code>room</code> length ≤ 128; only <code>[a-z0-9:_-]</code> charset enforced client-side to avoid injecting reserved namespaces; server-side enforcement is the real fix (gap).</li> <li>Never subscribe to <code>tenant:{...}</code> explicitly (automatic — duplicate is harmless).</li> </ul> <h2 id="4-serverclient-responses-informational-not-form-errors"><a class="header" href="#4-serverclient-responses-informational-not-form-errors">4. Server→client "responses" (informational, not form errors)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Situation</th><th>Server behavior</th><th>Client handling</th></tr></thead><tbody> <tr><td>Bad/missing token</td><td>close connection (no error event)</td><td>state <code>error-auth</code> → refresh token → reconnect (<a href="ws/06_Screen_Specifications.html">06</a> §1.2)</td></tr> <tr><td>Successful connect</td><td>joins <code>tenant:{tenantId}</code> (<code>ws.gateway.ts:50</code>)</td><td>state <code>connected</code></td></tr> <tr><td>Event delivery</td><td>envelope <code>{eventType, occurredAt, payload}</code> (<code>ws-bridge.service.ts:17-21</code>)</td><td>validate envelope, route to blocs (<a href="ws/13_State_Management.html">13</a>)</td></tr> <tr><td>Unknown client event name</td><td>ignored (no handler registered)</td><td>n/a (client only sends <code>subscribe</code>/<code>unsubscribe</code>)</td></tr> </tbody></table> </div> <h2 id="5-future-wire-inputs-planned"><a class="header" href="#5-future-wire-inputs-planned">5. Future wire inputs (planned)</a></h2> <ul> <li>Communication module will define its own client→server messages (send/typing/read receipts) <code>(planned)</code> — <code>docs/IMPLEMENTATION_PLAN.md:119</code>. Until then the client MUST NOT send free-form messages.</li> <li>Event-subscription filters (per-eventType) <code>(planned)</code>; today a client receives the whole tenant stream and filters locally (<a href="ws/06_Screen_Specifications.html">06</a> §6).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="09--user-behaviour-ws--realtime-module"><a class="header" href="#09--user-behaviour-ws--realtime-module">09 — User Behaviour (WS / Realtime Module)</a></h1> <ul> <li><a href="ws/09_User_Behaviour.html#1-users-are-passive-consumers">1. Users are passive consumers</a></li> <li><a href="ws/09_User_Behaviour.html#2-tolerance-windows">2. Tolerance windows</a></li> <li><a href="ws/09_User_Behaviour.html#3-notification-consumption">3. Notification consumption</a></li> <li><a href="ws/09_User_Behaviour.html#4-editing-vs-live-mutation-conflict">4. Editing vs. live mutation conflict</a></li> <li><a href="ws/09_User_Behaviour.html#5-admindebug-behaviour-proposed">5. Admin/debug behaviour (proposed)</a></li> <li><a href="ws/09_User_Behaviour.html#6-multi-device-reality">6. Multi-device reality</a></li> <li><a href="ws/09_User_Behaviour.html#7-connection-affecting-behaviours">7. Connection-affecting behaviours</a></li> <li><a href="ws/09_User_Behaviour.html#8-trust-rules">8. Trust rules</a></li> </ul> <blockquote> <p>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: <a href="ws/02_User_Personas.html">02</a>; journeys: <a href="ws/03_User_Journey.html">03</a>.</p> </blockquote> <hr /> <h2 id="1-users-are-passive-consumers"><a class="header" href="#1-users-are-passive-consumers">1. Users are passive consumers</a></h2> <ul> <li>The only explicit user actions tied to realtime: pull-to-refresh (reconciliation), tapping a toast, opening the status sheet <code>(proposed)</code>, and — offline — tapping "Retry now". Everything else is ambient.</li> <li>Consequence: <strong>no screen may be designed around realtime being present.</strong> Every surface must be fully functional with the socket dead (REST fallback). Realtime is an enhancement layer, never a dependency for rendering.</li> </ul> <h2 id="2-tolerance-windows"><a class="header" href="#2-tolerance-windows">2. Tolerance windows</a></h2> <div class="table-wrapper"><table><thead><tr><th>Behaviour</th><th>Observed pattern</th><th>Rule</th></tr></thead><tbody> <tr><td>Brief reconnect (≤ 5 s)</td><td>users don't notice</td><td>no banner needed until first retry fails</td></tr> <tr><td>Sustained offline (> 10 s)</td><td>users expect an indicator and a path to fix</td><td>banner + status sheet</td></tr> <tr><td>Stale numbers</td><td>users distrust the screen and reload</td><td>offline banner must say updates are paused (<a href="ws/06_Screen_Specifications.html">06</a> §1.3)</td></tr> <tr><td>Missed event while offline</td><td>users notice only when list looks wrong</td><td>reconcile on reconnect (<a href="ws/06_Screen_Specifications.html">06</a> §3.1)</td></tr> </tbody></table> </div> <h2 id="3-notification-consumption"><a class="header" href="#3-notification-consumption">3. Notification consumption</a></h2> <ul> <li>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).</li> <li>Duplicate toasts (multi-tab) are actively confusing — dedupe is a correctness rule (<a href="ws/06_Screen_Specifications.html">06</a> §2.3), not polish.</li> </ul> <h2 id="4-editing-vs-live-mutation-conflict"><a class="header" href="#4-editing-vs-live-mutation-conflict">4. Editing vs. live mutation conflict</a></h2> <ul> <li>A user mid-edit (typing a fee entry, editing a mark) reacts badly to rows re-ordering or inputs changing underneath them. <code>AppLiveList</code> must defer mutation while the screen has an active edit session (<a href="ws/10_Interaction_Specification.html">10</a> §2).</li> <li>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.</li> </ul> <h2 id="5-admindebug-behaviour-proposed"><a class="header" href="#5-admindebug-behaviour-proposed">5. Admin/debug behaviour (proposed)</a></h2> <ul> <li>IT admins open the debug view after deploys and during incident reports; they expect a <strong>pauseable</strong> stream and filter, not a wall of text. They will screenshot it — payloads must be redactable (no PII in payload render, <a href="ws/10_Interaction_Specification.html">10</a> §8).</li> </ul> <h2 id="6-multi-device-reality"><a class="header" href="#6-multi-device-reality">6. Multi-device reality</a></h2> <ul> <li>Same user, two tabs / two devices: both receive tenant broadcasts (<code>ws.gateway.ts:50,76-78</code>). Users accept this; they do not accept <em>double toasts</em> — 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.</li> </ul> <h2 id="7-connection-affecting-behaviours"><a class="header" href="#7-connection-affecting-behaviours">7. Connection-affecting behaviours</a></h2> <ul> <li>Locking the workstation / closing the laptop → heartbeat timeout → reconnect cycle; users expect zero-state loss on return (state restored, one refetch).</li> <li>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, <a href="ws/03_User_Journey.html">03</a> J4).</li> <li>Corporate proxies that block WS: engine.io falls back to long-polling (socket.io default transports) — the client must keep functioning in degraded transport.</li> </ul> <h2 id="8-trust-rules"><a class="header" href="#8-trust-rules">8. Trust rules</a></h2> <ul> <li>Never claim "Live" when state ≠ connected — status must be truthful to the millisecond (<a href="ws/06_Screen_Specifications.html">06</a> §1).</li> <li>On <code>error-auth</code>, do not loop forever: bounded retries → sign-out guidance (<a href="ws/06_Screen_Specifications.html">06</a> §1.2, <a href="ws/14_QA_Checklist.html">14</a> Q2).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="10--interaction-specification-ws--realtime-module"><a class="header" href="#10--interaction-specification-ws--realtime-module">10 — Interaction Specification (WS / Realtime Module)</a></h1> <ul> <li><a href="ws/10_Interaction_Specification.html#1-connection-lifecycle-interactions">1. Connection lifecycle interactions</a></li> <li><a href="ws/10_Interaction_Specification.html#2-live-update-interactions">2. Live-update interactions</a></li> <li><a href="ws/10_Interaction_Specification.html#3-toast-queueing-rules">3. Toast queueing rules</a></li> <li><a href="ws/10_Interaction_Specification.html#4-navigation-from-events">4. Navigation from events</a></li> <li><a href="ws/10_Interaction_Specification.html#5-ordering--dedup-semantics">5. Ordering & dedup semantics</a></li> <li><a href="ws/10_Interaction_Specification.html#6-offline--reconnect-interactions">6. Offline / reconnect interactions</a></li> <li><a href="ws/10_Interaction_Specification.html#7-keyboard--adaptive-interactions">7. Keyboard / adaptive interactions</a></li> <li><a href="ws/10_Interaction_Specification.html#8-analytics-events-proposed">8. Analytics events (proposed)</a></li> </ul> <blockquote> <p>Precise interaction rules for the realtime layer. Motion/animation tokens come from <a href="ws/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>; accessibility baseline from <a href="ws/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>. Analytics <code>(proposed)</code>.</p> </blockquote> <hr /> <h2 id="1-connection-lifecycle-interactions"><a class="header" href="#1-connection-lifecycle-interactions">1. Connection lifecycle interactions</a></h2> <div class="table-wrapper"><table><thead><tr><th>Trigger</th><th>Interaction</th></tr></thead><tbody> <tr><td>App start</td><td>silent <code>connecting</code>; no UI until connected or first failure</td></tr> <tr><td>Connected</td><td>nothing visible (dot green, no animation)</td></tr> <tr><td>Reconnect attempt #1..n</td><td>pulsing dot only; after n=1 show banner (<a href="ws/06_Screen_Specifications.html">06</a> §1.2)</td></tr> <tr><td>Offline (> 30 s)</td><td>banner + "Retry now"; background backoff continues</td></tr> <tr><td>error-auth</td><td>banner "Session expired — reconnecting"; client refreshes token then reconnects; on refresh failure → app sign-out with snackbar</td></tr> <tr><td>Manual retry</td><td>cancels backoff timer, immediate connect attempt, <code>m-fast</code> state change</td></tr> </tbody></table> </div> <p>All transitions announce via live region (<a href="ws/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>); none block user input.</p> <h2 id="2-live-update-interactions"><a class="header" href="#2-live-update-interactions">2. Live-update interactions</a></h2> <div class="table-wrapper"><table><thead><tr><th>Rule</th><th>Detail</th></tr></thead><tbody> <tr><td>Non-interruption</td><td>events never steal focus, move the caret, or reorder mid-edit — defer via <code>AppLiveList</code> (<a href="ws/06_Screen_Specifications.html">06</a> §3, <a href="ws/09_User_Behaviour.html">09</a> §4)</td></tr> <tr><td>Row flash</td><td>matching row gets a 400 ms highlight (<code>m-fast</code> ease-out) then settles</td></tr> <tr><td>Insert</td><td>only at list head or by current sort; never while pull-to-refresh is active</td></tr> <tr><td>Aggregation</td><td>dashboard tiles animate delta (<code>m-fast</code>); total recomputed locally, no refetch</td></tr> <tr><td>Pull-to-refresh</td><td>always available; forces REST refetch even when connected (source of truth)</td></tr> <tr><td>Toast tap</td><td>navigate to target; bell badge decrements only via list-open reset (<a href="ws/09_User_Behaviour.html">09</a> §3)</td></tr> </tbody></table> </div> <h2 id="3-toast-queueing-rules"><a class="header" href="#3-toast-queueing-rules">3. Toast queueing rules</a></h2> <ul> <li>Max 1 visible toast; others queue FIFO; queue cap 5 (drop oldest, keep badge).</li> <li>High-priority types only (<a href="ws/06_Screen_Specifications.html">06</a> §2.2); all events still increment the badge.</li> <li>Dedup window 30 s per <code>eventType+entityId</code>.</li> </ul> <h2 id="4-navigation-from-events"><a class="header" href="#4-navigation-from-events">4. Navigation from events</a></h2> <ul> <li>Envelope carries only <code>{eventType, occurredAt, payload}</code> (<code>ws-bridge.service.ts:17-21</code>); navigation targets are derived from <code>eventType</code> via a static route map (<code>notification.created</code> → notification detail, etc.) <code>(proposed)</code>.</li> <li>If target entity is gone (REST 404), snackbar "No longer available"; never a dead screen.</li> </ul> <h2 id="5-ordering--dedup-semantics"><a class="header" href="#5-ordering--dedup-semantics">5. Ordering & dedup semantics</a></h2> <ul> <li>In-connection order is guaranteed by socket.io; cross-reconnect order is not — after reconnect, reconcile once per visible live screen (<a href="ws/06_Screen_Specifications.html">06</a> §3.1).</li> <li>Dedup key: <code>eventType + entityId</code> (payload). Correlation ID exists on the domain event (<code>events/domain-event.interface.ts:6</code>) but is <strong>not forwarded</strong> by the bridge — until it is, entityId is the best key (see <a href="ws/12_API_Mapping.html">12</a> gap).</li> <li>Server event rate: bridge forwards <strong>every</strong> domain event for the tenant (<code>ws-bridge.service.ts:16-22</code>) — clients must filter client-side today (<a href="ws/08_Form_Specifications.html">08</a> §5).</li> </ul> <h2 id="6-offline--reconnect-interactions"><a class="header" href="#6-offline--reconnect-interactions">6. Offline / reconnect interactions</a></h2> <ol> <li>On <code>offline</code>: pause live mutations; keep data; show banner.</li> <li>On <code>connected</code> (reconnect): re-<code>subscribe</code> extra rooms (<code>ws.gateway.ts:63-68</code> — rooms die with the socket), then one refetch per visible live screen.</li> <li>No replay of missed events — accept eventual consistency (fire-and-forget bridge, <code>ws-bridge.service.ts:16-22</code>).</li> </ol> <h2 id="7-keyboard--adaptive-interactions"><a class="header" href="#7-keyboard--adaptive-interactions">7. Keyboard / adaptive interactions</a></h2> <ul> <li>Banner "Retry" is focusable and Enter-activatable; status dot is a button (focusable) opening the status sheet <code>(proposed)</code>.</li> <li>Debug view: filter field keyboard enter applies filter; Escape clears.</li> <li>Desktop hover on status dot shows tooltip; mobile long-press opens sheet (<a href="ws/05_Screen_Inventory.html">05</a> §4).</li> </ul> <h2 id="8-analytics-events-proposed-1"><a class="header" href="#8-analytics-events-proposed-1">8. Analytics events (proposed)</a></h2> <p><code>app.ws.connect</code>, <code>app.ws.disconnect</code>, <code>app.ws.reconnect{attempt}</code>, <code>app.ws.offline{ms}</code>, <code>app.ws.auth_error</code>, <code>app.ws.event_received{eventType}</code> (sampled ≤ 1%), <code>app.ws.toast_tap</code>, <code>app.ws.status_open</code>, <code>admin.ws.{open,pause,filter}</code>.</p> <p>Logging rule: never log payload contents or tokens; eventType + occurredAt + sizes only (<a href="ws/06_Screen_Specifications.html">06</a> §6, <a href="ws/07_Component_Library.html">07</a> §6).</p> <div style="break-before: page; page-break-before: always;"></div><h1 id="11--design-system-mapping-ws--realtime-module"><a class="header" href="#11--design-system-mapping-ws--realtime-module">11 — Design System Mapping (WS / Realtime Module)</a></h1> <ul> <li><a href="ws/11_Design_System_Mapping.html#1-token-mapping">1. Token mapping</a></li> <li><a href="ws/11_Design_System_Mapping.html#2-component-mapping">2. Component mapping</a></li> <li><a href="ws/11_Design_System_Mapping.html#3-state-mapping">3. State mapping</a></li> <li><a href="ws/11_Design_System_Mapping.html#4-typography">4. Typography</a></li> <li><a href="ws/11_Design_System_Mapping.html#5-motion-from-00-shared08">5. Motion (from 00-shared/08)</a></li> <li><a href="ws/11_Design_System_Mapping.html#6-accessibility-from-00-shared09">6. Accessibility (from 00-shared/09)</a></li> <li><a href="ws/11_Design_System_Mapping.html#7-qa-alignment-from-00-shared10">7. QA alignment (from 00-shared/10)</a></li> </ul> <blockquote> <p>Maps every realtime component/surface to the shared design system. Sources of truth: <a href="ws/../00-shared/02_Design_Tokens.html">00-shared/02</a> (Design Tokens), <a href="ws/../00-shared/03_Component_Library.html">00-shared/03</a> (Component Library), <a href="ws/../00-shared/04_Design_System_Mapping.html">00-shared/04</a> (Design System Mapping), <a href="ws/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a> (Interaction & Motion), <a href="ws/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a> (Accessibility Baseline), <a href="ws/../00-shared/10_QA_Baseline.html">00-shared/10</a> (QA Baseline).</p> </blockquote> <hr /> <h2 id="1-token-mapping"><a class="header" href="#1-token-mapping">1. Token mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>Real-time concept</th><th>Token / primitive</th><th>Notes</th></tr></thead><tbody> <tr><td>connected</td><td><code>color.success</code> (status)</td><td>from <a href="ws/../00-shared/02_Design_Tokens.html">00-shared/02</a> status palette</td></tr> <tr><td>connecting / reconnecting</td><td><code>color.warning</code> + pulse</td><td>pulse duration from <a href="ws/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a> motion tokens</td></tr> <tr><td>offline / error-auth</td><td><code>color.danger</code></td><td>banner uses <code>surface.danger-container</code> for background</td></tr> <tr><td>idle (neutral)</td><td><code>color.neutral</code></td><td>dot only, no label</td></tr> <tr><td>status dot</td><td>8 dp circle, <code>radius.round</code></td><td>from shared shape tokens</td></tr> <tr><td>banner</td><td><code>AppBanner</code>/<code>AppOfflineBanner</code> primitive (<a href="ws/../00-shared/03_Component_Library.html">00-shared/03</a>)</td><td>module composes <code>AppConnectionBanner</code> (<a href="ws/07_Component_Library.html">07</a> §2)</td></tr> <tr><td>row flash</td><td><code>m-fast</code> (≤ 200 ms) ease-out highlight</td><td>from <a href="ws/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a></td></tr> <tr><td>toast</td><td><code>AppSnackbar</code></td><td>from <a href="ws/../00-shared/03_Component_Library.html">00-shared/03</a></td></tr> <tr><td>live-region announcements</td><td>a11y baseline §3</td><td><a href="ws/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a></td></tr> </tbody></table> </div> <h2 id="2-component-mapping-3"><a class="header" href="#2-component-mapping-3">2. Component mapping</a></h2> <div class="table-wrapper"><table><thead><tr><th>ws component (<a href="ws/07_Component_Library.html">07</a>)</th><th>Built on (shared)</th><th>Extends with</th></tr></thead><tbody> <tr><td><code>AppRealtimeStatusDot</code></td><td><code>AppBadge</code>/dot primitive + tooltip</td><td>state→color/motion mapping table</td></tr> <tr><td><code>AppConnectionBanner</code></td><td><code>AppBanner</code>/<code>AppOfflineBanner</code></td><td>retry action, live-region text</td></tr> <tr><td><code>AppLiveList</code></td><td><code>AppList</code> + <code>AppSkeleton</code> + <code>RefreshIndicator</code></td><td>event merge logic, row flash, bounded refetch</td></tr> <tr><td><code>AppNotificationToast</code></td><td><code>AppSnackbar</code></td><td>priority filter, dedup window</td></tr> <tr><td><code>AppEventLog</code></td><td><code>AppDataTable</code> + <code>AppEmptyState</code></td><td>monospace stream, filter, pause</td></tr> <tr><td><code>AppBottomSheet</code> (status sheet)</td><td><code>AppBottomSheet</code></td><td>state chip + last-event row</td></tr> <tr><td><code>RealtimeClient</code></td><td>— (non-visual)</td><td>socket lifecycle, handshake, rooms</td></tr> </tbody></table> </div> <h2 id="3-state-mapping"><a class="header" href="#3-state-mapping">3. State mapping</a></h2> <p>All surfaces map the five realtime states to the shared state vocabulary (idle / loading / success / empty / error — <a href="ws/../00-shared/03_Component_Library.html">00-shared/03</a> legend):</p> <div class="table-wrapper"><table><thead><tr><th>Realtime state</th><th>Shared vocabulary mapping</th></tr></thead><tbody> <tr><td>idle</td><td>idle</td></tr> <tr><td>connecting</td><td>loading (indeterminate, non-blocking)</td></tr> <tr><td>connected</td><td>success</td></tr> <tr><td>reconnecting</td><td>loading (indeterminate) + warning tint</td></tr> <tr><td>offline</td><td>error (offline subtype per <a href="ws/../00-shared/10_QA_Baseline.html">00-shared/10</a> QA baseline)</td></tr> <tr><td>error-auth</td><td>error (auth subtype — distinct copy, no destructive tone)</td></tr> </tbody></table> </div> <h2 id="4-typography"><a class="header" href="#4-typography">4. Typography</a></h2> <ul> <li>Debug log: <code>font.mono</code> (<a href="ws/../00-shared/02_Design_Tokens.html">00-shared/02</a>), <code>text.sm</code>, tabular numerals for timestamps.</li> <li>Banner/toast: <code>text.md</code> title + <code>text.sm</code> body per shared type scale.</li> </ul> <h2 id="5-motion-from-00-shared08"><a class="header" href="#5-motion-from-00-shared08">5. Motion (from <a href="ws/../00-shared/08_Interaction_&_Motion.html">00-shared/08</a>)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Interaction</th><th>Motion</th></tr></thead><tbody> <tr><td>banner in/out</td><td><code>m-fast</code> slide from top (16 dp)</td></tr> <tr><td>dot pulse</td><td>1 s ease-in-out loop, only in connecting/reconnecting</td></tr> <tr><td>row flash</td><td>200 ms ease-out highlight</td></tr> <tr><td>toast</td><td><code>m-fast</code> fade+slide, 5 s dwell</td></tr> <tr><td>status sheet</td><td><code>m-base</code> slide-up, content <code>m-fast</code> fade</td></tr> </tbody></table> </div> <h2 id="6-accessibility-from-00-shared09"><a class="header" href="#6-accessibility-from-00-shared09">6. Accessibility (from <a href="ws/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>)</a></h2> <ul> <li>Status never color-only: dot always paired with label/tooltip/live-region text.</li> <li>Banner content in live region; retry button reachable by keyboard (tab order after app bar).</li> <li>Toast auto-dismiss must not destroy meaning: bell badge persists as the durable signal.</li> <li>Debug view: monospace rows readable with text-scale up to 200%; filter field labeled.</li> <li>Reduced-motion: pulse and row-flash degrade to static states (<a href="ws/../00-shared/09_Accessibility_Baseline.html">00-shared/09</a>).</li> </ul> <h2 id="7-qa-alignment-from-00-shared10"><a class="header" href="#7-qa-alignment-from-00-shared10">7. QA alignment (from <a href="ws/../00-shared/10_QA_Baseline.html">00-shared/10</a>)</a></h2> <ul> <li>Realtime surfaces follow the shared QA baseline (offline/error matrix) plus module cases in <a href="ws/14_QA_Checklist.html">14_QA_Checklist.md</a>.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="12--api-mapping-ws--realtime-module"><a class="header" href="#12--api-mapping-ws--realtime-module">12 — API Mapping (WS / Realtime Module)</a></h1> <ul> <li><a href="ws/12_API_Mapping.html#1-rest-surface-none">1. REST surface: none</a></li> <li><a href="ws/12_API_Mapping.html#2-transport--handshake">2. Transport & handshake</a></li> <li><a href="ws/12_API_Mapping.html#3-client--server-events-the-only-wire-inputs">3. Client → Server events (the only wire inputs)</a></li> <li><a href="ws/12_API_Mapping.html#4-server--client-events">4. Server → Client events</a></li> <li><a href="ws/12_API_Mapping.html#5-known-gaps-all-verified-in-source">5. Known gaps (all verified in source)</a></li> <li><a href="ws/12_API_Mapping.html#6-planned-socket-consumers">6. Planned socket consumers</a></li> <li><a href="ws/12_API_Mapping.html#7-versioning--errors">7. Versioning & errors</a></li> </ul> <blockquote> <p><strong>Honest statement: the ws module exposes NO REST endpoints.</strong> It is a socket contract only. General API conventions live in <a href="ws/../00-shared/07_API_Conventions.html">00-shared/07</a>; this file maps the socket contract 1:1 to source, and marks every gap explicitly.</p> </blockquote> <hr /> <h2 id="1-rest-surface-none"><a class="header" href="#1-rest-surface-none">1. REST surface: none</a></h2> <div class="table-wrapper"><table><thead><tr><th>Method</th><th>Path</th><th>Status</th></tr></thead><tbody> <tr><td>—</td><td>—</td><td><strong>No HTTP endpoints.</strong> The module consists of gateway + bridge only (<code>ws.module.ts:6-9</code>). Auth is via JWT on the socket handshake, not via any REST call of this module.</td></tr> </tbody></table> </div> <h2 id="2-transport--handshake"><a class="header" href="#2-transport--handshake">2. Transport & handshake</a></h2> <div class="table-wrapper"><table><thead><tr><th>Aspect</th><th>Contract</th><th>Source</th></tr></thead><tbody> <tr><td>Endpoint</td><td><code>ws(s)://<host>/ws</code> (socket.io namespace <code>/ws</code>)</td><td><code>ws.gateway.ts:20-23</code></td></tr> <tr><td>Auth</td><td>Bearer-style access token in <code>handshake.auth.token</code> <strong>or</strong> <code>handshake.query.token</code></td><td><code>ws.gateway.ts:37-39</code></td></tr> <tr><td>Verification</td><td><code>JwtService.verify</code> with <code>JWT_ACCESS_SECRET</code>; payload <code>{sub, tenantId, roles}</code></td><td><code>ws.gateway.ts:42-48</code></td></tr> <tr><td>Failure</td><td>socket disconnected, no error frame</td><td><code>ws.gateway.ts:54-56</code></td></tr> <tr><td>CORS</td><td><code>origin: '*'</code>, credentials true</td><td><code>ws.gateway.ts:22</code></td></tr> </tbody></table> </div> <h2 id="3-client--server-events-the-only-wire-inputs"><a class="header" href="#3-client--server-events-the-only-wire-inputs">3. Client → Server events (the only wire inputs)</a></h2> <div class="table-wrapper"><table><thead><tr><th>Event</th><th>Payload</th><th>Effect</th><th>Source</th></tr></thead><tbody> <tr><td><code>subscribe</code></td><td><code>room: string</code> (any)</td><td>join room; future <code>broadcastToRoom</code> deliveries</td><td><code>ws.gateway.ts:63-68,80-82</code></td></tr> <tr><td><code>unsubscribe</code></td><td><code>room: string</code> (any)</td><td>leave room</td><td><code>ws.gateway.ts:70-74</code></td></tr> </tbody></table> </div> <p>No other client messages are handled; unknown events are ignored. No acks, no errors.</p> <h2 id="4-server--client-events"><a class="header" href="#4-server--client-events">4. Server → Client events</a></h2> <div class="table-wrapper"><table><thead><tr><th>Event name</th><th>Payload envelope</th><th>Delivery</th><th>Source</th></tr></thead><tbody> <tr><td><code>{eventType}</code> — any domain event type, dot-notation (<code>API_STANDARDS.md:642-658</code>), e.g. <code>notification.created</code>, <code>payment.completed</code>, <code>attendance.updated</code>, <code>homework.published</code></td><td><code>{ eventType: string, occurredAt: ISO-8601, payload: object }</code></td><td><code>tenant:{tenantId}</code> room — <strong>all</strong> connected sockets of the tenant</td><td><code>ws-bridge.service.ts:16-22</code>; <code>ws.gateway.ts:76-78</code></td></tr> </tbody></table> </div> <p>Details:</p> <ul> <li>The bridge relays <strong>every</strong> <code>EventBus</code> event (<code>events/event-bus.service.ts:11-14</code>) for the event's tenant — there is no server-side allow-list of event types.</li> <li>The envelope is a <strong>subset</strong> of <code>DomainEvent</code> (<code>events/domain-event.interface.ts:1-8</code>): <code>actorId</code> and <code>correlationId</code> are dropped by the bridge (<code>ws-bridge.service.ts:17-21</code>) — <strong>gap G3</strong>.</li> <li><code>broadcastToRoom(room, event, data)</code> exists (<code>ws.gateway.ts:80-82</code>) and is used for ad-hoc room fan-out, but nothing in source calls it today.</li> </ul> <h2 id="5-known-gaps-all-verified-in-source"><a class="header" href="#5-known-gaps-all-verified-in-source">5. Known gaps (all verified in source)</a></h2> <div class="table-wrapper"><table><thead><tr><th>#</th><th>Gap</th><th>Evidence</th><th>Impact / recommendation</th></tr></thead><tbody> <tr><td>G1</td><td>No <code>ws.*</code> permissions in RBAC</td><td><code>ALL_PERMISSIONS</code> (<code>permissions.constants.ts:1-97</code>) contains no <code>ws.*</code> entries</td><td>Admin debug view has no permission gate; add <code>ws.monitor</code> when debug surfaces ship</td></tr> <tr><td>G2</td><td><code>subscribe</code> accepts any room string, no allow-list/RBAC</td><td><code>ws.gateway.ts:65,71</code></td><td>Any authenticated user can join <code>room X</code> if another module broadcasts there; enforce room-prefix allow-list + permission check</td></tr> <tr><td>G3</td><td>Bridge drops <code>actorId</code>/<code>correlationId</code></td><td><code>ws-bridge.service.ts:17-21</code> vs <code>domain-event.interface.ts:4-6</code></td><td>Client dedup uses entityId heuristics; forward correlationId</td></tr> <tr><td>G4</td><td>No per-user room / private delivery</td><td>rooms = tenant + ad-hoc (<code>ws.gateway.ts:50,63-68</code>)</td><td>Sensitive notifications must define <code>user:{id}</code> convention + RBAC</td></tr> <tr><td>G5</td><td>No Redis pub/sub adapter for sockets</td><td><code>src/infrastructure/redis/</code> has only <code>REDIS_CLIENT</code> (<code>redis.constants.ts:1-2</code>, <code>redis.module.ts:12-27</code>); no socket.io adapter in <code>src</code></td><td>Multi-instance deployments will fan out only within one process; add socket.io Redis adapter when scaling (plan flags 10k-connection risk, <code>IMPLEMENTATION_PLAN.md:842</code>)</td></tr> <tr><td>G6</td><td>No heartbeat tuning in source</td><td>gateway sets no <code>pingInterval</code>/<code>pingTimeout</code></td><td>Relies on socket.io defaults (25 s / 20 s); tune for proxy timeouts</td></tr> <tr><td>G7</td><td>No server-side event allow-list / per-event subscription</td><td>bridge relays everything (<code>ws-bridge.service.ts:16-22</code>)</td><td>Clients filter client-side; adds bandwidth; plan per-eventType subscription</td></tr> <tr><td>G8</td><td>roles unused</td><td>set at connect (<code>ws.gateway.ts:48</code>) but never read for routing</td><td>future per-role delivery; do not rely on it today</td></tr> </tbody></table> </div> <h2 id="6-planned-socket-consumers"><a class="header" href="#6-planned-socket-consumers">6. Planned socket consumers</a></h2> <div class="table-wrapper"><table><thead><tr><th>Consumer</th><th>Contract</th><th>Status</th></tr></thead><tbody> <tr><td>Communication (messages)</td><td>realtime delivery via <code>WsModule</code></td><td>planned — <code>docs/IMPLEMENTATION_PLAN.md:119</code></td></tr> <tr><td>Async job progress</td><td>clients subscribe via WS instead of polling (<code>API_STANDARDS.md:579</code>)</td><td>planned</td></tr> <tr><td>Transport live tracking</td><td><code>transport.*</code> events</td><td>planned — <code>docs/IMPLEMENTATION_PLAN.md:229</code></td></tr> <tr><td>Notifications</td><td>push + in-app channels</td><td>planned — <code>docs/IMPLEMENTATION_PLAN.md:231</code></td></tr> </tbody></table> </div> <h2 id="7-versioning--errors"><a class="header" href="#7-versioning--errors">7. Versioning & errors</a></h2> <ul> <li>No versioning (no REST). Event names follow dot-notation and are additive; clients must ignore unknown <code>eventType</code>s (<a href="ws/06_Screen_Specifications.html">06</a> §6).</li> <li>Error signalling is <strong>connection-level only</strong> (disconnect on auth failure, <code>ws.gateway.ts:54-56</code>). No application error channel — add one for G-series fixes.</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="13--state-management-ws--realtime-module"><a class="header" href="#13--state-management-ws--realtime-module">13 — State Management (WS / Realtime Module)</a></h1> <ul> <li><a href="ws/13_State_Management.html#1-realtimestate-bloc-state">1. <code>RealtimeState</code> (Bloc state)</a></li> <li><a href="ws/13_State_Management.html#2-event-routing">2. Event routing</a></li> <li><a href="ws/13_State_Management.html#3-reconnect-state-machine-source-grounded">3. Reconnect state machine (source-grounded)</a></li> <li><a href="ws/13_State_Management.html#4-room-membership-state">4. Room membership state</a></li> <li><a href="ws/13_State_Management.html#5-persistence">5. Persistence</a></li> <li><a href="ws/13_State_Management.html#6-testing-strategy-client">6. Testing strategy (client)</a></li> </ul> <blockquote> <p>How the realtime layer plugs into client state management (Bloc). Shared conventions: <a href="ws/../00-shared/06_State_Management.html">00-shared/06</a> (State Management). The module owns one <code>RealtimeBloc</code>; 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).</p> </blockquote> <hr /> <h2 id="1-realtimestate-bloc-state"><a class="header" href="#1-realtimestate-bloc-state">1. <code>RealtimeState</code> (Bloc state)</a></h2> <div class="table-wrapper"><table><thead><tr><th>State</th><th>Meaning</th><th>Exposes</th></tr></thead><tbody> <tr><td><code>RealtimeIdle</code></td><td>app start, never connected</td><td>—</td></tr> <tr><td><code>RealtimeConnecting</code></td><td>socket opening, handshake in flight</td><td>attempt #</td></tr> <tr><td><code>RealtimeConnected</code></td><td>handshake OK, in tenant room (<code>ws.gateway.ts:50</code>)</td><td>userId, tenantId (from auth store)</td></tr> <tr><td><code>RealtimeReconnecting</code></td><td>retrying after unexpected close</td><td>attempt #, nextBackoffMs</td></tr> <tr><td><code>RealtimeOffline</code></td><td>retries exhausted / transport dead</td><td>lastError, manual retry available</td></tr> <tr><td><code>RealtimeAuthError</code></td><td>handshake rejected (<code>ws.gateway.ts:54-56</code>)</td><td>needs token refresh</td></tr> </tbody></table> </div> <p>Single cubit/bloc, app-scoped (DI singleton), mirrored by <code>AppRealtimeStatusDot</code> (<a href="ws/07_Component_Library.html">07</a> §1). No screen holds connection state locally — never a second source of truth.</p> <h2 id="2-event-routing"><a class="header" href="#2-event-routing">2. Event routing</a></h2> <pre><code>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) </code></pre> <p>Rules:</p> <ul> <li>Router is a <strong>pure map</strong> <code>eventType → AppEvent</code>; unknown types → ignored (<code>06</code> §6 — additive protocol).</li> <li>Feature blocs own their merge semantics via <code>AppLiveList</code> (<code>06</code> §3.1); <code>RealtimeBloc</code> never touches domain data.</li> <li>Subscription teardown is explicit (bloc <code>close()</code> cancels stream subs) — prevents fan-out leaks on screen navigation (<code>14</code> Q5).</li> </ul> <h2 id="3-reconnect-state-machine-source-grounded"><a class="header" href="#3-reconnect-state-machine-source-grounded">3. Reconnect state machine (source-grounded)</a></h2> <p>Facts driving the machine: server verifies JWT on every connection (<code>ws.gateway.ts:42-44</code>); disconnects silently on failure (<code>ws.gateway.ts:54-56</code>); tenant room is joined automatically (<code>ws.gateway.ts:50</code>) but extra rooms die with the socket (<code>ws.gateway.ts:63-68</code>) — so <strong>every successful reconnect re-runs: handshake → join → re-subscribe rooms → reconcile</strong>.</p> <pre class="mermaid">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 </pre> <p>Notes:</p> <ul> <li>Backoff: exponential with jitter, 1 s → 30 s cap (<code>15</code> §4); jitter avoids reconnect storms (<code>14</code> Q1).</li> <li>On <code>Connected</code> from <code>Reconnecting</code>: emit <code>RealtimeReconnected</code> → subscribers run reconcile (re-subscribe rooms + one refetch per visible live screen, <code>06</code> §3.1).</li> </ul> <h2 id="4-room-membership-state"><a class="header" href="#4-room-membership-state">4. Room membership state</a></h2> <div class="table-wrapper"><table><thead><tr><th>Room</th><th>Who manages</th><th>Source</th></tr></thead><tbody> <tr><td><code>tenant:{tenantId}</code></td><td>server, automatic</td><td><code>ws.gateway.ts:50</code></td></tr> <tr><td>extra rooms</td><td>client, re-applied after each reconnect</td><td><code>ws.gateway.ts:63-68</code></td></tr> </tbody></table> </div> <p><code>RealtimeBloc</code> keeps a <code>Set<String> subscribedRooms</code>; after every <code>Connected</code>, it replays <code>subscribe</code> for each — this is the only piece of state that survives reconnects by design.</p> <h2 id="5-persistence"><a class="header" href="#5-persistence">5. Persistence</a></h2> <ul> <li>No socket state is persisted (no replay — fire-and-forget bridge, <code>ws-bridge.service.ts:16-22</code>).</li> <li>Token source: shared auth store (secure storage); <code>RealtimeBloc</code> reads it at connect and after refresh — never stores its own copy (single source of truth per <code>00-shared/06</code>).</li> <li>Last-envelope metadata (eventType, occurredAt) kept in-memory for the status sheet (<code>05</code> §4) — non-critical, dropped on restart.</li> </ul> <h2 id="6-testing-strategy-client"><a class="header" href="#6-testing-strategy-client">6. Testing strategy (client)</a></h2> <ul> <li><code>RealtimeBloc</code> unit tests with a fake <code>RealtimeClient</code> (envelope-in → state-out).</li> <li>Router tests: every mapped eventType → typed event; unknown → ignored.</li> <li>State machine tests: full reconnect matrix incl. jitter bounds, auth-error→refresh.</li> <li><code>AppLiveList</code> merge tests: hit/miss/insert/refetch-bound (pure logic, no socket).</li> </ul> <div style="break-before: page; page-break-before: always;"></div><h1 id="14--qa-checklist-ws--realtime-module"><a class="header" href="#14--qa-checklist-ws--realtime-module">14 — QA Checklist (WS / Realtime Module)</a></h1> <ul> <li><a href="ws/14_QA_Checklist.html#q1--reconnect-storms-client--server">Q1 — Reconnect storms (client + server)</a></li> <li><a href="ws/14_QA_Checklist.html#q2--auth-on-reconnect">Q2 — Auth on reconnect</a></li> <li><a href="ws/14_QA_Checklist.html#q3--event-fan-out-leaks-tenant-isolation">Q3 — Event fan-out leaks (tenant isolation)</a></li> <li><a href="ws/14_QA_Checklist.html#q4--heartbeat-loss-silent-half-open-connection">Q4 — Heartbeat loss (silent half-open connection)</a></li> <li><a href="ws/14_QA_Checklist.html#q5--event-fan-out-leaks-client-side">Q5 — Event fan-out leaks (client side)</a></li> <li><a href="ws/14_QA_Checklist.html#q6--subscribe--unsubscribe-semantics">Q6 — Subscribe / unsubscribe semantics</a></li> <li><a href="ws/14_QA_Checklist.html#q7--reconnect-resubscription">Q7 — Reconnect resubscription</a></li> <li><a href="ws/14_QA_Checklist.html#q8--malformed-payloads--unknown-events">Q8 — Malformed payloads & unknown events</a></li> <li><a href="ws/14_QA_Checklist.html#q9--payload-integrity">Q9 — Payload integrity</a></li> <li><a href="ws/14_QA_Checklist.html#q10--scale-planned-per-plan">Q10 — Scale (planned, per plan)</a></li> <li><a href="ws/14_QA_Checklist.html#q11--broadcast-correctness-after-redeploy">Q11 — Broadcast correctness after redeploy</a></li> <li><a href="ws/14_QA_Checklist.html#q12--logging-hygiene">Q12 — Logging hygiene</a></li> <li><a href="ws/14_QA_Checklist.html#test-surface-summary">Test surface summary</a></li> </ul> <blockquote> <p>Test cases for the realtime layer. Server side (Jest) + client side (widget/unit). Shared baseline: <a href="ws/../00-shared/10_QA_Baseline.html">00-shared/10</a>. Every case lists pass criteria. Source anchors included; a "source" gap means the behavior is defined by us, not the server.</p> </blockquote> <hr /> <h2 id="q1--reconnect-storms-client--server"><a class="header" href="#q1--reconnect-storms-client--server">Q1 — Reconnect storms (client + server)</a></h2> <ul> <li><strong>Scenario:</strong> 100 clients behind the same NAT lose connectivity simultaneously and all retry in lockstep.</li> <li>Pass: reconnects spread over time (client backoff jitter, <a href="ws/13_State_Management.html">13</a> §3, <a href="ws/15_Flutter_Implementation_Guide.html">15</a> §4); server accepts them without CPU/connection spikes; no tenant event lost <em>after</em> each reconnect.</li> <li>Server evidence: <code>handleConnection</code> logs connect (<code>ws.gateway.ts:51-53</code>); verify log rate stays linear, not bursty.</li> <li>Client: no two consecutive retries with identical delay; cap 30 s (<a href="ws/13_State_Management.html">13</a>).</li> </ul> <h2 id="q2--auth-on-reconnect"><a class="header" href="#q2--auth-on-reconnect">Q2 — Auth on reconnect</a></h2> <ul> <li><strong>Scenario:</strong> session token expires mid-connection; socket drops; client reconnects with the stale token.</li> <li>Pass: server closes the socket (<code>ws.gateway.ts:42-44,54-56</code>); client enters <code>AuthError</code> (never infinite loop), refreshes token via REST, reopens socket, resumes in tenant room (<code>ws.gateway.ts:50</code>).</li> <li>Negative: refresh fails → sign-out path with snackbar (<a href="ws/06_Screen_Specifications.html">06</a> §1.2), no retry loop.</li> <li>Regression: missing token (neither <code>auth</code> nor <code>query</code>) → clean disconnect (<code>ws.gateway.ts:37-40</code>).</li> </ul> <h2 id="q3--event-fan-out-leaks-tenant-isolation"><a class="header" href="#q3--event-fan-out-leaks-tenant-isolation">Q3 — Event fan-out leaks (tenant isolation)</a></h2> <ul> <li><strong>Scenario:</strong> tenant A emits <code>payment.completed</code>; tenant B is connected concurrently.</li> <li>Pass: only sockets in <code>tenant:{A}</code> receive it (<code>ws-bridge.service.ts:16-22</code>, <code>ws.gateway.ts:76-78</code>); B receives nothing.</li> <li>Multi-socket: same user with 2 tabs in A receives exactly 2 copies (client dedups).</li> <li><strong>This is the highest-severity test on the list</strong> — isolation is the module's core security boundary (<a href="ws/04_Information_Architecture.html">04</a> §2).</li> </ul> <h2 id="q4--heartbeat-loss-silent-half-open-connection"><a class="header" href="#q4--heartbeat-loss-silent-half-open-connection">Q4 — Heartbeat loss (silent half-open connection)</a></h2> <ul> <li><strong>Scenario:</strong> device sleeps; TCP stays half-open; no data flows.</li> <li>Pass: server pings time out (socket.io heartbeat defaults — **untuned in source, G6 <a href="ws/12_API_Mapping.html">12</a>); server cleans the dead socket (visible in disconnect log, <code>ws.gateway.ts:59-61</code>); client detects loss and enters <code>Reconnecting</code>, not <code>Offline</code> forever.</li> <li>Tune check: <code>pingInterval</code>/<code>pingTimeout</code> explicitly set once proxy timeouts are known (pending decision, G6).</li> </ul> <h2 id="q5--event-fan-out-leaks-client-side"><a class="header" href="#q5--event-fan-out-leaks-client-side">Q5 — Event fan-out leaks (client side)</a></h2> <ul> <li><strong>Scenario:</strong> user navigates between 10 screens in a session.</li> <li>Pass: no duplicate Bloc subscriptions after navigation; bloc <code>close()</code> cancels stream subs (<a href="ws/13_State_Management.html">13</a> §2); memory flat over 15 min of navigation; single toast per event (dedup window, <a href="ws/10_Interaction_Specification.html">10</a> §3).</li> </ul> <h2 id="q6--subscribe--unsubscribe-semantics"><a class="header" href="#q6--subscribe--unsubscribe-semantics">Q6 — Subscribe / unsubscribe semantics</a></h2> <ul> <li><strong>Scenario:</strong> user subscribes to <code>class:10-A</code>, then unsubscribes, then resubscribes.</li> <li>Pass: <code>broadcastToRoom</code> reaches exactly the subscribed sockets (<code>ws.gateway.ts:80-82</code>); unsubscribe stops delivery (<code>ws.gateway.ts:70-74</code>); double-subscribe is idempotent; non-string payload ignored (<code>ws.gateway.ts:65,71</code>).</li> <li>Security negative: <code>subscribe</code> to arbitrary room <code>tenant:other</code> or <code>__admin</code> — <strong>no server rejection today (G2)</strong>; document as known-risk until allow-list lands.</li> </ul> <h2 id="q7--reconnect-resubscription"><a class="header" href="#q7--reconnect-resubscription">Q7 — Reconnect resubscription</a></h2> <ul> <li><strong>Scenario:</strong> connected with extra rooms → network flap → reconnect.</li> <li>Pass: client re-<code>subscribe</code>s all extra rooms after handshake (rooms die with socket, <code>ws.gateway.ts:63-68</code>; client replay <a href="ws/13_State_Management.html">13</a> §4); tenant room auto-restored (<code>ws.gateway.ts:50</code>); one reconcile refetch per visible live screen (<a href="ws/06_Screen_Specifications.html">06</a> §3.1).</li> </ul> <h2 id="q8--malformed-payloads--unknown-events"><a class="header" href="#q8--malformed-payloads--unknown-events">Q8 — Malformed payloads & unknown events</a></h2> <ul> <li><strong>Scenario:</strong> server sends envelope with wrong shape, or an unknown eventType.</li> <li>Pass: client ignores + logs eventType only (<a href="ws/06_Screen_Specifications.html">06</a> §6); app never crashes; unknown <code>eventType</code> routed to no bloc; payload size cap (e.g. 1 MB) prevents memory blowup.</li> </ul> <h2 id="q9--payload-integrity"><a class="header" href="#q9--payload-integrity">Q9 — Payload integrity</a></h2> <ul> <li><strong>Scenario:</strong> event carries entityId; REST fetch of that entity.</li> <li>Pass: live row update matches REST data after reconcile; insert/refetch-bound logic per <a href="ws/06_Screen_Specifications.html">06</a> §3.1; aggregate tiles recompute without refetch.</li> </ul> <h2 id="q10--scale-planned-per-plan"><a class="header" href="#q10--scale-planned-per-plan">Q10 — Scale (planned, per plan)</a></h2> <ul> <li><strong>Scenario:</strong> 10k concurrent connections (plan flags this as a risk — <code>docs/IMPLEMENTATION_PLAN.md:842</code>).</li> <li>Pass (when executed): single-instance target met OR Redis pub/sub adapter added (currently absent — G5 <a href="ws/12_API_Mapping.html">12</a>); broadcast latency p95 < 1 s; reconnect storm Q1 holds.</li> </ul> <h2 id="q11--broadcast-correctness-after-redeploy"><a class="header" href="#q11--broadcast-correctness-after-redeploy">Q11 — Broadcast correctness after redeploy</a></h2> <ul> <li><strong>Scenario:</strong> server restarts with new bridge; clients reconnect.</li> <li>Pass: bridge re-activates on <code>onModuleInit</code> (<code>ws-bridge.service.ts:15-23</code>); no double registration (EventBus <code>onAny</code> not stacked — verify listener count after N restarts).</li> </ul> <h2 id="q12--logging-hygiene"><a class="header" href="#q12--logging-hygiene">Q12 — Logging hygiene</a></h2> <ul> <li>Pass: no token, payload content, or PII in gateway/bridge logs; only userId/tenantId <ul> <li>eventType (<code>ws.gateway.ts:51-53,59-61</code>; <a href="ws/10_Interaction_Specification.html">10</a> §8). Grep CI check.</li> </ul> </li> </ul> <hr /> <h2 id="test-surface-summary"><a class="header" href="#test-surface-summary">Test surface summary</a></h2> <div class="table-wrapper"><table><thead><tr><th>Layer</th><th>Tooling</th><th>Cases</th></tr></thead><tbody> <tr><td>Server (gateway)</td><td>Jest + <code>@nestjs/websockets</code> mock</td><td>handshake ok/fail, room join, subscribe validation, broadcast target</td></tr> <tr><td>Server (bridge)</td><td>Jest</td><td>onAny wiring, envelope shape, single registration</td></tr> <tr><td>Server (e2e)</td><td>socket.io-client against test server</td><td>Q1, Q3, Q6, Q11 (requires Mongo+Redis, <code>npm run test:e2e</code>)</td></tr> <tr><td>Client</td><td>widget tests + fake client</td><td>Q2, Q5, Q7, Q8</td></tr> </tbody></table> </div><div style="break-before: page; page-break-before: always;"></div><h1 id="15--flutter-implementation-guide-ws--realtime-module"><a class="header" href="#15--flutter-implementation-guide-ws--realtime-module">15 — Flutter Implementation Guide (WS / Realtime Module)</a></h1> <ul> <li><a href="ws/15_Flutter_Implementation_Guide.html#1-transport-choice--read-this-first-honest-note">1. Transport choice — read this first (honest note)</a></li> <li><a href="ws/15_Flutter_Implementation_Guide.html#2-connection-setup">2. Connection setup</a></li> <li><a href="ws/15_Flutter_Implementation_Guide.html#3-envelope-handling">3. Envelope handling</a></li> <li><a href="ws/15_Flutter_Implementation_Guide.html#4-auto-reconnect-with-backoff-state-machine-in-13-3">4. Auto-reconnect with backoff (state machine in <code>13</code> §3)</a></li> <li><a href="ws/15_Flutter_Implementation_Guide.html#5-heartbeat">5. Heartbeat</a></li> <li><a href="ws/15_Flutter_Implementation_Guide.html#6-bloc-integration">6. Bloc integration</a></li> <li><a href="ws/15_Flutter_Implementation_Guide.html#7-offline-ux-wiring">7. Offline UX wiring</a></li> <li><a href="ws/15_Flutter_Implementation_Guide.html#8-security-checklist">8. Security checklist</a></li> <li><a href="ws/15_Flutter_Implementation_Guide.html#9-testing">9. Testing</a></li> </ul> <blockquote> <p>How to implement the realtime client in Flutter. Shared architecture: <a href="ws/../00-shared/11_Flutter_App_Architecture.html">00-shared/11</a> (Flutter App Architecture). State machine it must implement: <a href="ws/13_State_Management.html">13</a>.</p> </blockquote> <hr /> <h2 id="1-transport-choice--read-this-first-honest-note"><a class="header" href="#1-transport-choice--read-this-first-honest-note">1. Transport choice — read this first (honest note)</a></h2> <p>The server is <strong>socket.io</strong> (<code>ws.gateway.ts:10-11</code>), which speaks the engine.io handshake + protocol, <strong>not</strong> plain RFC 6455 WebSocket. Two options:</p> <div class="table-wrapper"><table><thead><tr><th>Option</th><th>Package</th><th>Fit</th></tr></thead><tbody> <tr><td><strong>A — recommended</strong></td><td><code>socket_io_client</code></td><td>Full protocol parity: engine.io handshake, long-polling fallback, built-in reconnect, <code>auth.token</code> field, namespaces (<code>/ws</code>)</td></tr> <tr><td>B</td><td><code>web_socket_channel</code> (raw WS)</td><td>Only works if the server runs with <code>transports: ['websocket']</code> only and the client reproduces the engine.io <code>sid</code> handshake dance — fragile; not recommended for production</td></tr> </tbody></table> </div> <p>This guide uses <strong><code>web_socket_channel</code></strong> 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 <code>RealtimeClient</code> abstraction (<a href="ws/07_Component_Library.html">07</a> §6) makes the swap a one-file change.</p> <h2 id="2-connection-setup"><a class="header" href="#2-connection-setup">2. Connection setup</a></h2> <pre><code class="language-dart">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); </code></pre> <ul> <li>Prefer the <strong>query token</strong> with <code>web_socket_channel</code> (no <code>auth</code> map available on raw sockets; <code>socket_io_client</code> can use <code>auth: {'token': ...}</code> instead — both accepted by source: <code>ws.gateway.ts:37-39</code>).</li> <li><code>WS_SCHEME</code> = <code>wss</code> in prod; namespace path <code>/ws</code> (<code>ws.gateway.ts:20-23</code>).</li> <li>Token from the secure auth store; <strong>never</strong> log the URI (contains token, <code>10</code> §8).</li> <li>Verify failure server-side = close (<code>ws.gateway.ts:54-56</code>) — treat any early <code>done</code> during handshake as <code>AuthError</code> until proven otherwise (<code>13</code> §1).</li> </ul> <h2 id="3-envelope-handling"><a class="header" href="#3-envelope-handling">3. Envelope handling</a></h2> <pre><code class="language-dart">class WsEnvelope { final String eventType; final DateTime occurredAt; final Map<String, dynamic> payload; // validate exactly these 3 keys — anything else = protocol violation (06 §6) } </code></pre> <p>Server emits <code>{eventType, occurredAt, payload}</code> (<code>ws-bridge.service.ts:17-21</code>). Route via the pure <code>RealtimeBloc</code> router (<code>13</code> §2). Ignore unknown <code>eventType</code>s.</p> <h2 id="4-auto-reconnect-with-backoff-state-machine-in-13-3"><a class="header" href="#4-auto-reconnect-with-backoff-state-machine-in-13-3">4. Auto-reconnect with backoff (state machine in <code>13</code> §3)</a></h2> <pre><code class="language-dart">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) } </code></pre> <p>Rules:</p> <ul> <li>On every successful reconnect: <strong>re-<code>subscribe</code> extra rooms</strong> (rooms die with the socket, <code>ws.gateway.ts:63-68</code>) then run one reconcile refetch per visible live screen (<code>06</code> §3.1).</li> <li>Token expiry check before reconnecting (<code>06</code> §6); refresh via REST when needed.</li> </ul> <h2 id="5-heartbeat"><a class="header" href="#5-heartbeat">5. Heartbeat</a></h2> <ul> <li>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 <code>pingTimeout</code>+grace → close channel → <code>Reconnecting</code>. <code>IOWebSocketChannel</code> does not heartbeat — add a <code>Timer</code> watchdog if using option B (<code>14</code> Q4).</li> </ul> <h2 id="6-bloc-integration"><a class="header" href="#6-bloc-integration">6. Bloc integration</a></h2> <pre><code class="language-dart">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) } </code></pre> <p>Feature blocs subscribe to router output only; <code>AppLiveList</code> (<code>07</code> §3) owns merge semantics; never mutate lists during active edits (<code>10</code> §2).</p> <h2 id="7-offline-ux-wiring"><a class="header" href="#7-offline-ux-wiring">7. Offline UX wiring</a></h2> <ul> <li><code>AppRealtimeStatusDot</code> + <code>AppConnectionBanner</code> bind to <code>RealtimeBloc.state</code> (<code>11</code> §3); banner only for <code>offline</code>/<code>authError</code>; live-region announcements (<code>00-shared/09</code>).</li> <li>Status sheet <code>(proposed)</code> reads last-envelope metadata held by the bloc (<code>13</code> §5).</li> </ul> <h2 id="8-security-checklist"><a class="header" href="#8-security-checklist">8. Security checklist</a></h2> <ul> <li>Token only from secure storage; URI built at runtime (never in code/logs).</li> <li>No payload content in logs (<code>10</code> §8); debug view redacts (<code>09</code> §5).</li> <li>Validate envelope shape before routing (<code>14</code> Q8); cap payload size.</li> <li>Reject/ignore any server message that isn't a valid envelope.</li> </ul> <h2 id="9-testing-3"><a class="header" href="#9-testing-3">9. Testing</a></h2> <ul> <li><code>RealtimeClient</code> behind a fake for widget tests (Q2, Q5, Q7).</li> <li>Backoff unit test: delays bounded, jittered, no duplicates (Q1).</li> <li>Integration: <code>socket_io_client</code> against the real e2e server (<code>npm run test:e2e</code>, <code>14</code> test-surface table).</li> </ul> </main> <nav class="nav-wrapper" aria-label="Page navigation"> <!-- Mobile navigation buttons --> <div style="clear: both"></div> </nav> </div> </div> <nav class="nav-wide-wrapper" aria-label="Page navigation"> </nav> </div> <script> window.playground_copyable = true; </script> <script src="elasticlunr.min.js"></script> <script src="mark.min.js"></script> <script src="searcher.js"></script> <script src="clipboard.min.js"></script> <script src="highlight.js"></script> <script src="book.js"></script> <!-- Custom JS scripts --> <script src="mermaid.min.js"></script> <script src="mermaid-init.js"></script> <script> window.addEventListener('load', function() { window.setTimeout(window.print, 100); }); </script> </div> </body> </html>