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