commit 1070ba3ac11d1347de24d1131f4f29ab29637ba9 Author: juliaweber Date: Tue Jun 16 11:53:24 2026 +0200 docs: add sprint planning — dependency diagram + specs for all 8 stories Analyzes all Taiga stories, maps hard/soft dependencies, and writes full specs (Goal, Data Model, Components, Verification, Open Questions) for every story before any implementation begins. Co-Authored-By: Claude Sonnet 4.6 diff --git a/docs/planning/README.md b/docs/planning/README.md new file mode 100644 index 0000000..da27004 --- /dev/null +++ b/docs/planning/README.md @@ -0,0 +1,25 @@ +# OSDB Planning Documents + +## Dependency Diagram + +`dependencies.drawio` — open with [app.diagrams.net](https://app.diagrams.net) + +## Story Specs + +| # | Story | Branch | Depends on | +|---|-------|--------|-----------| +| 01 | [Bootstrap](specs/01-bootstrap.md) | `feature/01-bootstrap-the-project` | — | +| 02 | [Manage Fruits](specs/02-manage-fruits.md) | `feature/02-manage-fruits` | 01 | +| 03 | [Import Fruit DB](specs/03-import-fruit-db.md) | `feature/03-import-fruit-db` | 02 | +| 04 | [Fruit Publications](specs/04-fruit-publications.md) | `feature/04-fruit-publications` | 02 | +| 05 | [Import Publications](specs/05-import-publications.md) | `feature/05-import-publications` | 03, 04 | +| 06 | [Fruit Search](specs/06-fruit-search.md) | `feature/06-fruit-search` | 02 | +| 07 | [Improve Overview](specs/07-improve-fruit-overview.md) | `feature/07-improve-fruit-overview` | 02, 04 | +| 08 | [Authorize User](specs/08-authorize-user.md) | `feature/08-authorize-user` | 01 (implement last) | + +## Recommended Implementation Order + +``` +01 → 02 → 03 → 04 → 05 → 06 → 07 → 08 + ↕ (03 and 04 can run in parallel after 02) +``` diff --git a/docs/planning/dependencies.drawio b/docs/planning/dependencies.drawio new file mode 100644 index 0000000..f2bf377 --- /dev/null +++ b/docs/planning/dependencies.drawio @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/planning/specs/01-bootstrap.md b/docs/planning/specs/01-bootstrap.md new file mode 100644 index 0000000..6a3ea80 --- /dev/null +++ b/docs/planning/specs/01-bootstrap.md @@ -0,0 +1,94 @@ +# Spec: 01 · Bootstrap the Project + +**Taiga Story:** #1 · `01-Bootstrap the project` +**Status:** Ready +**Branch:** `feature/01-bootstrap-the-project` + +--- + +## 1. Goal + +Scaffold a working skeleton: Go/Echo REST backend (port 3000) + VueJS frontend (port 5000) backed by PostgreSQL. No business features — only a "Hello World" page proves the stack is wired end-to-end. + +--- + +## 2. Background / Scope / Context / Constraints + +| Item | Value | +|------|-------| +| Backend | Go · Echo framework · PostgreSQL | +| Migrations | golang-migrate | +| Frontend | VueJS · vue-router · Pinia · Vitest · Prettier · Tailwind CSS v4 | +| Backend port | 3000 | +| Frontend port | 5000 | +| DB | PostgreSQL (local dev via Docker or native) | + +All subsequent stories depend on this skeleton. Keep the directory layout extensible: +- `backend/` — Go module, cmd, internal packages +- `frontend/` — Vite+Vue project +- `migrations/` — golang-migrate SQL files +- `docs/` — planning artifacts (already populated) + +--- + +## 3. Necessary Refactorings + +None — greenfield. No existing code to touch. + +--- + +## 4. Related Data Model + +No tables yet. The migration runner must be present so story #02 can add the `fruits` table. + +Migration naming convention: `{version}_{description}.up.sql` / `.down.sql` (golang-migrate standard). + +--- + +## 5. Affected Places / Related Components + +| Area | What to create | +|------|---------------| +| `/backend` | `go.mod`, `cmd/server/main.go`, Echo router, health route `GET /health` → `{"status":"ok"}` | +| `/backend/db` | DB connection pool (pgx or database/sql), loaded from env | +| `/backend/migrations` | `golang-migrate` integration; first migration file (empty, version 000001) | +| `/frontend` | `npm create vite@latest` with Vue + TS preset; install vue-router, pinia, vitest, prettier, tailwind v4 | +| `/frontend/src/views/HelloWorld.vue` | Displays "Hello, OSDB!" heading | +| `/frontend/vite.config.ts` | `server.port: 5000`; proxy `/api` → `http://localhost:3000` | +| `docker-compose.yml` | PostgreSQL service (db port 5432) | +| `.env.example` | `DATABASE_URL`, `PORT` | +| `Makefile` | targets: `dev`, `migrate-up`, `migrate-down`, `test` | + +--- + +## 6. Out of Scope + +- Any business feature (fruit CRUD, search, auth) +- Production deployment config +- Docker image for backend/frontend +- CI/CD pipeline + +--- + +## 7. Verification + +| Check | Expected | +|-------|---------| +| `make migrate-up` | No error; migration version recorded in `schema_migrations` | +| `curl http://localhost:3000/health` | `{"status":"ok"}` | +| `curl http://localhost:3000/api/v1/health` | `{"status":"ok"}` (v1 prefix established) | +| Browser `http://localhost:5000` | Shows "Hello, OSDB!" | +| Frontend → backend API call via proxy | No CORS error; 200 response | +| `npm run test` in frontend | Vitest passes (empty test suite OK) | +| `go test ./...` in backend | No failures | + +--- + +## 8. Open Questions + +| # | Question | Impact | +|---|----------|--------| +| 1 | DB credentials for local dev: `.env` file or docker-compose env block? | Dev setup UX | +| 2 | Go module path: `github.com/osdb/backend` or simpler `osdb/backend`? | All imports | +| 3 | Should `migrations/` live inside `backend/` or at repo root? | golang-migrate path config | +| 4 | Tailwind v4 uses CSS imports — confirm no `tailwind.config.js` needed | Frontend build | \ No newline at end of file diff --git a/docs/planning/specs/02-manage-fruits.md b/docs/planning/specs/02-manage-fruits.md new file mode 100644 index 0000000..2733794 --- /dev/null +++ b/docs/planning/specs/02-manage-fruits.md @@ -0,0 +1,157 @@ +# Spec: 02 · Manage Fruits + +**Taiga Story:** #2 · `02-manage-fruits` +**Status:** Ready +**Branch:** `feature/02-manage-fruits` +**Depends on:** `01-Bootstrap` + +--- + +## 1. Goal + +Maintainers can create, read, update, and delete fruits. Each fruit has a name, OSDB Kürzel (unique number), optional comment, synonyms list, a required type, and an images collection. + +--- + +## 2. Background / Scope / Context / Constraints + +- "OSDB Kürzel" is the public label for the internal `osdb_number` field (unique identifier used by legacy system). +- Fruit type is a fixed enum — no free-form entry. +- Images have an image type (`fruit`, `flower`, `tree`), a filename, and binary data stored in the DB. +- Synonyms are a 1:N child table (not a comma-separated string in the parent row). +- API must support listing, filtering (prepare for story #6), and CRUD. +- No authentication guard yet (added in story #8); all endpoints are public for now. + +### Fruit Type Enum + +``` +Apfelsorten, Birnensorten, Quittensorten, Aprikosen, Pfirsiche, +Mirabellen, Renekloden, Pflaumen, Zwetschen, Sauerkirschen, +Süßkirschen, Brombeeren, Erdbeeren, Himbeeren, Johannisbeeren, +Stachelbeeren, Wein +``` + +--- + +## 3. Necessary Refactorings + +None from prior stories (only bootstrap exists). Echo router structure should be established here as `GET /api/v1/fruits`, `POST /api/v1/fruits`, etc. + +--- + +## 4. Related Data Model + +```sql +-- Migration: 000002_create_fruits.up.sql + +CREATE TYPE fruit_type AS ENUM ( + 'Apfelsorten', 'Birnensorten', 'Quittensorten', 'Aprikosen', + 'Pfirsiche', 'Mirabellen', 'Renekloden', 'Pflaumen', 'Zwetschen', + 'Sauerkirschen', 'Süßkirschen', 'Brombeeren', 'Erdbeeren', + 'Himbeeren', 'Johannisbeeren', 'Stachelbeeren', 'Wein' +); + +CREATE TABLE fruits ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + osdb_number VARCHAR(50) NOT NULL UNIQUE, + comment TEXT, + fruit_type fruit_type NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE fruit_synonyms ( + id SERIAL PRIMARY KEY, + fruit_id INTEGER NOT NULL REFERENCES fruits(id) ON DELETE CASCADE, + synonym VARCHAR(255) NOT NULL +); + +CREATE TABLE fruit_images ( + id SERIAL PRIMARY KEY, + fruit_id INTEGER NOT NULL REFERENCES fruits(id) ON DELETE CASCADE, + filename VARCHAR(255), + data BYTEA NOT NULL, + image_type VARCHAR(20) NOT NULL CHECK (image_type IN ('fruit','flower','tree')), + title VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +-- Note: thumbnail_data column added in story #07 migration +``` + +--- + +## 5. Affected Places / Related Components + +### Backend + +| File | Purpose | +|------|---------| +| `backend/internal/domain/fruit.go` | `Fruit`, `FruitSynonym`, `FruitImage` structs | +| `backend/internal/repository/fruit_repo.go` | DB queries (list, get, create, update, delete, images) | +| `backend/internal/handler/fruit_handler.go` | Echo handlers — request bind/validate, call repo, return JSON | +| `backend/cmd/server/router.go` | Register `/api/v1/fruits` route group | +| `migrations/000002_create_fruits.up.sql` | Schema as above | +| `migrations/000002_create_fruits.down.sql` | DROP TABLE fruit_images, fruit_synonyms, fruits; DROP TYPE fruit_type | + +### Frontend + +| File | Purpose | +|------|---------| +| `frontend/src/api/fruits.ts` | Axios/fetch wrapper — listFruits, getFruit, createFruit, updateFruit, deleteFruit, addImage, deleteImage | +| `frontend/src/stores/fruitStore.ts` | Pinia store | +| `frontend/src/views/FruitList.vue` | Table/card list, search bar (placeholder for #6), link to detail | +| `frontend/src/views/FruitDetail.vue` | Show + edit form, synonyms editor, image gallery with upload/delete | +| `frontend/src/views/FruitCreate.vue` | Create form | +| `frontend/src/router/index.ts` | Add routes: `/fruits`, `/fruits/:id`, `/fruits/new` | + +### API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/fruits` | List (query params: `name`, `type`) | +| POST | `/api/v1/fruits` | Create | +| GET | `/api/v1/fruits/:id` | Get by ID (includes synonyms) | +| PUT | `/api/v1/fruits/:id` | Full update | +| DELETE | `/api/v1/fruits/:id` | Delete (cascades images+synonyms) | +| GET | `/api/v1/fruits/:id/images` | List images (metadata only, no binary) | +| POST | `/api/v1/fruits/:id/images` | Upload image (multipart) | +| GET | `/api/v1/fruits/:id/images/:imageId` | Serve binary image | +| DELETE | `/api/v1/fruits/:id/images/:imageId` | Delete image | + +--- + +## 6. Out of Scope + +- Search/filter implementation (story #6) +- Authentication guards (story #8) +- Thumbnail generation (story #7) +- Bulk import (story #3) +- Publications link (story #4) + +--- + +## 7. Verification + +| Check | Expected | +|-------|---------| +| `POST /api/v1/fruits` with missing name | 422 Unprocessable Entity | +| `POST /api/v1/fruits` with duplicate osdb_number | 409 Conflict | +| `GET /api/v1/fruits/:id` | Returns fruit with synonyms array | +| `POST /api/v1/fruits/:id/images` (multipart JPEG) | 201 Created; GET image serves correct bytes | +| `DELETE /api/v1/fruits/:id` | 204; cascades synonyms+images | +| Frontend: create fruit → appears in list | No page reload needed (Pinia reactive) | +| Frontend: upload image → thumbnail shown | Image served from backend | +| `go test ./...` | All handler + repo tests green | +| `npm run test` | Vitest tests for store and API wrapper green | + +--- + +## 8. Open Questions + +| # | Question | Impact | +|---|----------|--------| +| 1 | Image upload max size limit? | Multipart config, DB row size | +| 2 | Should `GET /api/v1/fruits` return image URLs or inline base64 thumbnails? | Network perf vs simplicity | +| 3 | Pagination for fruit list (offset/limit) or full list? | UX + performance with large dataset | +| 4 | PUT vs PATCH for updates? | Client complexity | diff --git a/docs/planning/specs/03-import-fruit-db.md b/docs/planning/specs/03-import-fruit-db.md new file mode 100644 index 0000000..7603ad5 --- /dev/null +++ b/docs/planning/specs/03-import-fruit-db.md @@ -0,0 +1,135 @@ +# Spec: 03 · Import Old Obstsorten DB + +**Taiga Story:** #3 · `03-import-old-obstsorten-db` +**Status:** Ready +**Branch:** `feature/03-import-fruit-db` +**Depends on:** `02-manage-fruits` (fruits, fruit_synonyms, fruit_images tables) + +--- + +## 1. Goal + +Populate the `fruits`, `fruit_synonyms`, and `fruit_images` tables from the legacy XML export and image files. Delivered as a repeatable SQL/Go/Python script that can be re-run safely (delete-before-insert per fruit). + +--- + +## 2. Background / Scope / Context / Constraints + +- Source XML: `03-data/obstsorten.xml` +- Image directories: + - Fruit images: `03-data/einzelfruechte/` (use files containing `_s0` before extension) + - Flower images: `03-data/blueten/` (use `_s0` files) + - Tree images: `03-data/baume/` (use `_s0` files) +- Image filename pattern: `{id}_*_s0.{ext}` — prefix = fruit id +- Type mapping from XML `typ` field → `fruit_type` enum (see below) +- `created_at` sourced from XML `added` date field (fallback: NOW()) +- OSDB number = XML `id` field +- Script must be idempotent: delete existing images and synonyms for each fruit before re-inserting + +### Type Mapping + +| XML `typ` | DB `fruit_type` | +|-----------|----------------| +| `x` | `Apfelsorten` (Alle Obstsorten → map to most common or skip if no direct match) | +| `a` | `Apfelsorten` | +| `bq` | `Birnensorten` (Birnen- und Quittensorten → split not possible in enum, use `Birnensorten`) | +| `b` | `Birnensorten` | +| `w` | `Wein` | +| `q` | `Quittensorten` | +| `aprpfi` | `Aprikosen` | +| `apr` | `Aprikosen` | +| `pfi` | `Pfirsiche` | +| `mirren` | `Mirabellen` | +| `mir` | `Mirabellen` | +| `ren` | `Renekloden` | +| `pflzwe` | `Pflaumen` | +| `pfl` | `Pflaumen` | +| `zwe` | `Zwetschen` | +| `k` | `Sauerkirschen` | +| `bo` | `Brombeeren` | +| `bob` | `Brombeeren` | +| `boe` | `Erdbeeren` | +| `boh` | `Himbeeren` | +| `boj` | `Johannisbeeren` | +| `bos` | `Stachelbeeren` | +| `wei` | `Wein` | + +> **Note on ambiguous types** (`x`, `bq`, `aprpfi`, `mirren`, `pflzwe`, `bo`, `k`): these map to aggregate categories not directly in the enum. Use the primary type as shown above. Log a warning for each skipped or coerced row. + +--- + +## 3. Necessary Refactorings + +- Confirm `fruit_images.image_type` accepts `'fruit'`, `'flower'`, `'tree'` (already in migration from story #2). +- Leave title blank as specified. + +--- + +## 4. Related Data Model + +Tables consumed (no new migrations): +- `fruits` — insert/upsert per `osdb_number` +- `fruit_synonyms` — delete cascade then re-insert per fruit +- `fruit_images` — delete per fruit then re-insert + +--- + +## 5. Affected Places / Related Components + +### Script: `scripts/import_fruits.py` + +Recommended language: Python (XML parsing, file I/O, psycopg2). + +``` +scripts/ + import_fruits.py ← main import script + README_import.md ← how to run +``` + +Steps: +1. Parse `03-data/obstsorten.xml` with `xml.etree.ElementTree` +2. For each `` element: + a. Map `typ` → enum value (log+skip if unmappable) + b. Split `synonym` by comma → list of synonyms + c. DELETE FROM fruit_synonyms WHERE fruit_id = (SELECT id FROM fruits WHERE osdb_number = id_val) + d. DELETE FROM fruit_images WHERE fruit_id = ... + e. UPSERT into `fruits` (INSERT … ON CONFLICT (osdb_number) DO UPDATE) + f. INSERT synonyms + g. Find matching images in each image directory (glob `{id}_*_s0.*`) + h. INSERT into `fruit_images` for each found image +3. Log counts: fruits upserted, synonyms inserted, images inserted, warnings + +DB connection from `DATABASE_URL` env var. + +--- + +## 6. Out of Scope + +- Importing publications (story #5) +- Modifying the fruit CRUD API +- GUI trigger for import + +--- + +## 7. Verification + +| Check | Expected | +|-------|---------| +| Script runs without error on full dataset | Exit 0 | +| Re-run is idempotent | Same row counts; no duplicate key errors | +| Spot-check: known fruit by osdb_number | Correct name, type, synonyms in DB | +| At least one image per fruit (if source exists) | `fruit_images` rows with non-null `data` | +| Unknown `typ` values | Logged as warnings, fruit still inserted with best-guess type | +| `GET /api/v1/fruits` after import | Returns populated list | + +--- + +## 8. Open Questions + +| # | Question | Impact | +|---|----------|--------| +| 1 | Exact XML element/attribute names in `obstsorten.xml`? | Parser field mapping | +| 2 | What to do with `typ = 'x'` (Alle Obstsorten)? Skip, or map to first enum value? | Data integrity | +| 3 | Are there fruits without any matching image file? | Expected → just 0 images | +| 4 | `added` date field format in XML? | `created_at` parse | +| 5 | Should `bq`, `aprpfi`, `mirren`, `pflzwe`, `bo`, `k` map as above or produce error? | Type fidelity | diff --git a/docs/planning/specs/04-fruit-publications.md b/docs/planning/specs/04-fruit-publications.md new file mode 100644 index 0000000..2fa1867 --- /dev/null +++ b/docs/planning/specs/04-fruit-publications.md @@ -0,0 +1,164 @@ +# Spec: 04 · Fruit Publications + +**Taiga Story:** #4 · `04-fruit-publications` +**Status:** Ready +**Branch:** `feature/04-fruit-publications` +**Depends on:** `02-manage-fruits` (fruits table) + +--- + +## 1. Goal + +Maintainers can manage publications (books, catalogues) that document fruits. Each publication has a cover image, linked fruits, linked PDFs per fruit, and linked fruit images from the publication. Fruit detail page shows descriptions and publication images. + +--- + +## 2. Background / Scope / Context / Constraints + +- `osdb_pub_id` is the unique external identifier (label: "OSDB Kürzel"). +- Cover image stored as BYTEA in `publications.image_data`. +- PDF stored as BYTEA in `publication_descriptions.pdf_data` (one PDF per fruit per publication). +- Fruit images from publications stored in `publication_fruit_images` (BYTEA); `image_type` hardcoded to `'fruit'`, cannot be changed. +- On fruit detail page: show description list only if at least one description exists; link text = publication title; link opens PDF. +- On fruit detail page: show publication fruit images labeled "Publication title · Author". +- Mouseclick on cover image → overlay/drawer with full-size image. + +--- + +## 3. Necessary Refactorings + +- `fruit_images.image_type` already defined in story #2 — no changes. +- `thumbnail_data` column on `publication_fruit_images` deferred to story #7 migration. + +--- + +## 4. Related Data Model + +```sql +-- Migration: 000003_create_publications.up.sql + +CREATE TABLE publications ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + author VARCHAR(255), + osdb_pub_id VARCHAR(50) NOT NULL UNIQUE, + image_data BYTEA, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE publication_fruits ( + publication_id INTEGER NOT NULL REFERENCES publications(id) ON DELETE CASCADE, + fruit_id INTEGER NOT NULL REFERENCES fruits(id) ON DELETE CASCADE, + PRIMARY KEY (publication_id, fruit_id) +); + +CREATE TABLE publication_descriptions ( + id SERIAL PRIMARY KEY, + publication_id INTEGER NOT NULL REFERENCES publications(id) ON DELETE CASCADE, + fruit_id INTEGER NOT NULL REFERENCES fruits(id) ON DELETE CASCADE, + pdf_data BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (publication_id, fruit_id) +); + +CREATE TABLE publication_fruit_images ( + id SERIAL PRIMARY KEY, + publication_id INTEGER NOT NULL REFERENCES publications(id) ON DELETE CASCADE, + fruit_id INTEGER NOT NULL REFERENCES fruits(id) ON DELETE CASCADE, + filename VARCHAR(255), + data BYTEA NOT NULL, + image_type VARCHAR(20) NOT NULL DEFAULT 'fruit', -- hardcoded + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +-- Note: thumbnail_data column added in story #07 migration +``` + +--- + +## 5. Affected Places / Related Components + +### Backend + +| File | Purpose | +|------|---------| +| `backend/internal/domain/publication.go` | `Publication`, `PublicationDescription`, `PublicationFruitImage` structs | +| `backend/internal/repository/publication_repo.go` | DB queries | +| `backend/internal/handler/publication_handler.go` | Echo handlers | +| `backend/cmd/server/router.go` | Register `/api/v1/publications` routes | +| `migrations/000003_create_publications.up.sql` | Schema above | +| `migrations/000003_create_publications.down.sql` | DROP TABLEs | + +### API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/publications` | List | +| POST | `/api/v1/publications` | Create | +| GET | `/api/v1/publications/:id` | Get (with linked fruits list) | +| PUT | `/api/v1/publications/:id` | Update | +| DELETE | `/api/v1/publications/:id` | Delete | +| POST | `/api/v1/publications/:id/image` | Upload cover image | +| GET | `/api/v1/publications/:id/image` | Serve cover image | +| DELETE | `/api/v1/publications/:id/image` | Delete cover image | +| GET | `/api/v1/publications/:id/fruits` | List linked fruits | +| POST | `/api/v1/publications/:id/fruits` | Link a fruit | +| DELETE | `/api/v1/publications/:id/fruits/:fruitId` | Unlink a fruit | +| GET | `/api/v1/publications/:id/descriptions` | List descriptions (fruit + link metadata) | +| POST | `/api/v1/publications/:id/descriptions` | Upload PDF description (body: fruitId + PDF) | +| GET | `/api/v1/publications/:id/descriptions/:descId` | Serve PDF | +| DELETE | `/api/v1/publications/:id/descriptions/:descId` | Delete | +| GET | `/api/v1/publications/:id/fruit-images` | List pub fruit images | +| POST | `/api/v1/publications/:id/fruit-images` | Upload fruit image (fruitId + image) | +| GET | `/api/v1/publications/:id/fruit-images/:imgId` | Serve image | +| DELETE | `/api/v1/publications/:id/fruit-images/:imgId` | Delete | +| GET | `/api/v1/fruits/:id/descriptions` | All descriptions for a fruit (across publications) | +| GET | `/api/v1/fruits/:id/publication-images` | All pub images for a fruit | + +### Frontend + +| File | Purpose | +|------|---------| +| `frontend/src/api/publications.ts` | API wrapper | +| `frontend/src/stores/publicationStore.ts` | Pinia store | +| `frontend/src/views/PublicationList.vue` | List + link to detail | +| `frontend/src/views/PublicationDetail.vue` | Full management UI | +| `frontend/src/components/ImageOverlayDrawer.vue` | Full-size image overlay on click | +| `frontend/src/views/FruitDetail.vue` | Extended: descriptions section + pub images section | +| `frontend/src/router/index.ts` | Add `/publications`, `/publications/:id` | + +--- + +## 6. Out of Scope + +- Bulk import from XML (story #5) +- Thumbnail generation (story #7) +- Authentication guards (story #8) + +--- + +## 7. Verification + +| Check | Expected | +|-------|---------| +| Create publication with duplicate osdb_pub_id | 409 Conflict | +| Upload cover image → serve back → correct bytes | Pass | +| Link fruit to publication → appears in GET /publications/:id/fruits | Pass | +| Upload PDF description → GET description → Content-Type: application/pdf | Pass | +| Fruit detail page: no descriptions → section hidden | Pass | +| Fruit detail page: 1+ description → links visible | Pass | +| Click cover image thumbnail → overlay opens full-size | Pass | +| Pub fruit images on fruit detail labeled correctly | Publication title · Author shown | +| `go test ./...` | Green | +| `npm run test` | Green | + +--- + +## 8. Open Questions + +| # | Question | Impact | +|---|----------|--------| +| 1 | Is one PDF per (publication, fruit) pair enforced (UNIQUE) or allow multiples? | Schema + UI | +| 2 | Can a fruit image exist in a publication without the fruit being linked via publication_fruits? | Data integrity | +| 3 | Listing publications: include fruit count or just metadata? | API response shape | +| 4 | PDF viewer: open in new tab or inline browser PDF viewer? | UX | diff --git a/docs/planning/specs/05-import-publications.md b/docs/planning/specs/05-import-publications.md new file mode 100644 index 0000000..16f0497 --- /dev/null +++ b/docs/planning/specs/05-import-publications.md @@ -0,0 +1,114 @@ +# Spec: 05 · Import Existing Publications + +**Taiga Story:** #5 · `05-import-existing-publications` +**Status:** Ready +**Branch:** `feature/05-import-publications` +**Depends on:** `03-import-fruit-db` (fruits must exist), `04-fruit-publications` (publications tables) + +--- + +## 1. Goal + +A standalone Python script imports all publications from `03-data/osws.xml`, their cover images, linked fruits (determined by filesystem scan), PDFs, and fruit images into the database. + +--- + +## 2. Background / Scope / Context / Constraints + +- Separate file from the fruit import script — do not merge. +- Publication identified by XML `` element where `` = `1`. +- Fruit linking is NOT derived from `obstsorten.xml` tags (unreliable subset) but from filesystem scan of `03-data/osdb/{publicationId}/`. +- Image existence is not guaranteed — skip silently if file not found. +- `author` field: skip if value is `"."`. +- Cover image path given by `` tag content (relative to `03-data/`). + +### File patterns in `03-data/osdb/{publicationId}/` + +| Pattern | Import target | +|---------|--------------| +| `{fruitId}_{publicationId}_s0.jpg` | `publication_fruit_images` | +| `{fruitId}_{publicationId}.pdf` | `publication_descriptions` | + +- Collect unique `fruitId` values from both patterns → union = linked fruit set. +- Look up each `fruitId` against `fruits.osdb_number` → skip if no match. +- Link matched fruits via `publication_fruits`. + +--- + +## 3. Necessary Refactorings + +- Script must run AFTER `import_fruits.py` (fruits table populated). +- Idempotent: use `INSERT … ON CONFLICT (osdb_pub_id) DO UPDATE` for publications; delete cascade clears linked data before re-import if re-run. + +--- + +## 4. Related Data Model + +Tables written (all created by story #4 migration): +- `publications` +- `publication_fruits` +- `publication_descriptions` +- `publication_fruit_images` + +--- + +## 5. Affected Places / Related Components + +``` +scripts/ + import_fruits.py ← existing (story #3) + import_publications.py ← new + README_import.md ← update with run order and env var requirements +``` + +### Script: `scripts/import_publications.py` + +Steps: +1. Parse `03-data/osws.xml`; iterate `` where `` = `1` +2. For each publication: + a. Extract id, name, author (skip if `"."`) + b. Resolve cover image: `03-data/{img_tag_value}` — read bytes; skip if missing + c. `UPSERT` into `publications` (conflict on `osdb_pub_id`) + d. Scan `03-data/osdb/{id}/` for `*_s0.jpg` and `*.pdf` files + e. Collect unique fruit IDs from filenames + f. Resolve fruit IDs → `fruits.id` via `osdb_number`; skip unresolved + g. Delete existing `publication_fruits` for this pub; re-insert linked fruits + h. Delete existing `publication_descriptions` for this pub; import PDFs + i. Delete existing `publication_fruit_images` for this pub; import images +3. Log: publications upserted, cover images loaded, fruits linked, PDFs imported, images imported, skips + +DB from `DATABASE_URL` env var. + +--- + +## 6. Out of Scope + +- Modifying publication CRUD API +- GUI import trigger +- Fruit import (handled by script #3) + +--- + +## 7. Verification + +| Check | Expected | +|-------|---------| +| Script runs to completion | Exit 0, logs summary counts | +| Re-run is idempotent | Same final row counts | +| Publication with `osw != 1` | Not imported | +| Author `"."` | Stored as NULL | +| Cover image file missing | Skipped, publication still imported | +| Fruit ID from filename not in fruits table | Skipped with log warning | +| `GET /api/v1/publications` after import | Returns populated list | +| Spot-check: known publication | Correct title, linked fruits visible | + +--- + +## 8. Open Questions + +| # | Question | Impact | +|---|----------|--------| +| 1 | Exact XML element structure in `osws.xml`? (tag names, nesting) | Parser field mapping | +| 2 | Can `03-data/osdb/{id}/` be missing entirely for some publications? | Directory scan guard | +| 3 | Re-import strategy: delete-then-insert vs upsert per file? | Idempotency | +| 4 | Encoding of XML file (UTF-8 or Latin-1)? | `ElementTree` `encoding` param | diff --git a/docs/planning/specs/06-fruit-search.md b/docs/planning/specs/06-fruit-search.md new file mode 100644 index 0000000..aac3710 --- /dev/null +++ b/docs/planning/specs/06-fruit-search.md @@ -0,0 +1,137 @@ +# Spec: 06 · Fruit Search + +**Taiga Story:** #6 · `06-fruit-search` +**Status:** Ready +**Branch:** `feature/06-fruit-search` +**Depends on:** `02-manage-fruits` (fruits + fruit_synonyms) + +--- + +## 1. Goal + +Improve the fruit list search: users can filter by name/synonym text and optionally by type. Combined-type aliases (e.g. "Birnen- und Quittensorten") match both constituent types. + +--- + +## 2. Background / Scope / Context / Constraints + +- Target component: `FruitList.vue` (search bar already exists as placeholder from story #2). +- Search is case-insensitive, substring match on `fruits.name` OR any `fruit_synonyms.synonym`. +- Type filter is optional; when set, filters to exactly that enum value OR to its alias group. +- Combined-type aliases (not in the enum — only in search UI): + +| Alias | Matches enum values | +|-------|-------------------| +| Birnen- und Quittensorten | `Birnensorten`, `Quittensorten` | +| Aprikosen und Pfirsiche | `Aprikosen`, `Pfirsiche` | +| Mirabellen und Reineclauden | `Mirabellen`, `Renekloden` | +| Pflaumen und Zwetschen | `Pflaumen`, `Zwetschen` | + +- Search is client-triggered (on input, debounced) or button-triggered — consistent with existing UX pattern. +- API already supports `?name=` and `?type=` query params (placeholder from story #2); this story implements them correctly on the backend. + +--- + +## 3. Necessary Refactorings + +- `GET /api/v1/fruits` handler: add SQL filter logic for `name` (ILIKE on name + JOIN synonyms) and `type` (exact enum match or multi-value IN for aliases). +- `FruitList.vue`: wire up search input + type dropdown to API query params. + +--- + +## 4. Related Data Model + +No new tables or migrations. Query pattern: + +```sql +SELECT DISTINCT f.* +FROM fruits f +LEFT JOIN fruit_synonyms fs ON fs.fruit_id = f.id +WHERE + ($name = '' OR f.name ILIKE '%' || $name || '%' OR fs.synonym ILIKE '%' || $name || '%') + AND ($types IS NULL OR f.fruit_type = ANY($types::fruit_type[])) +ORDER BY f.name; +``` + +`$types` is an array expanded from the selected alias or single type value. + +--- + +## 5. Affected Places / Related Components + +### Backend + +| File | Change | +|------|--------| +| `backend/internal/repository/fruit_repo.go` | `List(name, types []string)` — parameterized SQL | +| `backend/internal/handler/fruit_handler.go` | Parse `?name=` and `?type=` query params; resolve alias → types array | +| `backend/internal/domain/fruit.go` | Add alias map constant | + +### Frontend + +| File | Change | +|------|--------| +| `frontend/src/views/FruitList.vue` | Search text input (debounced 300 ms) + type dropdown (enum values + aliases) | +| `frontend/src/api/fruits.ts` | `listFruits({name?, type?})` passes query params | +| `frontend/src/stores/fruitStore.ts` | Store search state; trigger reload on change | + +### Type Dropdown Options + +``` +(All) +Apfelsorten +Birnensorten +Quittensorten +Birnen- und Quittensorten ← alias +Aprikosen +Pfirsiche +Aprikosen und Pfirsiche ← alias +Mirabellen +Renekloden +Mirabellen und Reineclauden ← alias +Pflaumen +Zwetschen +Pflaumen und Zwetschen ← alias +Sauerkirschen +Süßkirschen +Brombeeren +Erdbeeren +Himbeeren +Johannisbeeren +Stachelbeeren +Wein +``` + +--- + +## 6. Out of Scope + +- Full-text search index (pg `tsvector`) — ILIKE sufficient for expected dataset size +- Search in comment field +- Sorting controls +- Pagination (defer to later if needed) + +--- + +## 7. Verification + +| Check | Expected | +|-------|---------| +| Search "Boskop" → results include fruit named "Boskop" | Pass | +| Search "boskop" (lowercase) | Same results (case-insensitive) | +| Search by synonym term | Fruits with matching synonym appear | +| Filter "Birnensorten" | Only Birnensorten fruits | +| Filter "Birnen- und Quittensorten" | Birnensorten AND Quittensorten fruits | +| Combined name + type filter | Intersection of both | +| Empty search + no type filter | All fruits | +| `go test ./...` — repo list filter tests | Green | + +--- + +## 8. Open Questions + +| # | Question | Impact | +|---|----------|--------| +| 1 | Debounce delay: 300 ms sufficient or user preference? | UX | +| 2 | Search on `Enter` only vs real-time? | UX | +| 3 | Type dropdown: grouped by alias vs flat list? | UX clarity | diff --git a/docs/planning/specs/07-improve-fruit-overview.md b/docs/planning/specs/07-improve-fruit-overview.md new file mode 100644 index 0000000..a8ec8cc --- /dev/null +++ b/docs/planning/specs/07-improve-fruit-overview.md @@ -0,0 +1,151 @@ +# Spec: 07 · Improve Fruit Overview + +**Taiga Story:** #7 · `07-improve-fruit-overview` +**Status:** Ready +**Branch:** `feature/07-improve-fruit-overview` +**Depends on:** `02-manage-fruits` (fruit_images), `04-fruit-publications` (publication_fruit_images) + +--- + +## 1. Goal + +Replace the plain fruit list with styled cards showing title, OSDB ID, type, synonyms, and an auto-cropped thumbnail. Thumbnails are generated at upload time in Go (stdlib only) and stored as a separate DB column. A backfill endpoint handles existing images. + +--- + +## 2. Background / Scope / Context / Constraints + +- Thumbnail selection priority: + 1. Fruit's own image with `image_type = 'fruit'` (first found) + 2. First `publication_fruit_images` entry linked to the fruit + 3. No image → no thumbnail (graceful empty state) +- Thumbnail constraint: image may be scaled down but MUST NOT be cropped (aspect ratio preserved). Empty borders allowed — no cutting. +- Auto-crop algorithm: find bounding box of non-background content → add small padding → crop → re-encode as JPEG. Falls back to original if image decoding fails. +- Thumbnail stored in `thumbnail_data BYTEA` column (nullable) on `fruit_images` and `publication_fruit_images`. +- New serve endpoints for thumbnails, falling back to full `data` if `thumbnail_data` is NULL. +- Backfill: HTTP endpoint `POST /api/v1/admin/backfill-thumbnails` (or CLI command) processes all rows without `thumbnail_data`. +- No external libraries (OpenCV etc.) — Go stdlib only (`image/jpeg`, `image/png`). + +--- + +## 3. Necessary Refactorings + +- Add `thumbnail_data` migration for both image tables. +- On-upload path for both `fruit_images` and `publication_fruit_images`: run crop before INSERT. +- `ListFruits` response: include `thumbnail_url` pointing to new `/thumbnail` endpoint. +- `FruitList.vue`: replace table rows with card components. + +--- + +## 4. Related Data Model + +```sql +-- Migration: 000004_add_thumbnails.up.sql + +ALTER TABLE fruit_images + ADD COLUMN thumbnail_data BYTEA; + +ALTER TABLE publication_fruit_images + ADD COLUMN thumbnail_data BYTEA; +``` + +--- + +## 5. Affected Places / Related Components + +### Backend + +| File | Change | +|------|--------| +| `backend/internal/imaging/crop.go` | Auto-crop function: `CropToContent(data []byte) ([]byte, error)` | +| `backend/internal/repository/fruit_repo.go` | `CreateImage`: call `CropToContent`, store both `data` + `thumbnail_data` | +| `backend/internal/repository/publication_repo.go` | Same for `publication_fruit_images` | +| `backend/internal/handler/fruit_handler.go` | Add `GET /fruits/:id/images/:imageId/thumbnail` | +| `backend/internal/handler/publication_handler.go` | Add `GET /publications/:id/fruit-images/:imgId/thumbnail` | +| `backend/internal/handler/admin_handler.go` | `POST /api/v1/admin/backfill-thumbnails` | +| `backend/cmd/server/router.go` | Register new routes | +| `migrations/000004_add_thumbnails.up.sql` | ALTER TABLE | + +### Crop Algorithm (Go stdlib) + +```go +// Pseudocode — no external deps +func CropToContent(src []byte) ([]byte, error) { + img, _, err := image.Decode(bytes.NewReader(src)) + if err != nil { return src, nil } // fallback: return original + bounds := img.Bounds() + // Detect background: sample corner pixel as bg color + bgColor := img.At(bounds.Min.X, bounds.Min.Y) + // Find bounding box of non-bg pixels + minX, minY, maxX, maxY := findContentBounds(img, bgColor, threshold=10) + // Add padding (e.g. 5px each side) + // Crop to bounding box + // Encode as JPEG + return encodedBytes, nil +} +``` + +### Frontend + +| File | Change | +|------|--------| +| `frontend/src/views/FruitList.vue` | Replace rows with `` components | +| `frontend/src/components/FruitCard.vue` | Card: thumbnail + name + OSDB ID + type + synonyms | +| `frontend/src/api/fruits.ts` | `listFruits` response includes `thumbnail_url` | + +### API Changes + +| New endpoint | Description | +|-------------|-------------| +| `GET /api/v1/fruits/:id/images/:imageId/thumbnail` | Serves `thumbnail_data`, falls back to `data` | +| `GET /api/v1/publications/:id/fruit-images/:imgId/thumbnail` | Same | +| `POST /api/v1/admin/backfill-thumbnails` | Processes all rows missing `thumbnail_data` | + +`ListFruits` response shape per fruit: +```json +{ + "id": 1, + "name": "Boskop", + "osdb_number": "a001", + "fruit_type": "Apfelsorten", + "synonyms": ["Roter Boskop"], + "thumbnail_url": "/api/v1/fruits/1/images/3/thumbnail" +} +``` + +--- + +## 6. Out of Scope + +- Interactive image editor / manual crop +- WebP encoding +- CDN / object storage for images +- Pagination of fruit list + +--- + +## 7. Verification + +| Check | Expected | +|-------|---------| +| Upload fruit image → `fruit_images.thumbnail_data` is non-null | Pass | +| `GET /thumbnail` endpoint → responds with JPEG, smaller than original | Pass | +| Backfill endpoint: runs on existing rows without thumbnail_data | All rows populated | +| `GET /api/v1/fruits` → each fruit with image has `thumbnail_url` | Pass | +| Fruit with no image → `thumbnail_url: null` | Graceful | +| Thumbnail falls back to publication image if no direct image | Correct priority | +| `FruitList.vue` shows cards with thumbnails | Visual check | +| Image with undecodable format → fallback = original stored, no error | Pass | +| `go test ./...` including imaging unit tests | Green | + +--- + +## 8. Open Questions + +| # | Question | Impact | +|---|----------|--------| +| 1 | Background detection threshold: what color tolerance? | Crop quality | +| 2 | Padding around cropped content: 5px? 10px? | Visual quality | +| 3 | Should backfill endpoint be idempotent (skip rows already having thumbnail)? | Yes — only process WHERE thumbnail_data IS NULL | +| 4 | Max concurrent backfill workers? | Performance | +| 5 | Thumbnail max dimensions (e.g. 300×300 max)? | UX + storage | diff --git a/docs/planning/specs/08-authorize-user.md b/docs/planning/specs/08-authorize-user.md new file mode 100644 index 0000000..e43a425 --- /dev/null +++ b/docs/planning/specs/08-authorize-user.md @@ -0,0 +1,128 @@ +# Spec: 08 · Authorize User + +**Taiga Story:** #8 · `08-authorize-user` +**Status:** Ready +**Branch:** `feature/08-authorize-user` +**Depends on:** `01-Bootstrap` (needs full feature set to protect; implement last) + +--- + +## 1. Goal + +Protect all data-modifying API endpoints behind login. Hide CRUD controls and admin nav links for unauthenticated visitors. Simple `.env`-based user/password map; password hashed with username as salt. + +--- + +## 2. Background / Scope / Context / Constraints + +- No OAuth, LDAP, or DB-stored users — `.env` file maps `username → hashed_password`. +- Password hashed with bcrypt where the username is used as the pepper/salt input alongside the password (e.g. hash `username:password` together or prepend username to password before hashing). +- Helper script `scripts/create_user.sh` (or `scripts/create_user.py`) generates the bcrypt entry. +- Session: JWT token (short-lived, e.g. 1 h) stored in `localStorage` or `httpOnly` cookie. +- Frontend reads auth state from Pinia and conditionally renders CRUD buttons + admin link. +- Read-only endpoints (`GET *`) remain public. +- Write endpoints (`POST`, `PUT`, `DELETE`) require valid JWT. +- Admin backfill endpoint from story #7 must also be protected. + +--- + +## 3. Necessary Refactorings + +- Echo middleware: JWT auth middleware applied to all non-GET routes. +- All existing CRUD handlers continue to work unchanged — middleware handles auth, not handlers. +- `FruitList.vue`, `FruitDetail.vue`, `PublicationList.vue`, `PublicationDetail.vue`: conditionally show/hide edit, delete, upload, create buttons. +- Nav/header: hide "Administration" link if not logged in. + +--- + +## 4. Related Data Model + +No DB changes. Auth is stateless (JWT) backed by `.env`. + +`.env` addition: +``` +USERS_MAP=admin:$2a$12$...,... +``` + +Or a separate file `users.env`: +``` +# username:bcrypt_hash +admin:$2a$12$abc... +julia:$2a$12$xyz... +``` + +--- + +## 5. Affected Places / Related Components + +### Backend + +| File | Purpose | +|------|---------| +| `backend/internal/auth/auth.go` | `LoadUsers(path)`, `VerifyPassword(user, pass)`, `GenerateJWT()`, `ValidateJWT()` | +| `backend/internal/middleware/jwt_auth.go` | Echo middleware — checks `Authorization: Bearer ` header | +| `backend/cmd/server/router.go` | Apply middleware to write-route groups | +| `backend/internal/handler/auth_handler.go` | `POST /api/v1/auth/login` → returns JWT | +| `scripts/create_user.sh` (or `.py`) | `./create_user.sh ` → prints `.env` line with bcrypt hash | +| `.env.example` | Add `USERS_FILE=./users.env` | + +### API Endpoints + +| Method | Path | Auth required | +|--------|------|:---:| +| POST | `/api/v1/auth/login` | No | +| GET | `/api/v1/fruits` | No | +| GET | `/api/v1/fruits/:id` | No | +| POST/PUT/DELETE | `/api/v1/fruits/*` | **Yes** | +| POST/PUT/DELETE | `/api/v1/publications/*` | **Yes** | +| POST | `/api/v1/admin/*` | **Yes** | + +### Frontend + +| File | Change | +|------|--------| +| `frontend/src/stores/authStore.ts` | Pinia: `isLoggedIn`, `token`, `login()`, `logout()` | +| `frontend/src/api/auth.ts` | `POST /api/v1/auth/login` | +| `frontend/src/views/LoginView.vue` | Login form | +| `frontend/src/router/index.ts` | Admin route guard (redirect if not logged in) | +| All CRUD views | Wrap buttons with `v-if="authStore.isLoggedIn"` | +| `frontend/src/components/NavBar.vue` | Conditionally show admin link + login/logout button | + +--- + +## 6. Out of Scope + +- Role-based access control (admin vs. read-only) +- Password reset / forgot password flow +- Session revocation / token blacklist +- Multiple auth backends (OAuth, SSO) +- User management UI + +--- + +## 7. Verification + +| Check | Expected | +|-------|---------| +| `POST /api/v1/fruits` without token | 401 Unauthorized | +| `POST /api/v1/auth/login` with valid creds | 200 + JWT in response | +| `POST /api/v1/fruits` with valid JWT | 201 Created | +| Expired JWT | 401 | +| `GET /api/v1/fruits` without token | 200 (public) | +| Frontend: not logged in → no CRUD buttons visible | Pass | +| Frontend: logged in → CRUD buttons visible | Pass | +| Frontend: not logged in → admin nav link hidden | Pass | +| `create_user.sh admin secret` → entry verifiable with bcrypt | Pass | +| `go test ./...` auth middleware tests | Green | + +--- + +## 8. Open Questions + +| # | Question | Impact | +|---|----------|--------| +| 1 | JWT stored in `localStorage` (simpler) or `httpOnly` cookie (more secure)? | XSS risk vs complexity | +| 2 | Token expiry: 1h with no refresh, or sliding expiry? | UX | +| 3 | Username used as bcrypt cost salt or prepended to password string? | Auth security | +| 4 | Should login endpoint be rate-limited? | Brute-force protection | +| 5 | Is `users.env` committed to git or only `.env.example`? | Secrets management |