# Implementation Plan: Upload Thumbnails

**Branch**: `003-upload-thumbnails` | **Date**: 2026-05-03 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/003-upload-thumbnails/spec.md`

## Summary

When an image is uploaded, generate a WebP thumbnail (longest side ≤ 400 px,
aspect ratio preserved) and store it in S3 alongside the original. Add a
`GET /api/v1/images/{id}/thumbnail` endpoint that serves the thumbnail (or falls
back to the original for images that pre-date the feature). The Angular library
grid switches from `/file` to `/thumbnail`. The detail page is unchanged.

Changes span: a new Pillow dependency, a new `thumbnail.py` utility module, one
Alembic migration, the upload and delete routes, a new thumbnail serve route, and
the Angular image service and library component.

## Technical Context

**Language/Version**: Python 3.12+ (API); TypeScript strict mode (UI)
**Primary Dependencies**: FastAPI, Pillow (new — thumbnail generation), aiobotocore,
  SQLAlchemy 2.x async, Alembic, Angular
**Storage**: S3-compatible object storage via `StorageBackend.put()` and `.get()`;
  thumbnails stored at key `{sha256_hash}-thumb` in the same bucket
**Testing**: pytest + pytest-asyncio (API); Angular Karma/Jest + TestBed (UI)
**Target Platform**: Linux server (containerised); modern evergreen desktop browsers
**Project Type**: Web application — FastAPI API + Angular SPA
**Performance Goals**: 20-image grid transfers ≥ 80% less data than full-size;
  first page of 1,000-image library loads in under 2 s
**Constraints**: Thumbnail generation is synchronous within the upload request;
  thumbnail failure must not block upload success; no backfill of existing images in v1
**Scale/Scope**: Single-user personal application; upload frequency low

## Constitution Check

*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design below.*

| Principle | Check | Status |
|-----------|-------|--------|
| §2.1 Separation of concerns | `thumbnail.py` owns resize logic; router owns orchestration; UI knows nothing about S3 keys | ✅ |
| §2.2 Dependency direction | UI → API → Storage; thumbnail stored via `StorageBackend`; no upward imports | ✅ |
| §2.3 Storage abstraction | All thumbnail I/O via `StorageBackend.put()` and `.get()`; no raw S3 SDK calls in routes or `thumbnail.py` | ✅ |
| §2.4 Auth abstraction | No change to auth flow | ✅ |
| §2.5 DB abstraction | New `thumbnail_key` column accessed only through `ImageRepository`; migration added | ✅ |
| §2.6 No speculative abstraction | `thumbnail.py` is a concrete module-level function; no interface added because one implementation exists | ✅ |
| §3.1 API versioning | New route at `/api/v1/images/{id}/thumbnail` | ✅ |
| §3.3 Error shape | `image_not_found` and `storage_error` codes used consistently | ✅ |
| §4.2 Images immutable after upload | Thumbnail is generated at upload time only; never mutated | ✅ |
| §4.3 Dedup by hash | Duplicate upload returns existing record including existing `thumbnail_key`; no re-generation | ✅ |
| §5.1 TDD non-negotiable | Failing tests written before every implementation task | ✅ |
| §5.2 Test pyramid | Unit test for `thumbnail.py`; integration tests for new route + upload + delete | ✅ |
| §5.3 Test colocation | API tests in `api/tests/`; Angular spec files colocated with components | ✅ |
| §5.4 CI gate | All tests + ruff must pass before milestone is done | ✅ |
| §7.1 One-command start | No change to `docker-compose.yml` required | ✅ |
| §7.2 Env configuration | No new env vars; Pillow is a build dependency, not a runtime config | ✅ |
| §8 Scope boundaries | Backfill of existing images, multiple thumbnail sizes, animated WebP — all deferred | ✅ |

**Post-design re-check**: All gates still pass after Phase 1 design.

## Project Structure

### Documentation (this feature)

```text
specs/003-upload-thumbnails/
├── plan.md              # This file
├── spec.md              # Feature specification
├── research.md          # Phase 0 decisions
├── data-model.md        # Schema and module changes
├── contracts/
│   └── api.md           # New endpoint + changed response shapes
├── checklists/
│   └── requirements.md  # Spec quality checklist
└── tasks.md             # Phase 2 output (/speckit-tasks — NOT created here)
```

### Files changed or created

```text
api/
├── pyproject.toml                              # Add Pillow dependency
├── app/
│   ├── thumbnail.py                            # NEW — generate_thumbnail()
│   ├── models.py                               # Add thumbnail_key column to Image
│   ├── repositories/
│   │   └── image_repo.py                       # Pass thumbnail_key on create
│   └── routers/
│       └── images.py                           # Upload: generate+store thumbnail
│                                               # Delete: remove thumbnail
│                                               # New route: GET /images/{id}/thumbnail
│                                               # _image_to_dict: add thumbnail_key field
└── alembic/
    └── versions/
        └── 002_add_thumbnail_key.py            # NEW migration

ui/
└── src/
    └── app/
        ├── services/
        │   └── image.service.ts                # Add getThumbnailUrl(); add thumbnail_key to ImageRecord
        └── library/
            └── library.component.ts            # Use getThumbnailUrl() for grid image src
```

## Milestones

> **TDD ORDER IS MANDATORY** (constitution §5.1): For every milestone, write
> the failing test(s) first, confirm they fail, then implement until they pass.

---

### M1 — Thumbnail generation utility

**Goal**: A tested, self-contained function that produces a WebP thumbnail from
raw image bytes.

**Deliverables**:
- Add `pillow>=10.0` to `[project.dependencies]` in `api/pyproject.toml`
- Create `api/app/thumbnail.py` with `generate_thumbnail(data: bytes, mime_type: str) -> bytes`:
  - Open image bytes with Pillow
  - Seek to frame 0 (handles animated GIFs)
  - Convert mode as needed for WebP output
  - Resize to fit within 400×400 using LANCZOS resampling (never upscale)
  - Encode as WebP quality 80 and return bytes
- Unit tests in `api/tests/unit/test_thumbnail.py`:
  - `test_thumbnail_is_webp` — output starts with WebP magic bytes
  - `test_thumbnail_fits_within_400px` — both dimensions ≤ 400
  - `test_thumbnail_preserves_aspect_ratio` — ratio within 1% of original
  - `test_thumbnail_handles_gif_first_frame` — GIF input produces static WebP
  - `test_thumbnail_handles_png_with_alpha` — RGBA PNG produces valid WebP
  - `test_thumbnail_does_not_upscale` — 100×100 image stays ≤ 100×100

**Done criterion**: All unit tests pass; `ruff check api/` passes.

---

### M2 — Database migration

**Goal**: The `images` table has a nullable `thumbnail_key` column; the ORM model
and repository reflect it.

**Deliverables**:
- `api/alembic/versions/002_add_thumbnail_key.py` — `upgrade` adds
  `VARCHAR(70) NULLABLE` column; `downgrade` drops it
- `api/app/models.py`: add `thumbnail_key: Mapped[str | None]` mapped to
  `String(70)`, `nullable=True`, `default=None`
- `api/app/repositories/image_repo.py`: add `thumbnail_key: str | None = None`
  parameter to `create()`; persist it on the new `Image` instance

**Done criterion**: `alembic upgrade head` runs cleanly inside Docker; all
existing 46 integration tests still pass (new column is nullable, no existing
test breaks).

---

### M3 — Upload route: generate and store thumbnail

**Goal**: Every new upload generates a thumbnail; duplicates reuse the existing
record; failures are tolerated without blocking the upload.

**TDD first** — new tests in `api/tests/integration/test_upload.py`:
- `test_upload_returns_thumbnail_key` — upload response includes non-null
  `thumbnail_key` ending in `-thumb`
- `test_duplicate_upload_reuses_thumbnail_key` — second upload of the same
  file returns the same `thumbnail_key` as the first
- `test_upload_succeeds_when_thumbnail_fails` — patch `generate_thumbnail` to
  raise, upload returns 200/201 with `thumbnail_key: null`

**Implementation** in `api/app/routers/images.py` `upload_image()`:
1. After `await storage.put(hash_hex, data, mime_type)`, attempt thumbnail generation
   and storage in a try/except; catch any exception and leave `thumbnail_key` as `None`
2. Call `thumb_bytes = await asyncio.to_thread(generate_thumbnail, data, mime_type)`
   — `generate_thumbnail` is CPU-bound (Pillow); `asyncio.to_thread` runs it in the
   default thread pool executor so it does not block the async event loop
3. Pass `thumbnail_key` to `image_repo.create()`
4. Add `"thumbnail_key": image.thumbnail_key` to `_image_to_dict()`

**Done criterion**: New tests pass; all 46 existing tests pass.

---

### M4 — New `GET /api/v1/images/{id}/thumbnail` endpoint

**Goal**: Clients fetch thumbnail content; falls back to original if no thumbnail exists.

**TDD first** — new tests in `api/tests/integration/test_serving.py`:
- `test_thumbnail_returns_webp` — upload image, call `/thumbnail`, assert
  200, `content-type: image/webp`, ETag, `Cache-Control` with immutable
- `test_thumbnail_fallback_returns_original` — set `thumbnail_key=None` on a
  record, call `/thumbnail`, assert 200 with original mime_type
- `test_thumbnail_unknown_id_returns_404` — unknown UUID → 404 `image_not_found`

**Implementation**: new route `GET /images/{image_id}/thumbnail` in
`api/app/routers/images.py` using `image.thumbnail_key or image.storage_key`
to select the key, and `"image/webp" if image.thumbnail_key else image.mime_type`
for the content type. Same `ETag` + `Cache-Control` headers as `/file`.

**Done criterion**: All new tests pass; all existing tests pass.

---

### M5 — Delete route: remove thumbnail from storage

**Goal**: Deleting an image also removes its thumbnail; no orphaned objects left.

**TDD first** — new test in `api/tests/integration/test_delete.py`:
- `test_delete_removes_thumbnail` — upload image, delete it, then verify
  `GET /images/{id}/thumbnail` returns 404

**Implementation** in `api/app/routers/images.py` `delete_image()`: after
deleting the DB record and the original object, call `await storage.delete(image.thumbnail_key)`
if `image.thumbnail_key` is not None.

**Done criterion**: New test passes; all existing delete tests pass.

---

### M6 — UI: library grid uses thumbnail endpoint

**Goal**: Library grid fetches thumbnails instead of full-size originals; detail
page is unchanged.

**Deliverables**:
- `ui/src/app/services/image.service.ts`:
  - Add `thumbnail_key: string | null` to `ImageRecord` interface
  - Add `getThumbnailUrl(id: string): string` returning `/api/v1/images/${id}/thumbnail`
- `ui/src/app/library/library.component.ts` + template: replace
  `getFileUrl(image.id)` with `getThumbnailUrl(image.id)` for grid `<img src>`
- Update Angular spec files: add `thumbnail_key: null` to all `ImageRecord`
  mock objects
- Verify `ng test` passes and `ng build` succeeds

**Done criterion**: Angular build clean; all Angular tests pass; library grid
`<img>` elements reference `/thumbnail` not `/file`.

## Post-design Constitution Re-check

| Principle | Verdict |
|-----------|---------|
| §2.3 Storage abstraction | All thumbnail I/O via `StorageBackend`; `thumbnail.py` never touches S3 directly | ✅ |
| §2.5 DB abstraction | `thumbnail_key` persisted only through `ImageRepository.create()` | ✅ |
| §2.6 No speculative abstraction | One concrete function; no interface | ✅ |
| §4.2 Immutability | Thumbnail written once at upload; never mutated | ✅ |
| §5.1 TDD | Failing tests written before each milestone's implementation | ✅ |

All gates pass. Feature is ready for `/speckit-tasks`.
