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.
┌─────────────────────────────────────────────┐
│ [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"] │
└─────────────────────────────────────────────┘
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.
State Behavior
loading AppSkeleton rows (n=6)
success grouped rows, newest first; size formatted KB/MB (size, file.schema.ts:16)
empty AppEmptyState "No files yet — attach the first one" + Attach CTA
error inline banner + retry (re-run GET /files); offline via AppOfflineBanner
permission 403 → locked row style + "Ask admin for access" (file.read missing)
disabled upload button hidden when file.upload absent; delete menu hidden when file.delete absent
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.
files.list.refresh, files.list.open_detail, files.list.download, files.list.delete,
files.list.attach_open.
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.
┌─────────────────────────────────────────────┐
│ Attach file [× close] │
├─────────────────────────────────────────────┤
│ [Choose file…] (system picker) │
│ [Take photo] (forward-looking) │
├─────────────────────────────────────────────┤
│ Selected: [FileTypeIcon] syllabus.pdf │
│ 1.4 MB · application/pdf │
├─────────────────────────────────────────────┤
│ [Upload] (AppButton filled, fullWidth) │
└─────────────────────────────────────────────┘
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).
Rule Value Verdict
File required — block Upload; sheet error "Choose a file"
Size cap none enforced (gap)client soft-cap 25 MB w/ warning; hard user block (proposed)
MIME allowlist none server-side client may gate common doc/image types (proposed)
Filename any (un-sanitized, files.service.ts:31) display-only; quote in header Content-Disposition (files.controller.ts:61)
State Behavior
idle picker rows only; Upload disabled
picking system picker open (platform sheet)
selected preview card; Upload enabled; re-pick replaces selection
uploading UploadProgressCard (§2.5); Cancel available
success sheet closes → tile appears in list (optimistic insert, rollback on fail)
error inline: 401/403 (auth, (planned)), 413 (planned) size, 500 provider; Retry re-POSTs (new record; no server idempotency)
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).
Sheet slides up m-base; live-region announces "Uploading filename — 40%".
All touch targets ≥ 44 dp; picker rows are single Semantics(button:).
┌─────────────────────────────────────────────┐
│ [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] │
└─────────────────────────────────────────────┘
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).
State Behavior
loading skeleton card
not found sheet closes; row removed from list; snackbar "File no longer available"
permission 403 → lock + admin note
Tap download → GET /api/v1/files/:id/download (files.controller.ts:55-64).
Service: findById → storage.download(storageFileId) → {buffer, mimeType, filename}
(files.service.ts:58-64).
Response headers: Content-Type: <stored mimeType> (files.controller.ts:60),
Content-Disposition: attachment; filename="<originalName>" (files.controller.ts:61).
Body: raw bytes (res.send(result.buffer), files.controller.ts:63) — proxied,
fully buffered ; no redirect, no signed URL (those are (planned)).
State Behavior
starting progress row appears (0%) — client-computed from transport
progress % bytes received; cancel allowed (no server resume; no Range support)
success web: browser save; mobile: system notification + "Open" share (forward-looking)
failure inline retry; 404 → row dropped; 403 → permission state
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).
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.
State Behavior
confirming destructive AppDialog (red confirm, m-fast)
pending button spinner; double-tap ignored
success optimistic row removal + snackbar; no undo endpoint (proposed: restore)
error 403 (permission revoked) / 500 → dialog error inline; row stays
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."
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).
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).