[Spec Kit] Initial commit — constitution, spec, plan, and tasks for Reaction Image Board v1

This commit is contained in:
2026-05-02 15:56:39 +00:00
commit 691f7570fe
46 changed files with 6149 additions and 0 deletions

View File

@@ -0,0 +1,354 @@
# 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.