03 — User Journeys (Users Module)
- 1. Invite a user (admin creates identity + membership)
- 2. Bulk CSV import (users) with progress
- 3. Edit profile & preferences (self-service)
- 4. Deactivate / soft-delete / GDPR erasure
- 5. Import failures & recovery
- Journey matrix (who / where / what)
End-to-end journeys computed from
users.controller.ts,users.service.ts,bulk.controller.ts/bulk-import.service.ts/students-import.adapter.ts,rbac.controller.ts,auth.service.ts, andtenant-purge.worker.ts. Each journey: entry, intent, decision points, system responses, failures, recovery, exit, permission denial, offline, conflict, multi-device, deep-link/push/email entry.(planned)/(forward-looking)per global rules.
1. Invite a user (admin creates identity + membership)
entry: /users → FAB "Add user"
intent: onboard a new staff member / teacher / parent with roles
sequenceDiagram
actor A as Org Admin / HR
participant F as CreateUserScreen
participant R as UsersRepository
participant API as POST /api/v1/users
participant RBAC as POST /api/v1/rbac/members
A->>F: firstName, lastName, email (+ optional middleName/displayName/phone/gender/DOB/language/timezone/status)
F->>R: submit()
R->>API: CreateUserDto — create-user.dto.ts:5-62
alt 201 success
API-->>R: user doc — users.service.ts:49-78 (displayName defaults to "firstName lastName" — :62)
Note over API: EventBus emits UserCreated → in-app queue job<br/>user-created-notification — event-queue-map.ts:10
R->>RBAC: {userId, roles: ["teacher"]} — rbac.service.ts:113-127 (status ACTIVE, joinedAt now)
F-->>A: success snackbar; row appears in list (refresh)
else 409 DUPLICATE_RESOURCE
API-->>R: "User with email X already exists." — users.service.ts:50-54
F-->>A: inline email conflict; suggest search; block submit until fixed
else 400 VALIDATION_ERROR
F-->>A: field errors (email format — create-user.dto.ts:24-26; gender enum :33-38; status enum :44-47)
else 429 / 5xx / offline
F-->>A: retry-able error; form draft kept
end
Note over F: Invite e-mail is (planned): email.worker.ts handles only<br/>UserRegistered / PasswordResetRequested (:25-42); no UserInvited event.
- Decision points: status (default
active—create-user.dto.ts:44-47); roles to assign (via RBAC step); whether to send verification email — today not sent for admin-created users (OQ-2). - Failures/recovery: email/phone dup → inline conflict (
users.service.ts:50-60); RBAC member create failing after user create → user exists without roles (compensate: retry membership; note OQ-5). - Exit: success → list (refresh); back → draft warning.
- Permission denial: no
user.create→ FAB hidden + 403 screen (client guard; server RBAC guard(planned)— onlyJwtAuthGuardtoday,users.controller.ts:31). - Multi-device: second admin sees the row on refresh; membership visible
via
GET /rbac/members. - Deep links / email entry:
(forward-looking)invite email with accept-link; no accept endpoint exists yet.
2. Bulk CSV import (users) with progress
entry: /users → "Import CSV" (bulk wizard)
intent: onboard N users from a spreadsheet
sequenceDiagram
actor A as Org Admin / HR
participant W as ImportWizard
participant P as CsvService (isolate parse + preview)
participant API as POST /api/v1/users/import (multipart file)
participant S as UsersService.bulkImport
A->>W: pick file (CSV) — desktop drag-drop
W->>P: parse in isolate → header map + row count + preview 5 rows
P-->>W: preview table, column mapping (firstname|first_name …)
A->>W: confirm → upload with progress bar (client-side stage progress)
W->>API: multipart field "file" — users.controller.ts:104-110
API->>S: buffer → utf-8 → lines — users.service.ts:236-237
Note over S: synchronous loop per row — :250-279 (min 2 lines :238-243; header lowercased :244-247)
S-->>API: {imported: N, errors: ["Row 3: missing email", …]} — :281
API-->>W: 201 envelope
W-->>A: result screen: N imported, M failed — expandable error list per row
alt some rows failed
A->>W: "Download errors" (client-side CSV) → fix → re-upload
end
- Progress semantics: the endpoint is synchronous — there is no
progress percentage from the server and no polling endpoint. The wizard's
progress bar reflects client-side stages (parse → upload → server processing
indeterminate) — honest labeling required (OQ-6). Async queue import with
polling
(planned)perPLAN.md 2.7(POST /api/v1/files/upload-csv→ per-rowUserCreated). - Validation facts shown in preview: required columns
email, firstname|first_name, lastname|last_name; optionalphone, gender, language, timezone(users.service.ts:264-271); within-file duplicate emails are caught sequentially (:260-263); quoted commas are not supported by the naive parser (:251—split(',')) — warn on quotes (OQ-7). - Partial-import semantics: rows are committed as they go; a failed row
does not roll back previous rows. Re-upload after fixes re-checks
duplicates, so already-imported emails are skipped with an error
(
:260-263) — user must delete those rows or accept the errors. - Idempotency: retrying the same file is safe (duplicates are rejected,
not duplicated) — but each retry returns the dupes as errors (see
14_QA_Checklist.md C2). - Timeout: for very large files the request may exceed gateway timeouts —
client guidance ≤ 1000 rows (see
14_QA_Checklist.md C1); upload timeout 120 s (00-shared/11 §5).
3. Edit profile & preferences (self-service)
entry: avatar menu → Profile (self) — or admin: user row → Edit
intent: update contact info / language / timezone / notification toggles / theme
sequenceDiagram
actor U as User (self) / Admin (managed)
participant F as EditUserScreen / PreferencesScreen
participant API as PATCH /api/v1/users/:id (+ /preferences)
participant S as UsersService.update / updatePreferences
U->>F: change firstName/lastName/email/phone/…
F->>API: UpdateUserDto — update-user.dto.ts:5-70
alt 200 success
S-->>F: updated doc; displayName recomputed if names changed — users.service.ts:136-139
Note over API: UserUpdated emitted with changes list — :145-153 → audit-write — event-queue-map.ts:11
F-->>U: success snackbar; audit trail continues in background
else 409 (email/phone taken by another user)
S-->>F: ConflictException — :120-134 → inline field error
else 404 (cross-tenant or erased id)
S-->>F: NotFoundException — :118,144 → "user not found" empty state
end
U->>F: Preferences tab → toggle email/push/sms, theme mode light|dark|system
F->>API: PATCH /users/:id/preferences — update-user-preferences.dto.ts:4-21
Note over S: preferences replaced wholesale ($set preferences: dto) — users.service.ts:161-163 — send full object!
- Critical contract: preferences are replaced (
$set: {preferences: dto},users.service.ts:161-163) — the client must PATCH the full merged preferences object; a partial payload wipes unmentioned sections. - Optimistic UI: safe toggles (theme, notification switches) may be
optimistic with rollback (
00-shared/06 §3.5); email/phone changes are never optimistic (conflict-prone,00-shared/07 §9). - Self vs managed: controller accepts any
:id; client restricts "self profile" tosub == :id(server-side self guard(planned), OQ-3). - Avatar: separate multipart flow
POST /users/:id/avatar(users.controller.ts:95-102); old avatar deleted best-effort (users.service.ts:227-229).
4. Deactivate / soft-delete / GDPR erasure
entry: user row → menu → "Deactivate" | "Delete user" | "GDPR erase"
intent: stop access; remove from lists; comply with right-to-erasure
sequenceDiagram
actor A as Org Admin
participant F as UserDetailScreen
participant API as DELETE /api/v1/users/:id
participant P as TenantPurgeWorker
actor E as Erased user
A->>F: Deactivate → PATCH /users/:id {status: inactive}
F->>API: status change — update-user.dto.ts:52-55
API-->>F: 200 (user can no longer be found by scoped queries? no — inactive ≠ deleted; login still possible if credentials exist)
A->>F: Delete → typed-confirm dialog (destructive, 05_Global_IA §5)
F->>API: DELETE /users/:id — users.controller.ts:62-67
API->>API: softDelete: isDeleted, deletedAt, deletedBy — base.repository.ts:68-74
Note over API: UserDeleted → audit-write — event-queue-map.ts:12
F-->>A: row removed; snackbar "Purged after 30 days"
A->>F: GDPR → POST /users/:id/erasure — users.controller.ts:69-76
API->>API: anonymize PII + isDeleted — users.service.ts:187-197
API->>P: gdpr-erasure job (attempts 3, backoff exp 5s) — :200-209
P-->>E: hard-delete user doc — tenant-purge.worker.ts:49-56
- Status vs delete distinction (must be explicit in UI):
inactive— record visible in admin lists, can be re-activated, can still log in if anauth_accountexists (auth.service.ts:124-137checks onlyisDeleted).- Soft-deleted — cannot log in (
findByEmailUnscopedfiltersisDeleted:false,users.repository.ts:21-25), invisible everywhere, hard-purged after 30 days (tenant-purge.worker.ts:32-43). - Erased — anonymized immediately, hard-deleted by job.
- Active sessions caveat: deleting/erasing a user does not revoke
existing JWTs/sessions (no session revocation call — OQ-8);
logout-allexists (auth.controller.ts:66-72) but is not called by user deletion. - Failures: 404 if already gone; network loss after confirm → retry is idempotent (DELETE is safe to retry).
- Permission:
user.deletefor delete; erasure uses the same permission surface today (no separate perm) — note in UI copy.
5. Import failures & recovery
entry: bulk wizard → result screen with errors → fix & re-run
sequenceDiagram
actor A as Org Admin
participant W as ImportResultScreen
participant API as POST /api/v1/users/import
A->>W: result: imported=97, errors=[{Row 3…}, {Row 14…}, …] — users.service.ts:281
W-->>A: error rows in a table (row number, message) — copy = backend text ("Row 3: missing email", "Row 3: email x@y already exists")
A->>W: download error CSV (client-side) → edit in Excel
A->>W: re-upload corrected file
W->>API: retry — same endpoint
alt duplicate of already-imported email
API-->>W: "Row N: email already exists" — re-verify before upload: keep previously imported rows out
end
- Recovery rules: partial imports are expected; the UI must never imply
atomicity (server commits row-by-row —
users.service.ts:250-279); error list is authoritative; cross-check imported count vs. expected. - Import via students adapter (bulk module) reports richer errors
{rowNumber, errors[]}+totalRows/imported/failed(import-adapter.interface.ts:14-25) and pre-validates refs (students-import.adapter.ts:38-60); entity selector in the wizard(planned)for ausersadapter (onlystudentsexists —bulk-import.service.ts:17-20). - Exit: result screen can be closed; import is already committed — no cancel path (synchronous).
Journey matrix (who / where / what)
| Journey | Entry | Primary screens | Backend truth |
|---|---|---|---|
| Invite user | /users FAB | CreateUser + RBAC roles sheet | POST /users, POST /rbac/members |
| Bulk import | /users "Import CSV" | ImportWizard (upload→preview→result) | POST /users/import (sync) |
| Edit profile/prefs | Avatar menu / row edit | EditUser, Preferences | PATCH /users/:id, PATCH /users/:id/preferences |
| Deactivate / delete / erase | Row menu / detail | Confirm dialogs | PATCH status, DELETE /users/:id, POST /users/:id/erasure |
| Import recovery | Result screen | Error table + re-upload | retry POST /users/import |