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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
```
|
||||
@@ -0,0 +1,134 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net" version="21.0.0">
|
||||
<diagram name="OSDB Story Dependencies" id="osdb-deps">
|
||||
<mxGraphModel dx="1422" dy="762" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
|
||||
<!-- ===== LAYER LABELS ===== -->
|
||||
<mxCell id="lbl0" value="Layer 1: Foundation" style="text;html=1;align=left;fontSize=10;fontStyle=2;fontColor=#666666;" vertex="1" parent="1">
|
||||
<mxGeometry x="20" y="55" width="160" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="lbl1" value="Layer 2: Core Domain" style="text;html=1;align=left;fontSize=10;fontStyle=2;fontColor=#666666;" vertex="1" parent="1">
|
||||
<mxGeometry x="20" y="175" width="160" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="lbl2" value="Layer 3: Features (parallel)" style="text;html=1;align=left;fontSize=10;fontStyle=2;fontColor=#666666;" vertex="1" parent="1">
|
||||
<mxGeometry x="20" y="315" width="190" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="lbl3" value="Layer 4: Enrichment" style="text;html=1;align=left;fontSize=10;fontStyle=2;fontColor=#666666;" vertex="1" parent="1">
|
||||
<mxGeometry x="20" y="455" width="160" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- ===== STORY NODES ===== -->
|
||||
|
||||
<!-- S01: Bootstrap -->
|
||||
<mxCell id="s01" value="<b>01 · Bootstrap</b><br>Go/Echo + VueJS + PostgreSQL<br>golang-migrate · port 3000/5000" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="430" y="40" width="240" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- S02: Manage Fruits -->
|
||||
<mxCell id="s02" value="<b>02 · Manage Fruits</b><br>CRUD · synonyms · type · images" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="430" y="160" width="240" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- S03: Import Fruit DB -->
|
||||
<mxCell id="s03" value="<b>03 · Import Fruit DB</b><br>SQL script · obstsorten.xml<br>images: einzelfruechte/blueten/baume" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="190" y="300" width="220" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- S04: Publications -->
|
||||
<mxCell id="s04" value="<b>04 · Fruit Publications</b><br>CRUD · PDF · cover image<br>linked fruits · fruit images" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="450" y="300" width="220" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- S06: Search -->
|
||||
<mxCell id="s06" value="<b>06 · Fruit Search</b><br>name + synonyms + type filter<br>combined-type aliases" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="710" y="300" width="200" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- S08: Auth -->
|
||||
<mxCell id="s08" value="<b>08 · Authorize User</b><br>.env user map · bcrypt+salt<br>route protection · hide UI" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="940" y="300" width="200" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- S05: Import Publications -->
|
||||
<mxCell id="s05" value="<b>05 · Import Publications</b><br>Python · osws.xml<br>PDFs + images per pub/fruit" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="450" width="220" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- S07: Improve Overview -->
|
||||
<mxCell id="s07" value="<b>07 · Improve Overview</b><br>fruit cards · auto-crop thumbnail<br>thumbnail_data col · backfill" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="540" y="450" width="220" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- ===== EDGES ===== -->
|
||||
|
||||
<!-- 01 → 02 (hard) -->
|
||||
<mxCell id="e01_02" style="edgeStyle=orthogonalEdgeStyle;rounded=0;" edge="1" source="s01" target="s02" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- 02 → 03 (hard) -->
|
||||
<mxCell id="e02_03" style="edgeStyle=orthogonalEdgeStyle;rounded=0;" edge="1" source="s02" target="s03" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- 02 → 04 (hard) -->
|
||||
<mxCell id="e02_04" style="edgeStyle=orthogonalEdgeStyle;rounded=0;" edge="1" source="s02" target="s04" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- 02 → 06 (hard) -->
|
||||
<mxCell id="e02_06" style="edgeStyle=orthogonalEdgeStyle;rounded=0;" edge="1" source="s02" target="s06" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- 01 → 08 (hard — needs app skeleton) -->
|
||||
<mxCell id="e01_08" style="edgeStyle=orthogonalEdgeStyle;rounded=0;" edge="1" source="s01" target="s08" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- 03 → 05 (hard — fruits must be imported first) -->
|
||||
<mxCell id="e03_05" style="edgeStyle=orthogonalEdgeStyle;rounded=0;" edge="1" source="s03" target="s05" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- 04 → 05 (hard — publications table must exist) -->
|
||||
<mxCell id="e04_05" style="edgeStyle=orthogonalEdgeStyle;rounded=0;" edge="1" source="s04" target="s05" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- 04 → 07 (hard — publication fruit images needed) -->
|
||||
<mxCell id="e04_07" style="edgeStyle=orthogonalEdgeStyle;rounded=0;" edge="1" source="s04" target="s07" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- 02 → 07 (dashed — fruit images also used) -->
|
||||
<mxCell id="e02_07" style="edgeStyle=orthogonalEdgeStyle;rounded=0;dashed=1;endArrow=open;" edge="1" source="s02" target="s07" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- ===== LEGEND ===== -->
|
||||
<mxCell id="leg_box" value="" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="940" y="450" width="210" height="90" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="leg_title" value="<b>Legend</b>" style="text;html=1;align=center;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="940" y="455" width="210" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="leg_hard" value="──▶ Hard dependency (must implement first)" style="text;html=1;align=left;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="950" y="478" width="200" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="leg_soft" value="- - ▷ Soft dependency (reuses model)" style="text;html=1;align=left;fontSize=10;" vertex="1" parent="1">
|
||||
<mxGeometry x="950" y="500" width="200" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- ===== IMPLEMENTATION ORDER ===== -->
|
||||
<mxCell id="order_box" value="<b>Recommended sequential order:</b> 01 → 02 → 03 → 04 → 05 → 06 → 07 → 08<br><i>03 / 04 / 06 can run in parallel after 02 · 08 best done last to protect all features</i>" style="text;html=1;align=left;fontSize=10;fillColor=#fffacd;strokeColor=#aaa;rounded=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="190" y="560" width="700" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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 `<sorte>` 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 |
|
||||
@@ -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 |
|
||||
@@ -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 `<obj>` element where `<osw>` = `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 `<img>` 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 `<obj>` where `<osw>` = `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 |
|
||||
@@ -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 |
|
||||
@@ -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 `<FruitCard>` 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 |
|
||||
@@ -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$...<bcrypt_hash>,...
|
||||
```
|
||||
|
||||
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 <token>` 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 <username> <password>` → 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 |
|
||||
Reference in New Issue
Block a user