Files

15 KiB
Raw Permalink Blame History

Implementation Plan: Reaction Image Board v1

Branch: 001-reaction-image-board | Date: 2026-05-02 | Spec: spec.md Input: Feature specification from specs/001-reaction-image-board/spec.md

Summary

Build a self-hosted personal reaction image library: a FastAPI backend that stores images in S3-compatible object storage (MinIO locally), persists metadata and tags in PostgreSQL, and an Angular SPA that lets the user upload, browse, filter, view, re-tag, and delete images. The project is split into seven milestones (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)

specs/001-reaction-image-board/
├── plan.md         # This file
├── research.md     # Phase 0 decisions
├── data-model.md   # Entity definitions and DB schema
├── quickstart.md   # Local dev setup walkthrough
├── contracts/      # OpenAPI-aligned endpoint contracts
│   └── api.md
└── tasks.md        # Phase 2 output (/speckit-tasks — NOT created here)

Source Code (repository root)

api/
├── app/
│   ├── main.py             # FastAPI app factory, lifespan, middleware
│   ├── config.py           # Settings from env vars (pydantic-settings)
│   ├── dependencies.py     # FastAPI dependency injection (db session, auth)
│   ├── repositories/
│   │   ├── image_repo.py   # ImageRepository
│   │   └── tag_repo.py     # TagRepository
│   ├── storage/
│   │   ├── backend.py      # StorageBackend interface
│   │   └── s3_backend.py   # S3StorageBackend implementation
│   ├── auth/
│   │   ├── provider.py     # AuthProvider interface
│   │   └── noop.py         # NoOpAuthProvider
│   ├── routers/
│   │   ├── images.py       # /api/v1/images routes
│   │   └── tags.py         # /api/v1/tags route
│   └── models.py           # SQLAlchemy ORM models
├── alembic/
│   ├── alembic.ini
│   └── versions/           # Migration files
├── tests/
│   ├── unit/
│   └── integration/
├── pyproject.toml          # deps, ruff config
└── Dockerfile

ui/
├── src/
│   ├── app/
│   │   ├── app.component.*
│   │   ├── app.routes.ts
│   │   ├── services/
│   │   │   ├── image.service.ts   (+ .spec.ts)
│   │   │   └── tag.service.ts     (+ .spec.ts)
│   │   ├── library/
│   │   │   └── library.component.*  (+ .spec.ts)
│   │   ├── upload/
│   │   │   └── upload.component.*   (+ .spec.ts)
│   │   ├── detail/
│   │   │   └── detail.component.*   (+ .spec.ts)
│   │   └── not-found/
│   │       └── not-found.component.*
│   └── environments/
├── proxy.conf.json         # /api/* → API in dev
├── angular.json
├── package.json
└── Dockerfile

docker-compose.yml
.env.example

Structure Decision: Web application (Option 2 variant). api/ contains the FastAPI project; ui/ contains the Angular project. Both have independent dependency manifests and Dockerfiles per constitution §1.

Milestones

TDD ORDER IS MANDATORY (constitution §5.1): For every task below, write the failing test(s) first, confirm they fail, then implement until they pass.

M0 — Project Skeleton

Goal: A running system with no features. Both services start, connect to their dependencies, and respond to a health check.

Deliverables (implement only after failing tests exist):

  • Monorepo layout per Project Structure above
  • docker-compose.yml: PostgreSQL, MinIO, API, UI dev server
  • .env.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.