# As a user I want to upload a profile picture so other family members recognise me > **This is the canonical worked example for SDD in this repo.** It is fictional but > realistic, chosen because no real avatar feature exists in the codebase. Use it as the > reference shape for a real `spec.md`. Every section is filled — no placeholders. ## Context & Why Readers and transcribers collaborate in threads and on document comments, but every user is currently represented by initials only. Letting a user upload a small profile picture makes the activity feed, comments, and the public user profile page (`/users/[id]`) more personal and easier to scan — directly serving the family-archive product goal of feeling like a shared family space, not a database. Constitution principles this feature depends on: - [§2 Security Defaults](../../constitution.md#2-security-defaults) — upload validation, permission gating, no PII in logs. - [§1.3 services own their repository](../../constitution.md#1-architecture-principles) — avatar storage goes through `UserService` + `FileService`, not a controller. - [§3.6 ErrorCode four-site rule](../../constitution.md#3-code-quality-rules) — introduces `AVATAR_TOO_LARGE`. Related: builds on the existing `FileService` (MinIO) used by `Document` uploads. ## User Journey A logged-in user opens their profile settings (`/profile`), clicks "Profilbild ändern", selects a PNG or JPEG from their device, sees an instant preview, and confirms. The picture replaces their initials everywhere their name appears. They can later remove it and fall back to initials. An admin (with `ADMIN_USER`) can remove an inappropriate picture from another user's account from the admin user view. ## Requirements - **REQ-001** (Ubiquitous) — The user service shall store each profile picture as a single object in the existing archive bucket under the key `avatars/{userId}`, overwriting any previous object for that user. - **REQ-002** (Event-driven) — When an authenticated user sends `POST /api/users/me/avatar` with a valid image, the user service shall store the image, set the user's `avatarObjectKey`, and return the updated profile view including a non-null `avatarUrl`. - **REQ-003** (Event-driven) — When an authenticated user sends `DELETE /api/users/me/avatar`, the user service shall delete the stored object, clear `avatarObjectKey`, and return the profile view with `avatarUrl = null`. - **REQ-004** (State-driven) — While a user has no stored avatar, the profile view for that user shall return `avatarUrl = null` and the frontend shall render the initials placeholder. - **REQ-005** (Optional-feature) — Where the caller holds `Permission.ADMIN_USER`, the user service shall allow `DELETE /api/users/{id}/avatar` to remove another user's avatar. - **REQ-006** (Unwanted-behavior) — If the request to any avatar endpoint is unauthenticated, then the system shall return `401` with `ErrorCode.UNAUTHORIZED` and store or delete nothing. - **REQ-007** (Unwanted-behavior) — If the uploaded file's content type is not `image/png` or `image/jpeg`, then the user service shall return `400 ErrorCode.UNSUPPORTED_FILE_TYPE` and store nothing. - **REQ-008** (Unwanted-behavior) — If the uploaded file exceeds 2 MB, then the user service shall return `400 ErrorCode.AVATAR_TOO_LARGE` and store nothing. - **REQ-009** (Unwanted-behavior) — If a caller without `Permission.ADMIN_USER` targets another user's avatar via `/api/users/{id}/avatar`, then the system shall return `403 ErrorCode.FORBIDDEN` and modify nothing. ## Acceptance Criteria - **REQ-001** — After a successful upload, exactly one object exists at `avatars/{userId}`; a second upload leaves exactly one object (no orphan), verified by a `FileService` interaction test. - **REQ-002** — `POST /api/users/me/avatar` with a 100 KB PNG returns `200` and a body whose `avatarUrl` is a non-null string; the persisted `app_users.avatar_object_key` equals `avatars/{userId}`. - **REQ-003** — `DELETE /api/users/me/avatar` returns `200`, the object is gone, and the response `avatarUrl` is `null`. - **REQ-004** — `GET` profile view for a user with `avatar_object_key IS NULL` returns `avatarUrl: null`; the rendered component shows a 2-letter initials placeholder (Playwright). - **REQ-005** — An `ADMIN_USER` caller deleting another user's avatar returns `200`; the target's `avatar_object_key` becomes `NULL`. - **REQ-006** — An unauthenticated `POST`/`DELETE` returns `401`; bucket object count is unchanged. - **REQ-007** — A `text/plain` or `application/pdf` upload returns `400 UNSUPPORTED_FILE_TYPE`; bucket object count is unchanged. - **REQ-008** — A 2.1 MB PNG returns `400 AVATAR_TOO_LARGE`; bucket object count is unchanged. - **REQ-009** — A non-admin caller targeting another user's id returns `403 FORBIDDEN`; the target's `avatar_object_key` is unchanged. ## Out of Scope - Image cropping, resizing, or transformation — the client sends a final image; the server stores it verbatim within the size limit. - Avatars for historical `Person` entities — this feature is for `AppUser` accounts only (Person ≠ AppUser). - Gravatar / external avatar providers. - Animated formats (GIF/WebP) — PNG and JPEG only in v1. ## API / Contract Stub See [`./api-contract.yaml`](./api-contract.yaml). Endpoints: `POST /api/users/me/avatar` (multipart), `DELETE /api/users/me/avatar`, `DELETE /api/users/{id}/avatar` (ADMIN_USER). The profile view gains an optional `avatarUrl: string | null`. All mutating endpoints carry `@RequirePermission` — `me` endpoints require an authenticated session; the `{id}` delete requires `ADMIN_USER`. ## Data Model Changes - Add nullable `avatar_object_key VARCHAR(512)` to `app_users`. - Flyway `V78__add_app_user_avatar_object_key.sql` (next free number — verify against `backend/src/main/resources/db/migration/` on disk before committing). - **Rollback:** forward-only. Reverse manually with `ALTER TABLE app_users DROP COLUMN avatar_object_key;`. The MinIO `avatars/` objects are orphaned but harmless on rollback and can be pruned with `mc rm --recursive`. ## Security Considerations STRIDE categories touched: **Tampering** (mass-assignment of `avatarObjectKey` if bound from body), **Elevation of privilege** (a non-admin modifying another user's avatar — REQ-009), **Denial of service** (oversized upload — REQ-008), **Information disclosure** (avatar URL must not expose a signed key that bypasses auth). No AI agent involved, so ASTRIDE does not apply. Full analysis: [`./threat-model.md`](./threat-model.md). ## Open Questions > All resolved before implementation. - [x] Public or signed avatar URL? — **Resolved:** served through an authenticated `GET /api/users/{id}/avatar` proxy (same auth as the rest of the API), not a public S3 URL. - [x] New bucket or reuse archive bucket? — **Resolved:** reuse the archive bucket under an `avatars/` prefix; see [`./adr-001-avatars-reuse-archive-bucket.md`](./adr-001-avatars-reuse-archive-bucket.md). ## Traceability | REQ-ID | Task ID(s) | Test ID(s) | Status | |---|---|---|---| | REQ-001 | T-3 | `UserServiceAvatarTest#storesUnderUserKey`, `…#replaceLeavesNoOrphan` | Planned | | REQ-002 | T-4 | `UserAvatarControllerTest#uploadReturnsAvatarUrl` | Planned | | REQ-003 | T-5 | `UserAvatarControllerTest#deleteClearsAvatar` | Planned | | REQ-004 | T-7 | `avatar-placeholder.svelte.spec.ts` | Planned | | REQ-005 | T-6 | `UserAvatarControllerTest#adminDeletesOthersAvatar` | Planned | | REQ-006 | T-2 | `UserAvatarControllerTest#unauthenticatedReturns401` | Planned | | REQ-007 | T-2 | `UserAvatarControllerTest#rejectsNonImage` | Planned | | REQ-008 | T-2 | `UserAvatarControllerTest#rejectsOversize` | Planned | | REQ-009 | T-6 | `UserAvatarControllerTest#nonAdminForbiddenOnOthers` | Planned | Mirrored in [`.specify/rtm.md`](../../rtm.md). ## Persona Review Results | Persona | Status | Key Findings | Resolved | |---|---|---|---| | Requirements Engineer | APPROVE | All 9 REQ ids EARS-formed; every limit has an `If` clause. | — | | Developer | APPROVE | Reuses `FileService`/`UserService`; `AVATAR_TOO_LARGE` four-site update listed (T-1). | — | | Security | APPROVE | REQ-006/008/009 cover authn/DoS/EoP; `avatarObjectKey` server-set only (see threat model T-1). | Yes | | DevOps | APPROVE | V78 forward-only with rollback note; no new bucket/env var. | — | | UI/UX | APPROVE | Placeholder + loading/error states specified; strings via i18n (T-8). | — | | Architect | APPROVE | Bucket-reuse decision captured in ADR-001; no new domain, no boundary crossing. | Yes |