# 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.