15 KiB
Implementation Plan: Reaction Image Board v1
Branch: 001-reaction-image-board | Date: 2026-05-02 | Spec: 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 upis 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)
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)
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.examplewith all variables from spec §5- API: FastAPI app starts, connects to PostgreSQL (SQLAlchemy async + asyncpg) and MinIO (aiobotocore)
- API:
GET /api/v1/healthreturns{"status": "ok"} - API: Alembic configured, initial empty migration applied on startup
- API:
ruffconfigured and passing in CI - UI: Angular app scaffolded, routing in place,
HttpClientwithAPI_BASE_URL - UI: proxy config routes
/api/*to API in local dev - UI:
eslint+prettierconfigured 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:
imagestable StorageBackendinterface (app/storage/backend.py)S3StorageBackendimplementation (app/storage/s3_backend.py)AuthProviderinterface +NoOpAuthProvider(app/auth/)ImageRepository:create,get_by_id,get_by_hashPOST /api/v1/images: MIME validation, size validation, SHA-256 hash, duplicate check, storage write, record insert, correct response shapes (201 new / 200 duplicate withduplicatefield)
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 includecodefield) - 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:
tagstable +image_tagsjoin 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:tagsfield now processed and persistedGET /api/v1/imageswithtags,limit,offset(AND-filter)GET /api/v1/images/{id}(returns image + tags)PATCH /api/v1/images/{id}/tags(full tag replacement)GET /api/v1/tagswithq,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 withLocationheader
Tests (write first):
- Integration:
GET /api/v1/images/{id}/file→ 302 withLocationheader 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):
ImageServicewrappingGET /api/v1/imagesandGET /api/v1/images/{id}/fileTagServicewrappingGET /api/v1/tagsLibraryComponent(route/): responsive grid, thumbnails via/fileredirect, tag chips per image, debounced tag filter bar, "Load more" pagination (offset/limit), upload button →/upload, click →/images/:id
Tests (write first):
- Unit:
ImageServiceconstructs correct query params from filter state - Unit:
TagServicecalls correct endpoint withqparam - Unit:
LibraryComponentrenders image grid from mocked service - Unit:
LibraryComponentfilter change triggers new API call with updatedtagsparam
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: trueresponse → toast shown, navigation triggered - Unit: on
duplicate: falseresponse → 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/fileredirect, 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:
AuthProviderinterface 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.