Files
osdb-1-claude-opusplan/frontend/src/api/fruits.ts
T
juliaandClaude Sonnet 4.6 1b889ac112 feat: manage fruits — full CRUD with images and synonyms (story #02)
Backend: domain structs, FruitRepository interface + pg implementation,
9 Echo v4 handlers (list/get/create/update/delete, image sub-resources),
migration 000002 (fruit_type ENUM, fruits, fruit_synonyms, fruit_images),
route-scoped BodyLimit("5M") for uploads, http.DetectContentType for serving.

Frontend: typed fetch API layer, Pinia setup-style fruitStore, FruitList
(paginated), FruitCreate, and FruitDetail (edit + synonyms editor + image
gallery). 25 backend unit tests + integration test; 18 frontend tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 10:18:01 +02:00

137 lines
3.4 KiB
TypeScript

export const FRUIT_TYPES = [
'Apfelsorten',
'Birnensorten',
'Quittensorten',
'Aprikosen',
'Pfirsiche',
'Mirabellen',
'Renekloden',
'Pflaumen',
'Zwetschen',
'Sauerkirschen',
'Süßkirschen',
'Brombeeren',
'Erdbeeren',
'Himbeeren',
'Johannisbeeren',
'Stachelbeeren',
'Wein',
] as const
export type FruitType = (typeof FRUIT_TYPES)[number]
export interface FruitImage {
id: number
fruit_id: number
filename: string | null
image_type: string
title: string | null
url: string
created_at: string
}
export interface Fruit {
id: number
name: string
osdb_number: string
comment: string | null
fruit_type: string
synonyms: string[]
images: FruitImage[]
created_at: string
updated_at: string
}
export interface FruitListResponse {
items: Fruit[]
total: number
limit: number
offset: number
}
export interface FruitWriteDTO {
name: string
osdb_number: string
comment?: string | null
fruit_type: string
synonyms: string[]
}
export function imageUrl(fruitId: number, imageId: number): string {
return `/api/v1/fruits/${fruitId}/images/${imageId}`
}
async function checkOk(res: Response): Promise<Response> {
if (!res.ok) {
let msg = `${res.status}`
try {
const body = await res.json()
msg = body.error ?? body.errors?.join(', ') ?? msg
} catch {
// ignore parse error
}
const err = new Error(msg) as Error & { status: number }
err.status = res.status
throw err
}
return res
}
export async function listFruits(limit = 50, offset = 0): Promise<FruitListResponse> {
const res = await fetch(`/api/v1/fruits?limit=${limit}&offset=${offset}`)
return (await checkOk(res)).json()
}
export async function getFruit(id: number): Promise<Fruit> {
const res = await fetch(`/api/v1/fruits/${id}`)
return (await checkOk(res)).json()
}
export async function createFruit(dto: FruitWriteDTO): Promise<Fruit> {
const res = await fetch('/api/v1/fruits', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dto),
})
return (await checkOk(res)).json()
}
export async function updateFruit(id: number, dto: FruitWriteDTO): Promise<Fruit> {
const res = await fetch(`/api/v1/fruits/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dto),
})
return (await checkOk(res)).json()
}
export async function deleteFruit(id: number): Promise<void> {
const res = await fetch(`/api/v1/fruits/${id}`, { method: 'DELETE' })
await checkOk(res)
}
export async function listImages(fruitId: number): Promise<FruitImage[]> {
const res = await fetch(`/api/v1/fruits/${fruitId}/images`)
return (await checkOk(res)).json()
}
export async function addImage(
fruitId: number,
file: File,
imageType: string,
title?: string,
): Promise<FruitImage> {
const form = new FormData()
form.append('image', file)
form.append('image_type', imageType)
if (title) form.append('title', title)
// Do NOT set Content-Type — browser sets it with the correct multipart boundary
const res = await fetch(`/api/v1/fruits/${fruitId}/images`, { method: 'POST', body: form })
return (await checkOk(res)).json()
}
export async function deleteImage(fruitId: number, imageId: number): Promise<void> {
const res = await fetch(`/api/v1/fruits/${fruitId}/images/${imageId}`, { method: 'DELETE' })
await checkOk(res)
}