# 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 |