355 lines
15 KiB
Markdown
355 lines
15 KiB
Markdown
# 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.
|