Files
reactbin/specs/001-reaction-image-board/plan.md

355 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (M0M6), 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.