[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,37 @@
# Specification Quality Checklist: Reaction Image Board v1
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-02
**Feature**: [../spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass. Spec is ready for `/speckit-plan`.
- The source technical spec (docs/SPEC.md) covers API contracts, storage
behaviour, and UI screen details — those details are available for
the planning phase but were intentionally kept out of this user-facing spec.

View File

@@ -0,0 +1,216 @@
# API Contract: Reaction Image Board v1
**Base URL**: `/api/v1`
**Format**: JSON (application/json) for all request/response bodies except
file uploads (multipart/form-data)
**Auth**: None in v1 (NoOpAuthProvider)
**Error envelope** (all 4xx/5xx responses):
```json
{ "detail": "<human-readable message>", "code": "<machine-readable code>" }
```
---
## Image Resource
### Image object shape
```json
{
"id": "uuid",
"hash": "sha256-hex-string-64-chars",
"filename": "original.jpg",
"mime_type": "image/jpeg",
"size_bytes": 102400,
"width": 800,
"height": 600,
"storage_key": "sha256-hex-string-64-chars",
"created_at": "2026-05-01T12:00:00Z",
"tags": ["cat", "funny"],
"duplicate": false
}
```
`duplicate` is only present on POST responses; omit or set to `false` on GET.
---
### POST /api/v1/images — Upload Image
**Request**: `Content-Type: multipart/form-data`
| Field | Required | Type | Notes |
|---|---|---|---|
| `file` | yes | file | The image binary |
| `tags` | no | string | Comma-separated, e.g. `"cat,funny,reaction"` |
**Processing**:
1. Validate MIME type → 422 `invalid_mime_type` if not accepted
2. Validate file size ≤ MAX_UPLOAD_BYTES → 422 `file_too_large` if exceeded
3. Compute SHA-256 hash of raw bytes
4. Query DB for existing record with same hash
5. If duplicate: return existing record, `duplicate: true`, HTTP 200
6. If new: write to storage, insert record, upsert tags, HTTP 201
**Responses**:
| Status | Condition | Body |
|---|---|---|
| 201 | New image stored | Image object with `duplicate: false` |
| 200 | Duplicate detected | Image object with `duplicate: true` |
| 422 | Invalid MIME type | `{"detail":"...", "code":"invalid_mime_type"}` |
| 422 | File too large | `{"detail":"...", "code":"file_too_large"}` |
| 422 | Invalid tag | `{"detail":"...", "code":"invalid_tag"}` |
---
### GET /api/v1/images — List / Search Images
**Query parameters**:
| Param | Type | Default | Notes |
|---|---|---|---|
| `tags` | string | — | Comma-separated. AND logic — all tags must be present. |
| `limit` | integer | 50 | Max 100 |
| `offset` | integer | 0 | Pagination offset |
**Response** 200:
```json
{
"items": [ { ...image object... } ],
"total": 142,
"limit": 50,
"offset": 0
}
```
Each item includes the `tags` array. `duplicate` field omitted.
Results ordered by `created_at` descending.
---
### GET /api/v1/images/{id} — Get Single Image
**Path**: `{id}` is a UUID.
**Response** 200: Single image object (tags included, `duplicate` omitted).
| Status | Code | Condition |
|---|---|---|
| 404 | `image_not_found` | No image with that UUID |
---
### GET /api/v1/images/{id}/file — Serve Image File
**Path**: `{id}` is a UUID.
**Response** 302: Redirect to pre-signed S3 URL (1-hour expiry).
`Location` header contains the URL.
| Status | Code | Condition |
|---|---|---|
| 404 | `image_not_found` | No image with that UUID |
---
### PATCH /api/v1/images/{id}/tags — Update Tags
**Request**: `Content-Type: application/json`
```json
{ "tags": ["cat", "funny", "new-tag"] }
```
Replaces the full tag set. Empty array removes all tags from the image.
Tag records themselves are never deleted.
**Response** 200: Full image object with updated tags.
| Status | Code | Condition |
|---|---|---|
| 404 | `image_not_found` | No image with that UUID |
| 422 | `invalid_tag` | A tag fails validation |
---
### DELETE /api/v1/images/{id} — Delete Image
**Path**: `{id}` is a UUID.
**Processing**:
1. Look up image record
2. Delete all `image_tags` rows for this image
3. Delete the image record
4. Delete the S3 object at `storage_key`
**Response** 204: No body.
| Status | Code | Condition |
|---|---|---|
| 404 | `image_not_found` | No image with that UUID |
---
## Tag Resource
### Tag object shape
```json
{
"id": "uuid",
"name": "cat",
"image_count": 42
}
```
---
### GET /api/v1/tags — List Tags
**Query parameters**:
| Param | Type | Default | Notes |
|---|---|---|---|
| `q` | string | — | Prefix filter on tag name |
| `limit` | integer | 100 | Max 200 |
| `offset` | integer | 0 | Pagination offset |
**Response** 200:
```json
{
"items": [ { "id": "uuid", "name": "cat", "image_count": 42 } ],
"total": 7,
"limit": 100,
"offset": 0
}
```
Results ordered alphabetically by `name`.
---
## Health
### GET /api/v1/health
**Response** 200:
```json
{ "status": "ok" }
```
No auth or error cases.
---
## Constraints Summary
| Constraint | Value |
|---|---|
| Accepted MIME types | `image/jpeg`, `image/png`, `image/gif`, `image/webp` |
| Max upload size | 52 428 800 bytes (50 MiB); configurable via `MAX_UPLOAD_BYTES` env var |
| Tag name pattern | `^[a-z0-9_-]{1,64}$` (after normalisation) |
| Pre-signed URL expiry | 3 600 seconds (1 hour) |
| Max `limit` for images list | 100 |
| Max `limit` for tags list | 200 |
| Default `limit` for images list | 50 |
| Default `limit` for tags list | 100 |

View File

@@ -0,0 +1,151 @@
# Data Model: Reaction Image Board v1
**Date**: 2026-05-02
---
## Entities
### Image
Represents a single uploaded image file.
| Field | Type | Constraints | Notes |
|---|---|---|---|
| `id` | UUID | PK, not null | Generated on insert |
| `hash` | VARCHAR(64) | UNIQUE, not null | SHA-256 hex digest of file bytes |
| `filename` | VARCHAR | not null | Original filename; display only |
| `mime_type` | VARCHAR(20) | not null | `image/jpeg`, `image/png`, `image/gif`, `image/webp` |
| `size_bytes` | BIGINT | not null, > 0 | File size |
| `width` | INTEGER | not null, > 0 | Pixel width |
| `height` | INTEGER | not null, > 0 | Pixel height |
| `storage_key` | VARCHAR(64) | not null | S3 object key; equals `hash` in v1 |
| `created_at` | TIMESTAMPTZ | not null, default now() | Set on insert; never updated |
**Indexes**:
- `images_hash_idx` UNIQUE on `hash` — supports fast duplicate detection
**Validation rules**:
- `mime_type` MUST be one of: `image/jpeg`, `image/png`, `image/gif`, `image/webp`
- `size_bytes` MUST be > 0 and ≤ `MAX_UPLOAD_BYTES` (default 52 428 800)
- `hash` MUST be a 64-character lowercase hex string (SHA-256)
---
### Tag
Represents a single normalised tag string.
| Field | Type | Constraints | Notes |
|---|---|---|---|
| `id` | UUID | PK, not null | Generated on insert |
| `name` | VARCHAR(64) | UNIQUE, not null | Normalised: lowercase, trimmed |
| `created_at` | TIMESTAMPTZ | not null, default now() | Set on insert |
**Indexes**:
- `tags_name_idx` UNIQUE on `name` — supports upsert by name
- `tags_name_prefix_idx` on `name` with `varchar_pattern_ops` — supports
prefix search (`LIKE 'prefix%'`)
**Validation rules**:
- `name` MUST match `^[a-z0-9_-]{1,64}$` (after normalisation)
- Normalisation applied before validation: lowercase + whitespace trim
**Lifecycle**:
- Tags are created implicitly on first use; no explicit creation endpoint
- Tag records are never deleted even when all image associations are removed
---
### ImageTag (join)
Many-to-many association between Image and Tag.
| Field | Type | Constraints | Notes |
|---|---|---|---|
| `image_id` | UUID | FK → images.id ON DELETE CASCADE | |
| `tag_id` | UUID | FK → tags.id ON DELETE RESTRICT | |
**Primary key**: composite `(image_id, tag_id)`
**Notes**:
- Deleting an image cascades to all its ImageTag rows
- Deleting a tag is RESTRICT (not permitted while image associations exist)
- In practice, tags are never deleted in v1 so RESTRICT is never triggered
---
## Relationships
```
Image ──< ImageTag >── Tag
(1) (M:M) (1)
```
- One Image has zero or more Tags (through ImageTag)
- One Tag is applied to zero or more Images (through ImageTag)
---
## State Transitions
### Image lifecycle
```
[upload received]
Validate MIME + size
Compute SHA-256
Existing hash? ──yes──► return existing record (duplicate: true)
no
Write to S3
Insert images row
Upsert tags + insert image_tag rows
Return new record (duplicate: false)
[user deletes image]
Delete image_tag rows (cascade)
Delete images row
Delete S3 object
[gone]
```
### Tag set update (PATCH)
```
[PATCH /api/v1/images/{id}/tags with new_tags=[...]]
Validate each tag name (post-normalisation)
Fetch current tag set for image
Compute removed = current \ new_tags
Compute added = new_tags \ current
Delete ImageTag rows for removed tags
Upsert Tag records for added tags
Insert ImageTag rows for added tags
Return full updated image record
```
---
## Database Migration Strategy
- Alembic manages all schema changes
- Migration files are committed to `api/alembic/versions/`
- Schema is applied on API startup (`alembic upgrade head`)
- M0: initial empty migration (no tables)
- M1: `images` table
- M2: `tags` table + `image_tags` table + indexes

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.

View File

@@ -0,0 +1,144 @@
# Quickstart: Reaction Image Board v1
**Goal**: Get a fully functional local development environment running in
under 5 minutes from a clean checkout.
---
## Prerequisites
- Docker and Docker Compose v2 installed
- Git
No other tools (Python, Node, etc.) are required on the host — everything
runs inside containers.
---
## Steps
### 1. Clone and configure
```bash
git clone <repo-url> reactbin
cd reactbin
cp .env.example .env
```
The `.env.example` file contains safe defaults for local development.
You do not need to edit `.env` to get started.
### 2. Start all services
```bash
docker compose up
```
This starts four services:
- **postgres** — PostgreSQL on port 5432
- **minio** — S3-compatible object storage on port 9000 (console on 9001)
- **api** — FastAPI application on port 8000
- **ui** — Angular dev server on port 4200
On first run, Docker builds the API and UI images (a few minutes). Subsequent
starts are fast.
### 3. Verify the API
```bash
curl http://localhost:8000/api/v1/health
# → {"status":"ok"}
```
### 4. Open the UI
Navigate to [http://localhost:4200](http://localhost:4200) in your browser.
The empty library is displayed.
---
## Upload a test image (API)
```bash
curl -X POST http://localhost:8000/api/v1/images \
-F "file=@/path/to/image.jpg" \
-F "tags=test,sample"
```
Expected response: HTTP 201 with the image JSON including its UUID.
---
## Upload a test image (UI)
1. Click the **Upload** button in the library view
2. Drag and drop an image (JPEG, PNG, GIF, or WebP, max 50 MB)
3. Type some tags separated by commas
4. Click **Upload**
5. You are redirected to the image's detail page
---
## MinIO Console
The MinIO management console is accessible at
[http://localhost:9001](http://localhost:9001).
Default credentials (from `.env.example`):
- User: `minioadmin`
- Password: `minioadmin`
You can inspect uploaded objects in the bucket here.
---
## Running tests
**API tests** (inside the container):
```bash
docker compose run --rm api pytest
```
**UI tests**:
```bash
docker compose run --rm ui ng test --watch=false
```
**Linters**:
```bash
docker compose run --rm api ruff check .
docker compose run --rm ui npm run lint
```
---
## Stopping and resetting
```bash
# Stop all services (preserves data)
docker compose down
# Stop and remove all data (PostgreSQL + MinIO volumes)
docker compose down -v
```
---
## Environment variables
All configuration comes from `.env`. The table below shows every variable
and its default:
| Variable | Default | Notes |
|---|---|---|
| `DATABASE_URL` | `postgresql+asyncpg://reactbin:reactbin@postgres:5432/reactbin` | Async DSN for SQLAlchemy |
| `S3_ENDPOINT_URL` | `http://minio:9000` | MinIO endpoint inside Docker network |
| `S3_BUCKET_NAME` | `reactbin` | Created automatically on first API start |
| `S3_ACCESS_KEY_ID` | `minioadmin` | MinIO root user |
| `S3_SECRET_ACCESS_KEY` | `minioadmin` | MinIO root password |
| `S3_REGION` | `us-east-1` | Required even for MinIO |
| `API_BASE_URL` | `http://localhost:8000` | Injected into Angular at build time |
| `MAX_UPLOAD_BYTES` | `52428800` | 50 MiB |
For production, replace MinIO credentials and DATABASE_URL with real values.
Never commit a `.env` file with real credentials.

View File

@@ -0,0 +1,134 @@
# Research: Reaction Image Board v1
**Phase**: 0 — Pre-design research
**Date**: 2026-05-02
The technology stack is fully specified in the constitution's tech stack table
(§6), so this document records rationale and key decisions rather than
exploratory research.
---
## Decision 1: Project layout
**Decision**: Two top-level directories (`api/`, `ui/`) in a single
repository, each with its own dependency manifest and Dockerfile.
**Rationale**: Constitution §1 mandates separate deployable artifacts with
separate dependency manifests. A monorepo with two independently buildable
services satisfies this without requiring a separate repo per service.
Docker Compose ties them together for local development (§7.1).
**Alternatives considered**:
- Separate repositories — rejected because it adds checkout/sync overhead
for a solo project with no reason to deploy them independently yet.
- Single `src/` with both projects — rejected because it would entangle
dependency manifests, violating §2.1.
---
## Decision 2: SQLAlchemy async + asyncpg driver
**Decision**: SQLAlchemy 2.x in async mode with the asyncpg driver.
**Rationale**: Constitution §6 specifies this explicitly. FastAPI's async
request handlers benefit directly; asyncpg is the fastest PostgreSQL driver
for Python async code. The async session is injected per-request via
FastAPI's dependency system.
**Alternatives considered**: Synchronous SQLAlchemy + psycopg2 — rejected
per constitution mandate.
---
## Decision 3: Storage object key
**Decision**: SHA-256 hex digest of file bytes, no prefix, no extension
(e.g. `a3f1...`).
**Rationale**: Spec §3 specifies this explicitly. Content-addressed keys
are stable and human-inspectable in the bucket. The same key is stored in
the `storage_key` column on the image record, so reconstruction without DB
state is possible.
**Alternatives considered**: UUID key — rejected; UUID keys lose the
content-addressing property and the deduplication shortcut.
---
## Decision 4: Duplicate detection strategy
**Decision**: Hash the file bytes in the API process; query PostgreSQL for an
existing record with that hash before any storage write.
**Rationale**: Constitution §4.3 mandates deduplication by SHA-256. Performing
the hash and DB check before writing to S3 avoids wasting storage bandwidth
and keeps the duplicate-response path (HTTP 200 + `duplicate: true`) cheap.
**Alternatives considered**:
- Hash at upload, always write to S3 then check — rejected because it wastes
S3 bandwidth for duplicate uploads.
- Client-side hash — rejected because the constitution places no trust in the
client and the UI knows nothing about storage implementation (§2.3).
---
## Decision 5: Tag validation pattern
**Decision**: `^[a-z0-9_-]{1,64}$` applied after normalisation (lowercase + trim).
**Rationale**: Spec §2.8 specifies this pattern exactly. Normalisation happens
before validation so that user input like `" Cat "` becomes `"cat"` and passes.
---
## Decision 6: Pre-signed URL strategy
**Decision**: Generate a 1-hour pre-signed URL on each request to
`GET /api/v1/images/{id}/file` and return a 302 redirect.
**Rationale**: Spec §2.4 specifies this approach. The client (browser or
Angular app) loads the image directly from S3/MinIO, avoiding API proxying
of potentially large files. The 1-hour expiry is short enough to be
meaningless at personal-use scale.
**Alternatives considered**:
- Proxy image bytes through the API — rejected because it wastes API memory
and bandwidth for potentially large files.
- Permanent public S3 URLs — rejected because it exposes the bucket structure
and requires the bucket to be public.
---
## Decision 7: Test database and storage in integration tests
**Decision**: Integration tests run against a real PostgreSQL database and a
real MinIO instance started by Docker Compose (or a dedicated test compose
file). No mocking of the database or storage layer.
**Rationale**: Constitution §5.2 mandates "API routes tested against a real
(test) database and a real (test) S3-compatible bucket (e.g. MinIO in Docker)".
Mocking at this layer has historically caused test/prod divergence.
**Alternatives considered**:
- SQLite in-memory for integration tests — rejected; constitution mandates
PostgreSQL specifically for the repository layer.
- Mocked S3 (moto) — rejected per constitution §5.2.
---
## Decision 8: Angular tag filter debounce
**Decision**: Debounce tag filter bar API calls in `LibraryComponent` using
RxJS `debounceTime` (e.g. 300 ms).
**Rationale**: Spec §4.1 says "updates in real time (debounced API call)".
300 ms is a standard UX debounce that prevents excessive API calls while
still feeling responsive.
---
## All NEEDS CLARIFICATION resolved
No unresolved clarifications remain. The constitution and spec together fully
specify the technology choices and behaviour for v1.

View File

@@ -0,0 +1,259 @@
# Feature Specification: Reaction Image Board v1
**Feature Branch**: `001-reaction-image-board`
**Created**: 2026-05-02
**Status**: Draft
**Input**: User description: "Read docs/SPEC.md, from which we will create the official spec"
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Upload an Image (Priority: P1)
A user drags and drops (or browses to select) a single image file from their
device, optionally adds tags, and submits the upload. The image appears in
the library immediately. If the same image was already uploaded before, the
system recognises the duplicate and shows the existing entry without creating
a second copy.
**Why this priority**: This is the core data-entry action. Without it no
library content exists to browse, search, or manage.
**Independent Test**: Upload a JPEG, verify it appears in the library grid,
then re-upload the same file and verify only one copy exists with an
"Already in your library" notification.
**Acceptance Scenarios**:
1. **Given** a supported image file (JPEG, PNG, GIF, or WebP) under 50 MB,
**When** the user submits the upload form,
**Then** the image is stored, appears in the library, and the user is
taken to the image's detail page.
2. **Given** an image already in the library,
**When** the user uploads the same file again,
**Then** no duplicate is stored, the user sees an "Already in your library"
notification, and is navigated to the existing image's detail page.
3. **Given** an unsupported file type (e.g. PDF, MP4),
**When** the user attempts to upload it,
**Then** an inline error is shown and the user remains on the upload page.
4. **Given** a file larger than 50 MB,
**When** the user attempts to upload it,
**Then** an inline error is shown before any storage is attempted.
5. **Given** a tag name longer than 64 characters or containing characters
outside lowercase letters, digits, hyphens, and underscores,
**When** the user submits the upload,
**Then** an inline validation error identifies the problematic tag and the
upload does not proceed.
---
### User Story 2 — Browse and Filter the Library (Priority: P1)
A user opens the application and sees a grid of all their uploaded images as
thumbnails. They can filter the grid by selecting one or more tags; the grid
updates to show only images that carry **all** selected tags. Filters can be
added and removed interactively without reloading the page.
**Why this priority**: The library view is the default landing page and the
primary way to find and re-use reaction images.
**Independent Test**: Seed the library with tagged images, apply a single tag
filter and verify only matching images are shown, then add a second filter and
verify both tags are required on every visible result.
**Acceptance Scenarios**:
1. **Given** the library contains images,
**When** the user opens the application,
**Then** all images are shown in reverse chronological order as thumbnails
with their tags displayed beneath each one.
2. **Given** a non-empty library,
**When** the user selects one or more tags in the filter bar,
**Then** only images that have every selected tag are shown.
3. **Given** active tag filters,
**When** the user removes a filter chip,
**Then** the grid expands to reflect the remaining filters (or shows all
images if no filters remain).
4. **Given** a large library (more images than fit on screen),
**When** the user scrolls to the bottom or clicks "Load more",
**Then** additional images load without replacing the already-visible ones.
---
### User Story 3 — View Image Detail and Edit Tags (Priority: P2)
A user clicks an image in the library to open a detail page showing the
full-size image and its current tags. They can add new tags or remove
existing ones. Changes are saved on blur or pressing Enter, not on every
keystroke.
**Why this priority**: Tag management is the primary organisation mechanism;
editing must be accessible from the image itself.
**Independent Test**: Open any image detail page, add a new tag, navigate
back to the library, filter by that tag, and confirm the image appears.
**Acceptance Scenarios**:
1. **Given** the user navigates to an image detail page,
**When** the page loads,
**Then** the full-size image is displayed alongside all its current tags.
2. **Given** the user types a new tag into the tag input and presses Enter
(or moves focus away),
**Then** the tag is added to the image and the display updates immediately.
3. **Given** the user clicks the remove (×) button on an existing tag chip,
**Then** the tag is removed from the image.
4. **Given** a tag value that exceeds 64 characters or contains invalid
characters,
**When** the user tries to save it,
**Then** an inline error is shown and the invalid tag is not persisted.
---
### User Story 4 — Delete an Image (Priority: P2)
A user chooses to permanently remove an image from their library. A
confirmation step prevents accidental deletion. After deletion, the image
is gone from the library view and from storage.
**Why this priority**: Users must be able to remove unwanted content from a
personal collection.
**Independent Test**: Delete a known image, confirm it no longer appears in
the library, and confirm that navigating to its former detail URL shows a
"not found" screen.
**Acceptance Scenarios**:
1. **Given** the user is on an image detail page,
**When** they click the delete button and confirm,
**Then** the image and its stored file are permanently removed and the user
is returned to the library.
2. **Given** the user clicks the delete button,
**When** they dismiss the confirmation dialog (cancel),
**Then** no deletion occurs and the user remains on the detail page.
---
### User Story 5 — Browse and Search Tags (Priority: P3)
A user can view a list of all tags currently in use, along with how many
images each tag is applied to. They can type a prefix to narrow the list.
**Why this priority**: Useful for discovering existing tags and maintaining a
consistent vocabulary, but the library filter bar already enables tag selection
so this is supplementary.
**Independent Test**: Open the tag browser and verify every tag present in the
library appears with a correct image count, then type a prefix and verify only
matching tags remain visible.
**Acceptance Scenarios**:
1. **Given** the user opens the tag browser,
**When** the page loads,
**Then** all tags are listed alphabetically with their image counts.
2. **Given** the user types a prefix into the search input,
**Then** only tags whose names begin with that prefix are shown.
---
### Edge Cases
- What happens when the library is empty? → An empty-state prompt is shown
encouraging the user to upload their first image.
- What happens when a tag filter matches zero images? → The grid shows an
empty-results message (not an error).
- What happens when the user navigates to a non-existent image ID? → A "Not
found" screen is shown with a link back to the library.
- What happens when the user navigates to an unknown route? → A "Not found"
screen is shown.
- What happens when the upload form is submitted with no tags? → The image is
stored with no tags; no validation error is raised.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST accept uploads of JPEG, PNG, GIF, and WebP images
up to 50 MB per file; all other types MUST be rejected.
- **FR-002**: System MUST detect duplicate image content at upload time and
return the existing record without writing a duplicate to storage.
- **FR-003**: Users MUST be able to attach zero or more tags to an image at
upload time via a comma-or-space-separated text input.
- **FR-004**: Tag names MUST be normalised (lowercased, whitespace-trimmed)
before storage and MUST conform to: lowercase letters, digits, hyphens, and
underscores only, 164 characters.
- **FR-005**: Users MUST be able to filter the image library by one or more
tags; the filter logic MUST be AND (every specified tag must be present on
the result).
- **FR-006**: All list views MUST support pagination; no view may load the
entire library at once.
- **FR-007**: Users MUST be able to replace the complete tag set on an
existing image (add new tags, remove existing tags) from the detail view.
- **FR-008**: Users MUST be able to permanently delete an image including its
stored file and all tag associations, after a confirmation step.
- **FR-009**: Images MUST be viewable in the browser (thumbnail in library,
full-size on detail page) without exposing permanent internal storage
credentials or addresses.
- **FR-010**: Users MUST be able to list all tags sorted alphabetically with
associated image counts, with optional prefix filtering.
- **FR-011**: Tags MUST be created implicitly on first use; no explicit
tag-creation step is required.
- **FR-012**: Removing a tag from an image MUST NOT delete the shared tag
record or affect other images that use the same tag.
### Key Entities *(include if feature involves data)*
- **Image**: A single uploaded file. Key attributes: unique content
fingerprint, original filename, file type, pixel dimensions, file size,
upload timestamp, associated tags.
- **Tag**: A normalised text label that can be applied to many images.
Key attributes: name (unique, always lowercase), creation timestamp, count
of images currently using it.
- **ImageTag**: The many-to-many association between an image and a tag.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: A user can upload an image with tags and see it appear in the
library in under 10 seconds on a local network connection.
- **SC-002**: Re-uploading an identical image produces no duplicate library
entry; duplicate detection is invisible to the user except for the
informational notification.
- **SC-003**: The library's first page of results loads in under 2 seconds
for a collection of 1,000 images.
- **SC-004**: A tag-filtered search with 13 active tags returns results in
under 2 seconds across a library of 1,000 images.
- **SC-005**: A user can add or remove a tag on an existing image within
5 seconds of interaction on a local network connection.
- **SC-006**: The complete application starts from a clean checkout with a
single command and requires no manual setup beyond copying the example
environment file.
## Assumptions
- The application serves a single user (the owner) on a local network.
No authentication, access control, or multi-user isolation is required in v1.
- Only one image file can be submitted per upload action; bulk upload is out
of scope for v1.
- Images are immutable after upload: file content is never replaced; only the
tag associations may change.
- The deployment environment provides S3-compatible object storage (locally
via MinIO for development).
- Target clients are modern evergreen desktop browsers; mobile-native
experience is explicitly out of scope for v1.
- OR/NOT tag logic, collections/albums, image editing, alternative sort
orders, and multi-user features are all explicitly out of scope for v1.

View File

@@ -0,0 +1,352 @@
---
description: "Task list for Reaction Image Board v1"
---
# Tasks: Reaction Image Board v1
**Input**: Design documents from `specs/001-reaction-image-board/`
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/api.md ✅
**Tests**: Per §5.1 of the constitution, TDD is non-negotiable. Test tasks
MUST appear before every implementation task. Write the test, confirm it
fails, then implement until it passes.
**Organization**: Tasks follow the milestone order from plan.md (API-first,
serial). Each task is tagged with the user story it serves.
## Format: `[ID] [P?] [Story?] Description`
- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
- **[Story]**: Which user story this task serves (US1US5 per spec.md)
- Include exact file paths in descriptions
## Path Conventions
```
api/app/ API source
api/tests/unit/ API unit tests
api/tests/integration/ API integration tests
api/alembic/versions/ Database migrations
ui/src/app/ Angular source
ui/src/app/services/ Angular services (+ .spec.ts colocated)
ui/src/app/<feature>/ Angular component dirs (+ .spec.ts colocated)
```
---
## Phase 1: Setup (Project Skeleton — M0)
**Purpose**: Establish the monorepo layout, Docker Compose stack, and linting
baseline. No feature logic. All subsequent milestones build on this.
- [ ] T001 Create top-level monorepo layout: `api/`, `ui/`, `docker-compose.yml`, `.env.example`
- [ ] T002 Write `.env.example` with all variables from spec §5 (DATABASE_URL, S3_*, API_BASE_URL, MAX_UPLOAD_BYTES)
- [ ] T003 [P] Write `api/Dockerfile` (Python 3.12 slim, installs pyproject.toml deps, runs uvicorn)
- [ ] T004 [P] Scaffold Angular project with CLI into `ui/` (strict mode, standalone components, routing)
- [ ] T005 [P] Write `ui/Dockerfile` (Node LTS, `ng serve --host 0.0.0.0`)
- [ ] T006 Write `docker-compose.yml` defining: postgres, minio, api (depends_on postgres+minio), ui (depends_on api)
- [ ] T007 [P] Configure `api/pyproject.toml` with FastAPI, SQLAlchemy 2.x async, asyncpg, Alembic, aiobotocore, pydantic-settings, pytest, pytest-asyncio, ruff
- [ ] T008 [P] Configure `ui/package.json` / `angular.json` with eslint + prettier; add `ui/proxy.conf.json` routing `/api/*` to `http://localhost:8000`
- [ ] T009 Write API unit test: settings load from env vars without error in `api/tests/unit/test_config.py`
- [ ] T010 Write API integration test: `GET /api/v1/health` returns 200 `{"status":"ok"}` in `api/tests/integration/test_health.py`
- [ ] T011 Implement `api/app/config.py` (pydantic-settings reading all env vars)
- [ ] T012 Implement `api/app/main.py` (FastAPI factory, lifespan connecting to Postgres + MinIO, health route)
- [ ] T013 Configure Alembic in `api/alembic/` with async engine; apply `alembic upgrade head` on startup
- [ ] T014 [P] Add Angular default smoke test in `ui/src/app/app.component.spec.ts`
**Checkpoint**: `docker compose up` starts all four services. Health endpoint
returns 200. Both linters pass. All tests pass.
---
## Phase 2: Foundational (Upload API Infrastructure — M1 core)
**Purpose**: Core interfaces and repositories that MUST exist before any user
story endpoint can be implemented. Establishes the `StorageBackend`,
`AuthProvider`, and `ImageRepository` abstractions.
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
- [ ] T015 Write unit test: SHA-256 hash of known bytes returns expected hex digest in `api/tests/unit/test_hashing.py`
- [ ] T016 Write unit test: MIME validator accepts jpeg/png/gif/webp and rejects pdf/mp4 in `api/tests/unit/test_validation.py`
- [ ] T017 Write unit test: file size validator rejects bytes exceeding MAX_UPLOAD_BYTES in `api/tests/unit/test_validation.py`
- [ ] T018 [P] Implement `StorageBackend` interface (put, get_presigned_url, delete) in `api/app/storage/backend.py`
- [ ] T019 [P] Implement `S3StorageBackend` using aiobotocore in `api/app/storage/s3_backend.py`
- [ ] T020 [P] Implement `AuthProvider` interface + `NoOpAuthProvider` in `api/app/auth/provider.py` and `api/app/auth/noop.py`
- [ ] T021 Implement MIME type + file size validation helpers in `api/app/routers/images.py` (or `api/app/validation.py`)
- [ ] T022 Write Alembic migration for `images` table in `api/alembic/versions/`
- [ ] T023 Implement `Image` SQLAlchemy model in `api/app/models.py`
- [ ] T024 Implement `ImageRepository` (create, get_by_id, get_by_hash) in `api/app/repositories/image_repo.py`
- [ ] T025 Wire `AuthProvider`, `StorageBackend`, and DB session into FastAPI dependency injection in `api/app/dependencies.py`
**Checkpoint**: All unit tests pass; foundation ready for user story endpoints.
---
## Phase 3: User Story 1 — Upload an Image (M1 endpoint + M5 UI) 🎯 MVP
**Goal**: A user can upload an image (with tags, though tag persistence is
deferred to Phase 4). Duplicate detection works. Errors shown inline.
**Independent Test**: Upload a JPEG via the UI, verify it appears in the
library grid. Re-upload the same file, verify "Already in your library" toast
and no duplicate in DB or MinIO.
### Tests for User Story 1 (REQUIRED per §5.1 — TDD) ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T026 [P] [US1] Integration test: valid JPEG upload → 201, record in DB, object in MinIO in `api/tests/integration/test_upload.py`
- [ ] T027 [P] [US1] Integration test: same image uploaded twice → 200, `duplicate: true`, no second MinIO object in `api/tests/integration/test_upload.py`
- [ ] T028 [P] [US1] Integration test: invalid MIME type → 422 with `{"detail":"...","code":"invalid_mime_type"}` in `api/tests/integration/test_upload.py`
- [ ] T029 [P] [US1] Integration test: file > MAX_UPLOAD_BYTES → 422 `file_too_large` in `api/tests/integration/test_upload.py`
- [ ] T030 [P] [US1] Angular unit test: tag chip input lowercases and splits on comma/space in `ui/src/app/upload/upload.component.spec.ts`
- [ ] T031 [P] [US1] Angular unit test: `duplicate: true` response → toast shown, navigate to detail in `ui/src/app/upload/upload.component.spec.ts`
- [ ] T032 [P] [US1] Angular unit test: `duplicate: false` response → success toast, navigate to detail in `ui/src/app/upload/upload.component.spec.ts`
- [ ] T033 [P] [US1] Angular unit test: error response → inline error shown, no navigation in `ui/src/app/upload/upload.component.spec.ts`
### Implementation for User Story 1
- [ ] T034 [US1] Implement `POST /api/v1/images` endpoint (MIME check, size check, SHA-256, duplicate query, storage write, record insert; tags field accepted but ignored) in `api/app/routers/images.py`
- [ ] T035 [US1] Implement `ImageService` wrapping `GET /api/v1/images` and `GET /api/v1/images/{id}/file` in `ui/src/app/services/image.service.ts`
- [ ] T036 [US1] Implement `UploadComponent` (route `/upload`) with drag-and-drop zone, click-to-browse, tag chip input, POST submit, duplicate/success/error handling in `ui/src/app/upload/upload.component.ts`
**Checkpoint**: Full upload flow works in browser. Duplicate detection gives
correct feedback. API tests and Angular unit tests all pass.
---
## Phase 4: User Story 2 — Browse and Filter the Library (M2 list/search + M4 UI)
**Goal**: User can view all images in a responsive grid and filter by one or
more tags (AND logic). Pagination works. Tags are persisted during upload.
**Independent Test**: Seed the library via the upload flow. Apply a single tag
filter and verify only matching images are shown. Add a second filter, verify
both tags must be present. Remove a filter, verify the grid expands.
### Tests for User Story 2 (REQUIRED per §5.1 — TDD) ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T037 [P] [US2] Unit test: tag normalisation — uppercase → lowercase, whitespace stripped in `api/tests/unit/test_tags.py`
- [ ] T038 [P] [US2] Unit test: tag validation — rejects names > 64 chars, invalid chars in `api/tests/unit/test_tags.py`
- [ ] T039 [P] [US2] Integration test: upload with tags → tags persisted, returned in response in `api/tests/integration/test_tags.py`
- [ ] T040 [P] [US2] Integration test: duplicate upload → existing record returned, tags unchanged in `api/tests/integration/test_tags.py`
- [ ] T041 [P] [US2] Integration test: `GET /api/v1/images?tags=cat,funny` → only images with both tags in `api/tests/integration/test_search.py`
- [ ] T042 [P] [US2] Integration test: same query excludes images with only one matching tag in `api/tests/integration/test_search.py`
- [ ] T043 [P] [US2] Angular unit test: `ImageService` constructs correct query params from filter state in `ui/src/app/services/image.service.spec.ts`
- [ ] T044 [P] [US2] Angular unit test: `LibraryComponent` renders image grid from mocked service in `ui/src/app/library/library.component.spec.ts`
- [ ] T045 [P] [US2] Angular unit test: filter change triggers new API call with updated `tags` param in `ui/src/app/library/library.component.spec.ts`
### Implementation for User Story 2
- [ ] T046 [US2] Write Alembic migration for `tags` and `image_tags` tables in `api/alembic/versions/`
- [ ] T047 [US2] Implement `Tag` and `ImageTag` SQLAlchemy models in `api/app/models.py`
- [ ] T048 [US2] Implement tag normalisation + validation helpers in `api/app/repositories/tag_repo.py`
- [ ] T049 [US2] Implement `TagRepository` (upsert_by_name, get_by_image_id) in `api/app/repositories/tag_repo.py`
- [ ] T050 [US2] Update `POST /api/v1/images` to process and persist the `tags` field in `api/app/routers/images.py`
- [ ] T051 [US2] Implement `GET /api/v1/images` with `tags` (AND-filter), `limit`, `offset` in `api/app/routers/images.py`
- [ ] T052 [US2] Implement `GET /api/v1/images/{id}` returning image + tags in `api/app/routers/images.py`
- [ ] T053 [US2] Update `ImageService` to support `tags` filter query param in `ui/src/app/services/image.service.ts`
- [ ] T054 [US2] Implement `LibraryComponent` (route `/`) with image grid, tag chips, debounced filter bar, "Load more" pagination in `ui/src/app/library/library.component.ts`
**Checkpoint**: Library view shows real images with tags. Tag filtering (AND
logic) and pagination work end-to-end.
---
## Phase 5: User Story 3 — View Image Detail and Edit Tags (M3 serving + M6 detail UI)
**Goal**: User can view a full-size image and edit its tags inline. Changes
saved on blur/Enter.
**Independent Test**: Open an image detail page, add a new tag, navigate back,
filter by that tag, and confirm the image appears.
### Tests for User Story 3 (REQUIRED per §5.1 — TDD) ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T055 [P] [US3] Integration test: `GET /api/v1/images/{id}/file` → 302 with `Location` header pointing to MinIO URL in `api/tests/integration/test_serving.py`
- [ ] T056 [P] [US3] Integration test: `/file` for unknown ID → 404 `image_not_found` in `api/tests/integration/test_serving.py`
- [ ] T057 [P] [US3] Integration test: `PATCH /api/v1/images/{id}/tags` replaces tags, old tags unlinked, new tags upserted in `api/tests/integration/test_tags.py`
- [ ] T058 [P] [US3] Integration test: PATCH with invalid tag → 422 `invalid_tag` in `api/tests/integration/test_tags.py`
- [ ] T059 [P] [US3] Angular unit test: removing tag chip calls PATCH with updated list (removed tag absent) in `ui/src/app/detail/detail.component.spec.ts`
- [ ] T060 [P] [US3] Angular unit test: adding tag + Enter calls PATCH with new tag included in `ui/src/app/detail/detail.component.spec.ts`
### Implementation for User Story 3
- [ ] T061 [US3] Implement `GET /api/v1/images/{id}/file` (generate 1-hour pre-signed URL, return 302) in `api/app/routers/images.py`
- [ ] T062 [US3] Implement `TagRepository.replace_tags_on_image` in `api/app/repositories/tag_repo.py`
- [ ] T063 [US3] Implement `PATCH /api/v1/images/{id}/tags` in `api/app/routers/images.py`
- [ ] T064 [US3] Implement `DetailComponent` (route `/images/:id`) with full-size image, editable tag chips (add/remove), save on blur/Enter via PATCH, back button in `ui/src/app/detail/detail.component.ts`
**Checkpoint**: Full-size image loads in browser via redirect. Tag editing
works from detail page. Changes persist across page navigation.
---
## Phase 6: User Story 4 — Delete an Image (M6 detail UI + M2 delete endpoint)
**Goal**: User can permanently delete an image with confirmation. Returns to
library afterwards.
**Independent Test**: Delete a known image, confirm it no longer appears in the
library and that navigating to its former URL shows a not-found screen.
### Tests for User Story 4 (REQUIRED per §5.1 — TDD) ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T065 [P] [US4] Integration test: `DELETE /api/v1/images/{id}` → 204; subsequent `GET /{id}` returns 404 in `api/tests/integration/test_delete.py`
- [ ] T066 [P] [US4] Integration test: DELETE verifies MinIO object is removed in `api/tests/integration/test_delete.py`
- [ ] T067 [P] [US4] Integration test: DELETE of unknown ID → 404 `image_not_found` in `api/tests/integration/test_delete.py`
- [ ] T068 [P] [US4] Angular unit test: delete confirmation → DELETE called → navigation to Library in `ui/src/app/detail/detail.component.spec.ts`
- [ ] T069 [P] [US4] Angular unit test: cancel confirmation dialog → no DELETE call, stays on detail page in `ui/src/app/detail/detail.component.spec.ts`
### Implementation for User Story 4
- [ ] T070 [US4] Implement `DELETE /api/v1/images/{id}` (delete image_tags rows, image record, S3 object) in `api/app/routers/images.py`
- [ ] T071 [US4] Add delete button with confirmation dialog + back-to-Library navigation to `DetailComponent` in `ui/src/app/detail/detail.component.ts`
- [ ] T072 [US4] Implement `NotFoundComponent` shown for all unrecognised routes in `ui/src/app/not-found/not-found.component.ts`
**Checkpoint**: Full CRUD loop works: upload → view → re-tag → delete.
Deleted images gone from library and storage.
---
## Phase 7: User Story 5 — Browse and Search Tags (M2 tags endpoint + UI)
**Goal**: User can list all tags alphabetically with image counts and narrow
by prefix.
**Independent Test**: Open the tag browser, verify every tag present in the
library appears with a correct image count. Type a prefix and verify only
matching tags remain.
### Tests for User Story 5 (REQUIRED per §5.1 — TDD) ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T073 [P] [US5] Integration test: `GET /api/v1/tags` returns all tags alphabetically with correct image_count in `api/tests/integration/test_tags.py`
- [ ] T074 [P] [US5] Integration test: `GET /api/v1/tags?q=ca` returns only tags prefixed "ca" in `api/tests/integration/test_tags.py`
- [ ] T075 [P] [US5] Angular unit test: `TagService` calls `GET /api/v1/tags` with `q` param in `ui/src/app/services/tag.service.spec.ts`
### Implementation for User Story 5
- [ ] T076 [US5] Implement `GET /api/v1/tags` with `q` prefix search, `limit`, `offset`, image_count in `api/app/routers/tags.py`
- [ ] T077 [US5] Implement `TagService` wrapping `GET /api/v1/tags` in `ui/src/app/services/tag.service.ts`
- [ ] T078 [US5] Wire `TagService` into `LibraryComponent` tag filter bar for tag autocomplete/selection in `ui/src/app/library/library.component.ts`
**Checkpoint**: All user stories independently functional and tested.
---
## Phase 8: Polish & Cross-Cutting Concerns
**Purpose**: Improvements affecting multiple user stories and final validation.
- [ ] T079 [P] Add `GET /api/v1/images/{id}` 404 test to verify error envelope shape `{"detail":"...","code":"image_not_found"}` in `api/tests/integration/test_upload.py`
- [ ] T080 [P] Verify all API error responses include both `detail` and `code` fields (constitution §3.3) — check tests for T028, T029, T056, T058, T067
- [ ] T081 [P] Add empty-state UI for library with zero images in `ui/src/app/library/library.component.ts`
- [ ] T082 [P] Add empty-state UI for tag filter returning zero results in `ui/src/app/library/library.component.ts`
- [ ] T083 Configure Angular routing to show `NotFoundComponent` for all unrecognised routes in `ui/src/app/app.routes.ts`
- [ ] T084 [P] Run quickstart.md validation: `docker compose up`, upload an image, filter by tag, edit tag, delete image — full happy path
- [ ] T085 [P] Run `ruff check .` in `api/` — confirm zero lint errors
- [ ] T086 [P] Run `npm run lint` in `ui/` — confirm zero lint errors
- [ ] T087 Run all API tests: `docker compose run --rm api pytest` — confirm all pass
- [ ] T088 Run all UI tests: `docker compose run --rm ui ng test --watch=false` — confirm all pass
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: No dependencies — start immediately
- **Phase 2 (Foundational)**: Depends on Phase 1 — BLOCKS all user stories
- **Phase 3 (US1 Upload)**: Depends on Phase 2 — API endpoint + Angular upload UI
- **Phase 4 (US2 Browse)**: Depends on Phase 3 (tags API needs upload first)
- **Phase 5 (US3 Detail/Edit)**: Depends on Phase 4 (detail view needs list view navigation)
- **Phase 6 (US4 Delete)**: Depends on Phase 5 (delete is in the detail component)
- **Phase 7 (US5 Tags)**: Can start after Phase 4 (tag endpoint is independent)
- **Phase 8 (Polish)**: Depends on all prior phases
### User Story Dependencies
- **US1 (Upload)**: Foundational complete → API endpoint first, then Angular component
- **US2 (Browse)**: US1 must exist to seed data; tag schema (Phase 4) needed
- **US3 (View/Edit)**: US2 complete (library navigation leads to detail)
- **US4 (Delete)**: US3 complete (delete is on the detail view)
- **US5 (Tags)**: Independent after Phase 4 tag endpoint; can start in parallel with US3/US4
### Within Each Phase
- Test tasks MUST be written and confirmed FAILING before implementation
- Models before repositories before endpoints
- API endpoint before Angular service
- Angular service before Angular component
- Story complete before moving to next phase
### Parallel Opportunities
- All Phase 1 tasks marked [P] can run in parallel after T001/T002
- T018T020 (interfaces) can run in parallel
- All integration tests within a phase marked [P] can be written in parallel
- Angular unit tests within a phase marked [P] can be written in parallel
- T085/T086/T087/T088 (lint + test runs) can run in parallel
---
## Parallel Example: Phase 3 (User Story 1)
```bash
# Write all tests for US1 in parallel (all different files):
T026: api/tests/integration/test_upload.py (new upload)
T027: api/tests/integration/test_upload.py (duplicate)
T028: api/tests/integration/test_upload.py (invalid MIME)
T029: api/tests/integration/test_upload.py (oversized)
T030: ui/src/app/upload/upload.component.spec.ts (tag chips)
T031: ui/src/app/upload/upload.component.spec.ts (duplicate response)
# Then implement sequentially:
T034: POST /api/v1/images endpoint
T035: Angular ImageService
T036: Angular UploadComponent
```
---
## Implementation Strategy
### MVP First (User Stories 1 + 2 only)
1. Complete Phase 1: Setup
2. Complete Phase 2: Foundational — BLOCKS all stories
3. Complete Phase 3: US1 Upload
4. Complete Phase 4: US2 Browse/Filter
5. **STOP and VALIDATE**: Upload via UI, filter by tag, verify end-to-end
6. Can demo at this point: full read+write loop without detail view
### Incremental Delivery
1. Phase 1 + 2 → Foundation ready
2. Phase 3 → US1 Upload works (MVP start)
3. Phase 4 → US2 Browse + tag filtering works (MVP complete)
4. Phase 5 → US3 Detail + tag editing
5. Phase 6 → US4 Delete (completes full CRUD)
6. Phase 7 → US5 Tag browser (supplementary)
7. Phase 8 → Polish + final validation
---
## Notes
- [P] = different files, no incomplete dependencies — safe to parallelise
- [USN] label maps task to user story for traceability
- TDD is non-negotiable (§5.1): test → fail → implement → pass
- API tests run against real Postgres + MinIO (no mocks per §5.2)
- Milestone done-criterion: all tests pass + linter passes
- `docker compose up` must keep working after every milestone