# Implementation Plan: Reaction Image Board v1

**Branch**: `001-reaction-image-board` | **Date**: 2026-05-02 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/001-reaction-image-board/spec.md`

## Summary

Build a self-hosted personal reaction image library: a FastAPI backend that
stores images in S3-compatible object storage (MinIO locally), persists
metadata and tags in PostgreSQL, and an Angular SPA that lets the user upload,
browse, filter, view, re-tag, and delete images. The project is split into
seven milestones (M0–M6), API-first, each leaving the system in a fully
working and tested state.

## Technical Context

**Language/Version**: Python 3.12+ (API); TypeScript strict mode (UI)
**Primary Dependencies**: FastAPI, SQLAlchemy 2.x async, asyncpg, Alembic,
  boto3/aiobotocore (API); Angular latest stable (UI)
**Storage**: PostgreSQL (relational), S3-compatible object storage via MinIO
  locally / AWS S3 in production
**Testing**: pytest + pytest-asyncio (API unit + integration); Angular Karma/Jest
  + TestBed (UI unit); E2E best-effort (constitution §5.2)
**Target Platform**: Linux server (containerised); modern evergreen desktop browsers
**Project Type**: Web application — separate API service + SPA
**Performance Goals**: First page of library < 2 s for 1 000 images; upload
  visible in library < 10 s on local network
**Constraints**: Single user, localhost-only in Phase 1; 50 MB upload cap;
  `docker compose up` is the only required start command
**Scale/Scope**: Personal use; v1 scope bounded per constitution §8

## Constitution Check

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

| Principle | Check | Status |
|---|---|---|
| §2.1 Separation of concerns | API service knows nothing about Angular; UI knows nothing about DB/S3 | ✅ |
| §2.2 Dependency direction | UI → API → Storage/DB; no upward imports | ✅ |
| §2.3 Storage abstraction | `StorageBackend` interface created before first S3 call (M1); no bucket names outside storage module | ✅ |
| §2.4 Auth abstraction | `AuthProvider` + `NoOpAuthProvider` wired in M1; all request-identity resolution goes through it | ✅ |
| §2.5 DB abstraction | All DB access through `ImageRepository` and `TagRepository`; no query logic outside repositories | ✅ |
| §2.6 No speculative abstraction | Only constitutionally-sanctioned interfaces (StorageBackend, AuthProvider, repositories) created | ✅ |
| §3.1 API versioning | All routes prefixed `/api/v1/` | ✅ |
| §3.3 Error shape | `{"detail": "...", "code": "..."}` enforced; integration tests verify envelope | ✅ |
| §3.4 Pagination | Every list endpoint has `limit`/`offset` from day one (M2) | ✅ |
| §4.1 Tag normalisation | Lowercase + trim applied before persistence (M2) | ✅ |
| §4.3 Dedup by hash | SHA-256 computed before storage write; existing hash returns existing record (M1) | ✅ |
| §4.4 Tag AND logic | List endpoint filters with AND semantics (M2) | ✅ |
| §5.1 TDD non-negotiable | **Tests are written FIRST and must fail before implementation in every milestone** | ✅ |
| §5.2 Test pyramid | Unit + integration tests required; E2E best-effort | ✅ |
| §5.3 Test colocation | API tests in `api/tests/`; Angular tests colocated with components | ✅ |
| §5.4 CI gate | All tests + linters must pass before a milestone is "done" | ✅ |
| §7.1 One-command start | M0 done-criterion is `docker compose up` starts all services | ✅ |
| §7.2 Env configuration | All config via env vars; `.env.example` committed in M0 | ✅ |
| §7.3 Linting | `ruff` (API) + `eslint`/`prettier` (UI) configured in M0, enforced | ✅ |
| §8 Scope boundaries | Bulk upload, OR/NOT tags, auth, image editing, multi-user — all deferred | ✅ |

**Post-design re-check**: See bottom of this file — updated after Phase 1 artifacts.

## Project Structure

### Documentation (this feature)

```text
specs/001-reaction-image-board/
├── plan.md         # This file
├── research.md     # Phase 0 decisions
├── data-model.md   # Entity definitions and DB schema
├── quickstart.md   # Local dev setup walkthrough
├── contracts/      # OpenAPI-aligned endpoint contracts
│   └── api.md
└── tasks.md        # Phase 2 output (/speckit-tasks — NOT created here)
```

### Source Code (repository root)

```text
api/
├── app/
│   ├── main.py             # FastAPI app factory, lifespan, middleware
│   ├── config.py           # Settings from env vars (pydantic-settings)
│   ├── dependencies.py     # FastAPI dependency injection (db session, auth)
│   ├── repositories/
│   │   ├── image_repo.py   # ImageRepository
│   │   └── tag_repo.py     # TagRepository
│   ├── storage/
│   │   ├── backend.py      # StorageBackend interface
│   │   └── s3_backend.py   # S3StorageBackend implementation
│   ├── auth/
│   │   ├── provider.py     # AuthProvider interface
│   │   └── noop.py         # NoOpAuthProvider
│   ├── routers/
│   │   ├── images.py       # /api/v1/images routes
│   │   └── tags.py         # /api/v1/tags route
│   └── models.py           # SQLAlchemy ORM models
├── alembic/
│   ├── alembic.ini
│   └── versions/           # Migration files
├── tests/
│   ├── unit/
│   └── integration/
├── pyproject.toml          # deps, ruff config
└── Dockerfile

ui/
├── src/
│   ├── app/
│   │   ├── app.component.*
│   │   ├── app.routes.ts
│   │   ├── services/
│   │   │   ├── image.service.ts   (+ .spec.ts)
│   │   │   └── tag.service.ts     (+ .spec.ts)
│   │   ├── library/
│   │   │   └── library.component.*  (+ .spec.ts)
│   │   ├── upload/
│   │   │   └── upload.component.*   (+ .spec.ts)
│   │   ├── detail/
│   │   │   └── detail.component.*   (+ .spec.ts)
│   │   └── not-found/
│   │       └── not-found.component.*
│   └── environments/
├── proxy.conf.json         # /api/* → API in dev
├── angular.json
├── package.json
└── Dockerfile

docker-compose.yml
.env.example
```

**Structure Decision**: Web application (Option 2 variant). `api/` contains
the FastAPI project; `ui/` contains the Angular project. Both have independent
dependency manifests and Dockerfiles per constitution §1.

## Milestones

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

### M0 — Project Skeleton

**Goal**: A running system with no features. Both services start, connect to
their dependencies, and respond to a health check.

**Deliverables** (implement only after failing tests exist):
- Monorepo layout per Project Structure above
- `docker-compose.yml`: PostgreSQL, MinIO, API, UI dev server
- `.env.example` with all variables from spec §5
- API: FastAPI app starts, connects to PostgreSQL (SQLAlchemy async + asyncpg)
  and MinIO (aiobotocore)
- API: `GET /api/v1/health` returns `{"status": "ok"}`
- API: Alembic configured, initial empty migration applied on startup
- API: `ruff` configured and passing in CI
- UI: Angular app scaffolded, routing in place, `HttpClient` with `API_BASE_URL`
- UI: proxy config routes `/api/*` to API in local dev
- UI: `eslint` + `prettier` configured and passing

**Tests (write first)**:
- API unit: settings loaded from env vars without error
- API integration: `GET /api/v1/health` → 200 `{"status": "ok"}`
- UI: Angular default smoke test passes

**Done when**: `docker compose up` starts all four services; health endpoint
returns 200; both linters and all tests pass.

---

### M1 — Image Upload (API only)

**Goal**: API accepts an image, validates it, deduplicates by hash, stores in
MinIO, and persists the record in PostgreSQL. Tags field accepted but ignored.

**Deliverables** (implement only after failing tests exist):
- Alembic migration: `images` table
- `StorageBackend` interface (`app/storage/backend.py`)
- `S3StorageBackend` implementation (`app/storage/s3_backend.py`)
- `AuthProvider` interface + `NoOpAuthProvider` (`app/auth/`)
- `ImageRepository`: `create`, `get_by_id`, `get_by_hash`
- `POST /api/v1/images`: MIME validation, size validation, SHA-256 hash,
  duplicate check, storage write, record insert, correct response shapes
  (201 new / 200 duplicate with `duplicate` field)

**Tests (write first)**:
- Unit: SHA-256 hash computation on known bytes
- Unit: MIME type validator rejects PDF, MP4, etc.
- Unit: file size validator rejects files over MAX_UPLOAD_BYTES
- Integration: valid JPEG upload → 201, record in DB, object in MinIO
- Integration: same image uploaded twice → 200, `duplicate: true`, no second
  MinIO object written
- Integration: invalid MIME type → 422 `invalid_mime_type` (error envelope
  must include `code` field)
- Integration: oversized file → 422 `file_too_large`

**Done when**: All tests pass; linter passes; duplicate detection works
end-to-end via curl.

---

### M2 — Tags (API only)

**Goal**: Tags are fully functional on the API. Upload persists tags; search
filters by tags; tags are editable; images can be deleted.

**Deliverables** (implement only after failing tests exist):
- Alembic migration: `tags` table + `image_tags` join table
- `TagRepository`: `upsert_by_name`, `get_by_image_id`, `replace_tags_on_image`
- Tag normalisation + validation (pattern `^[a-z0-9_-]{1,64}$` after lowercase+trim)
- `POST /api/v1/images`: `tags` field now processed and persisted
- `GET /api/v1/images` with `tags`, `limit`, `offset` (AND-filter)
- `GET /api/v1/images/{id}` (returns image + tags)
- `PATCH /api/v1/images/{id}/tags` (full tag replacement)
- `GET /api/v1/tags` with `q`, `limit`, `offset` (prefix search + image count)
- `DELETE /api/v1/images/{id}` (record + ImageTag rows + storage object)

**Tests (write first)**:
- Unit: normalisation: uppercase → lowercase, whitespace stripped
- Unit: validation: rejects names > 64 chars, rejects invalid chars
- Unit: AND-filter query produces correct WHERE clause
- Integration: upload with tags → tags persisted and returned
- Integration: duplicate upload → existing record returned, tags unchanged
- Integration: `GET /api/v1/images?tags=cat,funny` → only images with both tags
- Integration: same query excludes images with only one of the two tags
- Integration: PATCH replaces tags; old tags unlinked; new tags upserted
- Integration: `GET /api/v1/tags?q=ca` → tags prefixed "ca" with correct counts
- Integration: DELETE → 204; subsequent GET returns 404 `image_not_found`
- Integration: DELETE verifies storage object removed from MinIO

**Done when**: All prior tests still pass; full API functional via curl.

---

### M3 — Image Serving (API only)

**Goal**: Images can be viewed in a browser via pre-signed S3 URLs.

**Deliverables** (implement only after failing tests exist):
- `GET /api/v1/images/{id}/file` → generates 1-hour pre-signed URL, returns
  302 redirect with `Location` header

**Tests (write first)**:
- Integration: `GET /api/v1/images/{id}/file` → 302 with `Location` header
  pointing to MinIO URL
- Integration: unknown ID → 404 `image_not_found`

**Done when**: Pasting `http://localhost:8000/api/v1/images/{id}/file` into a
browser loads the image directly from MinIO.

---

### M4 — UI: Library View

**Goal**: Angular SPA displays uploaded images in a responsive grid with live
tag filtering. Upload button navigates but does not submit yet.

**Deliverables** (implement only after failing tests exist):
- `ImageService` wrapping `GET /api/v1/images` and `GET /api/v1/images/{id}/file`
- `TagService` wrapping `GET /api/v1/tags`
- `LibraryComponent` (route `/`): responsive grid, thumbnails via `/file`
  redirect, tag chips per image, debounced tag filter bar, "Load more"
  pagination (offset/limit), upload button → `/upload`, click → `/images/:id`

**Tests (write first)**:
- Unit: `ImageService` constructs correct query params from filter state
- Unit: `TagService` calls correct endpoint with `q` param
- Unit: `LibraryComponent` renders image grid from mocked service
- Unit: `LibraryComponent` filter change triggers new API call with updated
  `tags` param

**Done when**: Library view displays real images uploaded via curl in M2; tag
filtering and pagination work.

---

### M5 — UI: Upload View

**Goal**: Users can upload images and add tags through the browser.

**Deliverables** (implement only after failing tests exist):
- `UploadComponent` (route `/upload`): drag-and-drop zone, click-to-browse,
  tag chip input (comma/space separated), POST to API, duplicate toast →
  navigate to detail, success toast → navigate to detail, error → inline
  message, no navigation

**Tests (write first)**:
- Unit: tag chip input lowercases and splits on comma/space
- Unit: on `duplicate: true` response → toast shown, navigation triggered
- Unit: on `duplicate: false` response → success toast, navigation triggered
- Unit: on error response → error displayed, no navigation

**Done when**: Full upload flow works in browser including duplicate feedback.

---

### M6 — UI: Detail View

**Goal**: Users can view full-size images, edit tags, and delete images.
Completes the v1 feature set.

**Deliverables** (implement only after failing tests exist):
- `DetailComponent` (route `/images/:id`): full-size image via `/file`
  redirect, editable tag chips (× to remove, input to add), save via PATCH
  on blur/Enter, delete button with confirmation dialog → DELETE → navigate
  to Library, back button → Library (preserving tag filter state)
- `NotFoundComponent`: shown for all unrecognised routes

**Tests (write first)**:
- Unit: removing tag chip calls PATCH with updated list (removed tag absent)
- Unit: adding tag + Enter calls PATCH with new tag included
- Unit: delete confirmation → DELETE called → navigation to Library
- Unit: cancel on confirmation → no DELETE call, stays on detail page
- Unit: back button navigates to Library

**Done when**: Full CRUD loop works in browser: upload → view → re-tag →
delete. All tests across all milestones pass. Both linters pass. `docker
compose up` starts a fully working application.

---

## Dependency Graph

```
M0 (skeleton)
  └── M1 (upload API)
        └── M2 (tags API)
              └── M3 (serving API)
                    └── M4 (library UI)
                          └── M5 (upload UI)
                                └── M6 (detail UI)
```

Serial milestones: solo project; API must be stable before UI work begins.

## What This Plan Defers

Per constitution §8 and spec §6 — not forgotten, explicitly deferred:

- **Auth phases**: `AuthProvider` interface wired in M1 (no-op). Phase 2
  (username/password) and Phase 3 (OIDC) each get their own plan.
- **SQLite refactor**: Repository layer is the only thing that changes in a
  future plan.
- **Bulk upload**: Out of scope per spec §6.
- **Tag rename/merge on re-upload**: Spec §2.1 explicitly defers; future spec
  revision adds behaviour to M2.

## Post-Design Constitution Re-check

*Performed after Phase 1 artifacts (data-model.md, contracts/api.md,
quickstart.md) were generated.*

No new violations found. All constitutionally-required abstractions appear in
the data model and contracts. The `StorageBackend` interface is referenced
correctly in the API contract without exposing bucket names. Test colocation
confirmed in project structure above.
