Introduces the SDD root: a v1.0.0 constitution and machine-readable AGENTS.md grounded in the project's real conventions; six EARS-aware persona spec-review checklists that cross-reference .claude/personas/; feature-spec/ADR/threat-model/ api-contract templates; a fully worked _example feature; a living RTM; and an adrs/ pointer that reuses the existing docs/adr/ archive. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
119 lines
8.5 KiB
Markdown
119 lines
8.5 KiB
Markdown
# 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 |
|