# Design — Profile picture upload

> Companion to [`./spec.md`](./spec.md). The spec says *what*; this says *how*, and records
> the alternatives weighed for the non-obvious choices.

## Component overview

```
ProfileSettings.svelte ──► +page.server.ts (form action)
        (preview, validate)        │ POST /api/users/me/avatar (multipart)
                                    ▼
                         UserAvatarController  ── @RequirePermission(authenticated)
                                    │            ownership/admin check for /{id}
                                    ▼
                            UserService.setAvatar(userId, MultipartFile)
                                    │ validate type+size → ErrorCode
                                    ├──► FileService.put("avatars/{userId}", bytes)   (MinIO)
                                    └──► userRepository.save(user.avatarObjectKey=key)
                                    ▼
                            UserProfileView { …, avatarUrl }
```

Reads: `GET /api/users/{id}/avatar` streams the object through the authenticated API
(`FileService.get`), so no public S3 URL is ever exposed. `avatarUrl` in the view is simply
`/api/users/{id}/avatar` when a key exists, else `null`.

## Key decisions

| Decision | Choice | Why |
|---|---|---|
| Where avatars live | Existing archive bucket, `avatars/{userId}` prefix | No new bucket/env var/Compose change — see [ADR-001](./adr-001-avatars-reuse-archive-bucket.md). |
| URL exposure | Authenticated proxy endpoint, not a signed/public URL | Same auth surface as the rest of the API; no key leakage (Information disclosure). |
| Object key | Deterministic `avatars/{userId}` (no random suffix) | A new upload overwrites the old object — no orphan-cleanup job needed (REQ-001). |
| `avatarObjectKey` binding | Server-set in `UserService` only | Never bound from request body — prevents pointing a user's avatar at an arbitrary object (Tampering / CWE-639). |
| Validation site | `UserService`, boundary-only | Type + size checked once, at the service boundary, mapped to `ErrorCode` (constitution §2.3). |

## Layering & conventions

- Controller → `UserService` only; `UserService` owns `userRepository` and calls
  `FileService` (its public API), never another domain's repository. (constitution §1.2–1.3)
- New `ErrorCode.AVATAR_TOO_LARGE` requires the four-site update (see `tasks.md` T-1).
- `UserProfileView.avatarUrl` is `String` (nullable) with `@Schema` describing the proxy
  path; not marked `requiredMode = REQUIRED` because it is legitimately null (REQ-004).
- After backend changes: `npm run generate:api` regenerates `avatarUrl` into the TS types.

## Non-functional notes

- Size cap (2 MB, REQ-008) is enforced **before** the object touches MinIO — the multipart
  is read into a bounded buffer; Spring's `spring.servlet.multipart.max-file-size` is set to
  a matching ceiling so an oversized body is rejected at the container edge too.
- No N+1 risk: the profile view derives `avatarUrl` from the already-loaded `avatarObjectKey`
  column; no extra query, no S3 round-trip on list/read paths.
- The proxy `GET` streams bytes (no full-buffer) and sets a short `Cache-Control` so an
  updated avatar propagates quickly.

## Test strategy (maps to tasks.md)

| Level | What | Tooling |
|---|---|---|
| Unit | `UserService.setAvatar` validation + storage interactions | JUnit + Mockito (mock `FileService`) |
| Slice | controller auth, status codes, error codes | `@WebMvcTest` |
| E2E | upload → preview → confirm → avatar visible; remove → initials | Playwright |
| Component | initials placeholder when `avatarUrl` is null | `vitest-browser-svelte` (`*.svelte.spec.ts`) |
