[Spec Kit] Initial commit — constitution, spec, plan, and tasks for Reaction Image Board v1
This commit is contained in:
37
specs/001-reaction-image-board/checklists/requirements.md
Normal file
37
specs/001-reaction-image-board/checklists/requirements.md
Normal 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.
|
||||
216
specs/001-reaction-image-board/contracts/api.md
Normal file
216
specs/001-reaction-image-board/contracts/api.md
Normal 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 |
|
||||
151
specs/001-reaction-image-board/data-model.md
Normal file
151
specs/001-reaction-image-board/data-model.md
Normal 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
|
||||
354
specs/001-reaction-image-board/plan.md
Normal file
354
specs/001-reaction-image-board/plan.md
Normal 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 (M0–M6), API-first, each leaving the system in a fully
|
||||
working and tested state.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python 3.12+ (API); TypeScript strict mode (UI)
|
||||
**Primary Dependencies**: FastAPI, SQLAlchemy 2.x async, asyncpg, Alembic,
|
||||
boto3/aiobotocore (API); Angular latest stable (UI)
|
||||
**Storage**: PostgreSQL (relational), S3-compatible object storage via MinIO
|
||||
locally / AWS S3 in production
|
||||
**Testing**: pytest + pytest-asyncio (API unit + integration); Angular Karma/Jest
|
||||
+ TestBed (UI unit); E2E best-effort (constitution §5.2)
|
||||
**Target Platform**: Linux server (containerised); modern evergreen desktop browsers
|
||||
**Project Type**: Web application — separate API service + SPA
|
||||
**Performance Goals**: First page of library < 2 s for 1 000 images; upload
|
||||
visible in library < 10 s on local network
|
||||
**Constraints**: Single user, localhost-only in Phase 1; 50 MB upload cap;
|
||||
`docker compose up` is the only required start command
|
||||
**Scale/Scope**: Personal use; v1 scope bounded per constitution §8
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design below.*
|
||||
|
||||
| Principle | Check | Status |
|
||||
|---|---|---|
|
||||
| §2.1 Separation of concerns | API service knows nothing about Angular; UI knows nothing about DB/S3 | ✅ |
|
||||
| §2.2 Dependency direction | UI → API → Storage/DB; no upward imports | ✅ |
|
||||
| §2.3 Storage abstraction | `StorageBackend` interface created before first S3 call (M1); no bucket names outside storage module | ✅ |
|
||||
| §2.4 Auth abstraction | `AuthProvider` + `NoOpAuthProvider` wired in M1; all request-identity resolution goes through it | ✅ |
|
||||
| §2.5 DB abstraction | All DB access through `ImageRepository` and `TagRepository`; no query logic outside repositories | ✅ |
|
||||
| §2.6 No speculative abstraction | Only constitutionally-sanctioned interfaces (StorageBackend, AuthProvider, repositories) created | ✅ |
|
||||
| §3.1 API versioning | All routes prefixed `/api/v1/` | ✅ |
|
||||
| §3.3 Error shape | `{"detail": "...", "code": "..."}` enforced; integration tests verify envelope | ✅ |
|
||||
| §3.4 Pagination | Every list endpoint has `limit`/`offset` from day one (M2) | ✅ |
|
||||
| §4.1 Tag normalisation | Lowercase + trim applied before persistence (M2) | ✅ |
|
||||
| §4.3 Dedup by hash | SHA-256 computed before storage write; existing hash returns existing record (M1) | ✅ |
|
||||
| §4.4 Tag AND logic | List endpoint filters with AND semantics (M2) | ✅ |
|
||||
| §5.1 TDD non-negotiable | **Tests are written FIRST and must fail before implementation in every milestone** | ✅ |
|
||||
| §5.2 Test pyramid | Unit + integration tests required; E2E best-effort | ✅ |
|
||||
| §5.3 Test colocation | API tests in `api/tests/`; Angular tests colocated with components | ✅ |
|
||||
| §5.4 CI gate | All tests + linters must pass before a milestone is "done" | ✅ |
|
||||
| §7.1 One-command start | M0 done-criterion is `docker compose up` starts all services | ✅ |
|
||||
| §7.2 Env configuration | All config via env vars; `.env.example` committed in M0 | ✅ |
|
||||
| §7.3 Linting | `ruff` (API) + `eslint`/`prettier` (UI) configured in M0, enforced | ✅ |
|
||||
| §8 Scope boundaries | Bulk upload, OR/NOT tags, auth, image editing, multi-user — all deferred | ✅ |
|
||||
|
||||
**Post-design re-check**: See bottom of this file — updated after Phase 1 artifacts.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/001-reaction-image-board/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 decisions
|
||||
├── data-model.md # Entity definitions and DB schema
|
||||
├── quickstart.md # Local dev setup walkthrough
|
||||
├── contracts/ # OpenAPI-aligned endpoint contracts
|
||||
│ └── api.md
|
||||
└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
api/
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI app factory, lifespan, middleware
|
||||
│ ├── config.py # Settings from env vars (pydantic-settings)
|
||||
│ ├── dependencies.py # FastAPI dependency injection (db session, auth)
|
||||
│ ├── repositories/
|
||||
│ │ ├── image_repo.py # ImageRepository
|
||||
│ │ └── tag_repo.py # TagRepository
|
||||
│ ├── storage/
|
||||
│ │ ├── backend.py # StorageBackend interface
|
||||
│ │ └── s3_backend.py # S3StorageBackend implementation
|
||||
│ ├── auth/
|
||||
│ │ ├── provider.py # AuthProvider interface
|
||||
│ │ └── noop.py # NoOpAuthProvider
|
||||
│ ├── routers/
|
||||
│ │ ├── images.py # /api/v1/images routes
|
||||
│ │ └── tags.py # /api/v1/tags route
|
||||
│ └── models.py # SQLAlchemy ORM models
|
||||
├── alembic/
|
||||
│ ├── alembic.ini
|
||||
│ └── versions/ # Migration files
|
||||
├── tests/
|
||||
│ ├── unit/
|
||||
│ └── integration/
|
||||
├── pyproject.toml # deps, ruff config
|
||||
└── Dockerfile
|
||||
|
||||
ui/
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── app.component.*
|
||||
│ │ ├── app.routes.ts
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── image.service.ts (+ .spec.ts)
|
||||
│ │ │ └── tag.service.ts (+ .spec.ts)
|
||||
│ │ ├── library/
|
||||
│ │ │ └── library.component.* (+ .spec.ts)
|
||||
│ │ ├── upload/
|
||||
│ │ │ └── upload.component.* (+ .spec.ts)
|
||||
│ │ ├── detail/
|
||||
│ │ │ └── detail.component.* (+ .spec.ts)
|
||||
│ │ └── not-found/
|
||||
│ │ └── not-found.component.*
|
||||
│ └── environments/
|
||||
├── proxy.conf.json # /api/* → API in dev
|
||||
├── angular.json
|
||||
├── package.json
|
||||
└── Dockerfile
|
||||
|
||||
docker-compose.yml
|
||||
.env.example
|
||||
```
|
||||
|
||||
**Structure Decision**: Web application (Option 2 variant). `api/` contains
|
||||
the FastAPI project; `ui/` contains the Angular project. Both have independent
|
||||
dependency manifests and Dockerfiles per constitution §1.
|
||||
|
||||
## Milestones
|
||||
|
||||
> **TDD ORDER IS MANDATORY** (constitution §5.1): For every task below, write
|
||||
> the failing test(s) first, confirm they fail, then implement until they pass.
|
||||
|
||||
### M0 — Project Skeleton
|
||||
|
||||
**Goal**: A running system with no features. Both services start, connect to
|
||||
their dependencies, and respond to a health check.
|
||||
|
||||
**Deliverables** (implement only after failing tests exist):
|
||||
- Monorepo layout per Project Structure above
|
||||
- `docker-compose.yml`: PostgreSQL, MinIO, API, UI dev server
|
||||
- `.env.example` with all variables from spec §5
|
||||
- API: FastAPI app starts, connects to PostgreSQL (SQLAlchemy async + asyncpg)
|
||||
and MinIO (aiobotocore)
|
||||
- API: `GET /api/v1/health` returns `{"status": "ok"}`
|
||||
- API: Alembic configured, initial empty migration applied on startup
|
||||
- API: `ruff` configured and passing in CI
|
||||
- UI: Angular app scaffolded, routing in place, `HttpClient` with `API_BASE_URL`
|
||||
- UI: proxy config routes `/api/*` to API in local dev
|
||||
- UI: `eslint` + `prettier` configured and passing
|
||||
|
||||
**Tests (write first)**:
|
||||
- API unit: settings loaded from env vars without error
|
||||
- API integration: `GET /api/v1/health` → 200 `{"status": "ok"}`
|
||||
- UI: Angular default smoke test passes
|
||||
|
||||
**Done when**: `docker compose up` starts all four services; health endpoint
|
||||
returns 200; both linters and all tests pass.
|
||||
|
||||
---
|
||||
|
||||
### M1 — Image Upload (API only)
|
||||
|
||||
**Goal**: API accepts an image, validates it, deduplicates by hash, stores in
|
||||
MinIO, and persists the record in PostgreSQL. Tags field accepted but ignored.
|
||||
|
||||
**Deliverables** (implement only after failing tests exist):
|
||||
- Alembic migration: `images` table
|
||||
- `StorageBackend` interface (`app/storage/backend.py`)
|
||||
- `S3StorageBackend` implementation (`app/storage/s3_backend.py`)
|
||||
- `AuthProvider` interface + `NoOpAuthProvider` (`app/auth/`)
|
||||
- `ImageRepository`: `create`, `get_by_id`, `get_by_hash`
|
||||
- `POST /api/v1/images`: MIME validation, size validation, SHA-256 hash,
|
||||
duplicate check, storage write, record insert, correct response shapes
|
||||
(201 new / 200 duplicate with `duplicate` field)
|
||||
|
||||
**Tests (write first)**:
|
||||
- Unit: SHA-256 hash computation on known bytes
|
||||
- Unit: MIME type validator rejects PDF, MP4, etc.
|
||||
- Unit: file size validator rejects files over MAX_UPLOAD_BYTES
|
||||
- Integration: valid JPEG upload → 201, record in DB, object in MinIO
|
||||
- Integration: same image uploaded twice → 200, `duplicate: true`, no second
|
||||
MinIO object written
|
||||
- Integration: invalid MIME type → 422 `invalid_mime_type` (error envelope
|
||||
must include `code` field)
|
||||
- Integration: oversized file → 422 `file_too_large`
|
||||
|
||||
**Done when**: All tests pass; linter passes; duplicate detection works
|
||||
end-to-end via curl.
|
||||
|
||||
---
|
||||
|
||||
### M2 — Tags (API only)
|
||||
|
||||
**Goal**: Tags are fully functional on the API. Upload persists tags; search
|
||||
filters by tags; tags are editable; images can be deleted.
|
||||
|
||||
**Deliverables** (implement only after failing tests exist):
|
||||
- Alembic migration: `tags` table + `image_tags` join table
|
||||
- `TagRepository`: `upsert_by_name`, `get_by_image_id`, `replace_tags_on_image`
|
||||
- Tag normalisation + validation (pattern `^[a-z0-9_-]{1,64}$` after lowercase+trim)
|
||||
- `POST /api/v1/images`: `tags` field now processed and persisted
|
||||
- `GET /api/v1/images` with `tags`, `limit`, `offset` (AND-filter)
|
||||
- `GET /api/v1/images/{id}` (returns image + tags)
|
||||
- `PATCH /api/v1/images/{id}/tags` (full tag replacement)
|
||||
- `GET /api/v1/tags` with `q`, `limit`, `offset` (prefix search + image count)
|
||||
- `DELETE /api/v1/images/{id}` (record + ImageTag rows + storage object)
|
||||
|
||||
**Tests (write first)**:
|
||||
- Unit: normalisation: uppercase → lowercase, whitespace stripped
|
||||
- Unit: validation: rejects names > 64 chars, rejects invalid chars
|
||||
- Unit: AND-filter query produces correct WHERE clause
|
||||
- Integration: upload with tags → tags persisted and returned
|
||||
- Integration: duplicate upload → existing record returned, tags unchanged
|
||||
- Integration: `GET /api/v1/images?tags=cat,funny` → only images with both tags
|
||||
- Integration: same query excludes images with only one of the two tags
|
||||
- Integration: PATCH replaces tags; old tags unlinked; new tags upserted
|
||||
- Integration: `GET /api/v1/tags?q=ca` → tags prefixed "ca" with correct counts
|
||||
- Integration: DELETE → 204; subsequent GET returns 404 `image_not_found`
|
||||
- Integration: DELETE verifies storage object removed from MinIO
|
||||
|
||||
**Done when**: All prior tests still pass; full API functional via curl.
|
||||
|
||||
---
|
||||
|
||||
### M3 — Image Serving (API only)
|
||||
|
||||
**Goal**: Images can be viewed in a browser via pre-signed S3 URLs.
|
||||
|
||||
**Deliverables** (implement only after failing tests exist):
|
||||
- `GET /api/v1/images/{id}/file` → generates 1-hour pre-signed URL, returns
|
||||
302 redirect with `Location` header
|
||||
|
||||
**Tests (write first)**:
|
||||
- Integration: `GET /api/v1/images/{id}/file` → 302 with `Location` header
|
||||
pointing to MinIO URL
|
||||
- Integration: unknown ID → 404 `image_not_found`
|
||||
|
||||
**Done when**: Pasting `http://localhost:8000/api/v1/images/{id}/file` into a
|
||||
browser loads the image directly from MinIO.
|
||||
|
||||
---
|
||||
|
||||
### M4 — UI: Library View
|
||||
|
||||
**Goal**: Angular SPA displays uploaded images in a responsive grid with live
|
||||
tag filtering. Upload button navigates but does not submit yet.
|
||||
|
||||
**Deliverables** (implement only after failing tests exist):
|
||||
- `ImageService` wrapping `GET /api/v1/images` and `GET /api/v1/images/{id}/file`
|
||||
- `TagService` wrapping `GET /api/v1/tags`
|
||||
- `LibraryComponent` (route `/`): responsive grid, thumbnails via `/file`
|
||||
redirect, tag chips per image, debounced tag filter bar, "Load more"
|
||||
pagination (offset/limit), upload button → `/upload`, click → `/images/:id`
|
||||
|
||||
**Tests (write first)**:
|
||||
- Unit: `ImageService` constructs correct query params from filter state
|
||||
- Unit: `TagService` calls correct endpoint with `q` param
|
||||
- Unit: `LibraryComponent` renders image grid from mocked service
|
||||
- Unit: `LibraryComponent` filter change triggers new API call with updated
|
||||
`tags` param
|
||||
|
||||
**Done when**: Library view displays real images uploaded via curl in M2; tag
|
||||
filtering and pagination work.
|
||||
|
||||
---
|
||||
|
||||
### M5 — UI: Upload View
|
||||
|
||||
**Goal**: Users can upload images and add tags through the browser.
|
||||
|
||||
**Deliverables** (implement only after failing tests exist):
|
||||
- `UploadComponent` (route `/upload`): drag-and-drop zone, click-to-browse,
|
||||
tag chip input (comma/space separated), POST to API, duplicate toast →
|
||||
navigate to detail, success toast → navigate to detail, error → inline
|
||||
message, no navigation
|
||||
|
||||
**Tests (write first)**:
|
||||
- Unit: tag chip input lowercases and splits on comma/space
|
||||
- Unit: on `duplicate: true` response → toast shown, navigation triggered
|
||||
- Unit: on `duplicate: false` response → success toast, navigation triggered
|
||||
- Unit: on error response → error displayed, no navigation
|
||||
|
||||
**Done when**: Full upload flow works in browser including duplicate feedback.
|
||||
|
||||
---
|
||||
|
||||
### M6 — UI: Detail View
|
||||
|
||||
**Goal**: Users can view full-size images, edit tags, and delete images.
|
||||
Completes the v1 feature set.
|
||||
|
||||
**Deliverables** (implement only after failing tests exist):
|
||||
- `DetailComponent` (route `/images/:id`): full-size image via `/file`
|
||||
redirect, editable tag chips (× to remove, input to add), save via PATCH
|
||||
on blur/Enter, delete button with confirmation dialog → DELETE → navigate
|
||||
to Library, back button → Library (preserving tag filter state)
|
||||
- `NotFoundComponent`: shown for all unrecognised routes
|
||||
|
||||
**Tests (write first)**:
|
||||
- Unit: removing tag chip calls PATCH with updated list (removed tag absent)
|
||||
- Unit: adding tag + Enter calls PATCH with new tag included
|
||||
- Unit: delete confirmation → DELETE called → navigation to Library
|
||||
- Unit: cancel on confirmation → no DELETE call, stays on detail page
|
||||
- Unit: back button navigates to Library
|
||||
|
||||
**Done when**: Full CRUD loop works in browser: upload → view → re-tag →
|
||||
delete. All tests across all milestones pass. Both linters pass. `docker
|
||||
compose up` starts a fully working application.
|
||||
|
||||
---
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
M0 (skeleton)
|
||||
└── M1 (upload API)
|
||||
└── M2 (tags API)
|
||||
└── M3 (serving API)
|
||||
└── M4 (library UI)
|
||||
└── M5 (upload UI)
|
||||
└── M6 (detail UI)
|
||||
```
|
||||
|
||||
Serial milestones: solo project; API must be stable before UI work begins.
|
||||
|
||||
## What This Plan Defers
|
||||
|
||||
Per constitution §8 and spec §6 — not forgotten, explicitly deferred:
|
||||
|
||||
- **Auth phases**: `AuthProvider` interface wired in M1 (no-op). Phase 2
|
||||
(username/password) and Phase 3 (OIDC) each get their own plan.
|
||||
- **SQLite refactor**: Repository layer is the only thing that changes in a
|
||||
future plan.
|
||||
- **Bulk upload**: Out of scope per spec §6.
|
||||
- **Tag rename/merge on re-upload**: Spec §2.1 explicitly defers; future spec
|
||||
revision adds behaviour to M2.
|
||||
|
||||
## Post-Design Constitution Re-check
|
||||
|
||||
*Performed after Phase 1 artifacts (data-model.md, contracts/api.md,
|
||||
quickstart.md) were generated.*
|
||||
|
||||
No new violations found. All constitutionally-required abstractions appear in
|
||||
the data model and contracts. The `StorageBackend` interface is referenced
|
||||
correctly in the API contract without exposing bucket names. Test colocation
|
||||
confirmed in project structure above.
|
||||
144
specs/001-reaction-image-board/quickstart.md
Normal file
144
specs/001-reaction-image-board/quickstart.md
Normal 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.
|
||||
134
specs/001-reaction-image-board/research.md
Normal file
134
specs/001-reaction-image-board/research.md
Normal 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.
|
||||
259
specs/001-reaction-image-board/spec.md
Normal file
259
specs/001-reaction-image-board/spec.md
Normal 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, 1–64 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 1–3 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.
|
||||
352
specs/001-reaction-image-board/tasks.md
Normal file
352
specs/001-reaction-image-board/tasks.md
Normal 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 (US1–US5 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
|
||||
- T018–T020 (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
|
||||
Reference in New Issue
Block a user