Files
GerbilManager/gerbil-manager-web/e2e/mock-api.ts
Gulum 48b138bbfd feat(tickets): Volltextsuche + Kategorien, 👍/👎-Rückmeldung & ähnliche gelöste Tickets
- Suche: Volltext über Nachricht/Frage/Antwort/Changelog/Name/Kategorie, plus
  Kategorie-Filter (KI-gesetzte Kategorie als Chip + Auswahlmenü), je innerhalb der
  aktiven Ansicht.
- 👍/👎 auf gelösten Tickets: 👍 speichert die Rückmeldung, 👎 öffnet das Ticket
  wieder (zurück in die Rückfragen) und bittet um die fehlende Info.
- Melde-Fenster: schlägt beim Tippen ähnliche bereits gelöste Tickets vor
  (Wort-Überlappung + Bonus bei gleichem Tier/Wurf/Kontakt) — Self-Service.

Backend: Feedback.Category + Feedback.Helpful (+ PUT-Handling), Migration
FeedbackCategoryAndHelpful (beide nullable). Tests: 260 Backend grün, e2e Tickets
Desktop +4 (Suche/Kategorie/👍/👎), vitest 149.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:14:07 +02:00

916 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* QA-1: Stateful In-Memory-Mock der GerbilManagerWebAPI per Route-Interception.
*
* Bedient den DATA-2-Vertrag: Gridify-paged Listen ({items, totalCount, page,
* pageSize}), camelCase-DTOs, 409 Conflict bei referenzierten Becken/Kontakten
* (von Dwight bestätigt). Jeder Test bekommt über die Fixture in fixtures.ts
* einen FRISCHEN Datenbestand — CRUD-Flüsse mutieren ihn wie das echte Backend.
*/
import type { Page, Route } from '@playwright/test'
import { seedDb, type MockDb } from './mock-data'
/** Default-API-Origin des Frontends (src/api/client.ts). */
const API_ORIGIN = 'http://localhost:5179'
type Row = Record<string, unknown>
let seq = 0
const newId = (prefix: string) => `${prefix}-e2e-${++seq}`
/** Gridify-Escapes entfernen (\, vor Sonderzeichen). */
const unescapeGridify = (s: string) => s.replace(/\\(.)/g, '$1')
/** Mini-Gridify: genau die Ausdrücke, die die App baut (=, contains, ',', '|').
* Gleichheit akzeptiert '=' (kanonisch) UND '==' (alt) — POLISH-1 stellte den
* Client auf Gridifys echtes '=' um; der Mock bleibt für beide robust. */
function matchesFilter(row: Row, filter: string | null): boolean {
if (!filter) return true
return filter.split(',').every((andPart) =>
andPart.split('|').some((cond) => {
// Gridify "contains": `field=*value` — value runs to the end (optionally /i).
// Mirror the REAL backend: no trailing `*` (the old `=\*(.*)\*` regex hid the
// SQL-LIKE `name=*a*` bug that returned 0 rows against real Gridify).
let m = cond.match(/^(\w+)=\*(.*?)(\/i)?$/)
if (m) {
return String(row[m[1]] ?? '')
.toLowerCase()
.includes(unescapeGridify(m[2]).toLowerCase())
}
m = cond.match(/^(\w+)==?(.*)$/)
if (m) return String(row[m[1]] ?? '') === unescapeGridify(m[2])
return true
}),
)
}
function applyOrderBy(rows: Row[], orderBy: string | null): Row[] {
if (!orderBy) return rows
// Mirror Gridify's comma-separated multi-key ordering (e.g. "name,id"): each key
// is "<field> [desc]"; later keys act as tiebreakers.
const keys = orderBy.split(',').map((part) => {
const [field, dir] = part.trim().split(/\s+/)
return { field, sign: dir?.toLowerCase() === 'desc' ? -1 : 1 }
})
return [...rows].sort((a, b) => {
for (const { field, sign } of keys) {
const av = String(a[field] ?? '')
const bv = String(b[field] ?? '')
if (av < bv) return -sign
if (av > bv) return sign
}
return 0
})
}
function pagedResponse(rows: Row[], url: URL) {
const filtered = rows.filter((r) => matchesFilter(r, url.searchParams.get('filter')))
const sorted = applyOrderBy(filtered, url.searchParams.get('orderBy'))
const page = Number(url.searchParams.get('page') ?? '1')
const pageSize = Number(url.searchParams.get('pageSize') ?? '20')
return {
items: sorted.slice((page - 1) * pageSize, page * pageSize),
totalCount: filtered.length,
page,
pageSize,
}
}
const json = (route: Route, status: number, body?: unknown) =>
route.fulfill({
status,
contentType: 'application/json; charset=utf-8',
body: body === undefined ? '' : JSON.stringify(body),
})
/**
* Generische CRUD-Kollektion. `deleteConflict` bildet die 409-Regel des
* Backends ab (Becken nicht leer / Kontakt referenziert).
*/
function collection(
rows: Row[],
idPrefix: string,
deleteConflict?: (id: string, db: MockDb) => boolean,
) {
return { rows, idPrefix, deleteConflict }
}
export async function installMockApi(page: Page): Promise<MockDb> {
const db = seedDb()
const collections: Record<string, ReturnType<typeof collection>> = {
gerbils: collection(db.gerbils as unknown as Row[], 'gerbil'),
litters: collection(db.litters as unknown as Row[], 'litter'),
enclosures: collection(db.enclosures as unknown as Row[], 'enc', (id, d) =>
d.gerbils.some((g) => g.enclosureId === id),
),
contacts: collection(db.contacts as unknown as Row[], 'con', (id, d) =>
d.gerbils.some((g) => g.originContactId === id || g.receiverContactId === id),
),
'color-varieties': collection(db.colorVarieties as unknown as Row[], 'cv'),
'health-records': collection(db.healthRecords as unknown as Row[], 'hr'),
'weight-records': collection(db.weightRecords as unknown as Row[], 'wr'),
contracts: collection(db.contracts as unknown as Row[], 'contract'),
}
const handler = async (route: Route) => {
const request = route.request()
const url = new URL(request.url())
const method = request.method()
// OPS-1: in Produktion ist die API-Basis der relative Pfad /api (nginx-Proxy);
// den Präfix normalisieren, damit der Mock unter beiden Basen funktioniert.
const path = url.pathname.replace(/^\/api(?=\/)/, '')
// EXPORT-1: Zip-Download (Inhalt egal — der Smoke prüft nur, dass der
// Download startet; ein leeres Zip = End-of-central-directory-Record).
if (path === '/export' && method === 'GET') {
return route.fulfill({
status: 200,
contentType: 'application/zip',
headers: { 'Content-Disposition': 'attachment; filename="rennmaus-export-e2e.zip"' },
body: Buffer.from([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]),
})
}
// FEAT-13: Zuchtprofil (Einstellungen-Seite lädt es vor dem Rendern)
if (path === '/settings/breeder-profile') {
if (method === 'GET') {
return json(route, 200, {
zuchtName: 'Zucht der kleinen Chaoten',
name: 'Frau Erika Muster',
address: 'Musterweg 1, 12345 Musterstadt',
phone: '', email: '', homepage: '', city: 'Musterstadt',
nameSuffix: '',
})
}
if (method === 'PUT') return json(route, 204)
}
// ── WEB-3: lokale Vorschau (Renderer-Dateien; nur veröffentlichte Seiten) ──
if (path === '/render-site' && method === 'GET') {
const files = db.pages
.filter((p) => p.status === 'Published')
.map((p) => ({ path: p.slug === 'start' ? 'index.html' : `${p.slug}/index.html`, size: 1000 }))
return json(route, 200, [{ path: 'assets/site.css', size: 500 }, ...files])
}
const pv = path.match(/^\/preview(?:\/(.*))?$/)
if (pv && method === 'GET') {
const key = pv[1] ? pv[1].replace(/\/$/, '') : 'index.html'
if (key === 'assets/site.css') {
return route.fulfill({ status: 200, contentType: 'text/css', body: 'body{font-family:sans-serif}' })
}
const slug = key === 'index.html' ? 'start' : key.replace(/\/index\.html$/, '')
const p = db.pages.find((x) => x.slug === slug && x.status === 'Published')
if (!p) return json(route, 404, { title: 'Not Found' })
return route.fulfill({
status: 200,
contentType: 'text/html; charset=utf-8',
body: `<!doctype html><html lang="de"><head><meta charset="utf-8"><title>${p.title}</title></head><body><h1>${p.title}</h1><p>Vorschau-Mock</p></body></html>`,
})
}
// ── INBOX-1: Anfragen-Posteingang (vor den generischen Kollektionen) ──
if (path === '/requests/sync' && method === 'POST') {
return json(route, 200, db.mailConfigured ? { imported: 0, error: null } : { imported: 0, error: 'MailNotConfigured' })
}
let rm = path.match(/^\/requests\/([^/]+)\/draft$/)
if (rm && method === 'POST') {
if (!db.aiConfigured) {
return json(route, 503, { code: 'AiKeyMissing', message: 'AI nicht konfiguriert' })
}
const r = db.requests.find((x) => x.id === rm![1])
if (!r) return json(route, 404, { title: 'Not Found' })
r.draftReply = `Hallo ${r.fromName ?? ''},\n\nvielen Dank für deine Anfrage!\n\nViele Grüße`
return json(route, 200, r)
}
rm = path.match(/^\/requests\/([^/]+)\/send$/)
if (rm && method === 'POST') {
if (!db.mailConfigured) {
return json(route, 503, { title: 'MailNotConfigured', detail: 'Gmail ist noch nicht konfiguriert.' })
}
const r = db.requests.find((x) => x.id === rm![1])
if (!r) return json(route, 404, { title: 'Not Found' })
r.status = 'Answered'
r.answeredAt = '2026-06-06T10:00:00Z'
return json(route, 200, r)
}
rm = path.match(/^\/requests(?:\/([^/]+))?$/)
if (rm) {
const reqId = rm[1] ? decodeURIComponent(rm[1]) : null
if (!reqId && method === 'GET') {
return json(route, 200, pagedResponse(db.requests as unknown as Row[], url))
}
const r = db.requests.find((x) => x.id === reqId)
if (!r) return json(route, 404, { title: 'Not Found' })
if (method === 'GET') return json(route, 200, r)
if (method === 'PUT') {
// Triage-Vertrag des Backends: assignedContactId wird IMMER übernommen.
const body = request.postDataJSON() as { status?: string | null; assignedContactId?: string | null }
r.assignedContactId = body.assignedContactId ?? null
if (body.status) {
r.status = body.status as typeof r.status
if (body.status === 'Answered') r.answeredAt ??= '2026-06-06T10:00:00Z'
}
return json(route, 204)
}
return json(route, 405)
}
// ABGABE: KI-Inserat-Generator (POST /gerbils/sale-ad) — vor der generischen Route
if (path === '/gerbils/sale-ad' && method === 'POST') {
if (!db.saleAdConfigured) {
return json(route, 503, { code: 'AiKeyMissing', message: 'AI nicht konfiguriert' })
}
return json(route, 200, { text: 'Status: FREI\n\n„Zwei Freunde suchen ein Zuhause" Generierter Inserat-Text für den Mock.' })
}
// ABGABE: Vertrag-Download — /contracts/{id}/file (3 Segmente, nicht vom Generic-Handler bedient)
const cm = path.match(/^\/contracts\/([^/]+)\/file$/)
if (cm && method === 'GET') {
return route.fulfill({
status: 200,
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
headers: { 'Content-Disposition': `attachment; filename="vertrag-${cm[1]}.docx"` },
body: Buffer.from([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]),
})
}
// SEARCH-2b: distinct Herkunft (originBreeder) values, sorted — vor der
// generischen /gerbils/:id-Route abfangen.
if (path === '/gerbils/breeders' && method === 'GET') {
const values = [
...new Set(
db.gerbils
.map((g) => g.originBreeder)
.filter((v): v is string => typeof v === 'string' && v.length > 0),
),
].sort()
return json(route, 200, values)
}
// Sonderrouten zuerst (FEAT-1b/FEAT-4-Verträge)
let m = path.match(/^\/gerbils\/([^/]+)\/photos$/)
if (m) {
if (method === 'GET') return json(route, 200, [])
return json(route, 405)
}
if (path.match(/^\/photos\/[^/]+$/) && method === 'DELETE') return json(route, 204)
// Gehege-Bilder (analog zu Tier-Fotos).
m = path.match(/^\/enclosures\/([^/]+)\/photos$/)
if (m) {
if (method === 'GET') return json(route, 200, [])
return json(route, 405)
}
if (path.match(/^\/enclosure-photos\/[^/]+$/) && method === 'DELETE') return json(route, 204)
// Gehege-Reinigungszyklus: nächste fällige Reinigung = letzte Reinigung + Zyklus (Tage).
const nextCleaning = (lastCleaned?: string | null, cycleDays?: number | null): string | null => {
if (!lastCleaned || cycleDays == null || cycleDays <= 0) return null
const d = new Date(`${lastCleaned}T00:00:00Z`)
d.setUTCDate(d.getUTCDate() + cycleDays)
return d.toISOString().slice(0, 10)
}
// "Als gereinigt markieren": setzt letzte Reinigung auf heute (wie das echte Backend).
m = path.match(/^\/enclosures\/([^/]+)\/mark-cleaned$/)
if (m && method === 'POST') {
const enc = db.enclosures.find((x) => x.id === m![1])
if (!enc) return json(route, 404, { title: 'Not Found' })
enc.lastCleanedDate = new Date().toISOString().slice(0, 10)
enc.nextCleaningDate = nextCleaning(enc.lastCleanedDate, enc.cleaningCycleDays)
return json(route, 200, enc)
}
m = path.match(/^\/gerbils\/([^/]+)\/inbreeding-coefficient$/)
if (m) {
const isKruemel = m[1] === 'kruemel'
return json(route, 200, {
coefficient: isKruemel ? 0.0625 : 0,
percent: isKruemel ? 6.25 : 0,
generationsAvailable: isKruemel ? 5 : 2,
commonAncestors: isKruemel
? [{ id: 'anton', name: 'Anton', contribution: 0.0625 }]
: [],
})
}
// WEB-0b: CMS-Endpunkte (Seiten + Blöcke). Vor den generischen Kollektionen,
// weil 'pages'/'blocks' verschachtelte Routen haben und kein Standard-CRUD sind.
const toSummary = (p: (typeof db.pages)[number]) => ({
id: p.id, slug: p.slug, title: p.title, seoDescription: p.seoDescription, status: p.status,
})
const toBlock = (b: { id: string; order: number; type: string; data: unknown }) => ({
id: b.id, order: b.order, type: b.type, data: b.data,
})
const toPage = (p: (typeof db.pages)[number]) => ({
...toSummary(p),
blocks: [...p.blocks].sort((a, b) => a.order - b.order).map(toBlock),
})
if (path === '/pages' && method === 'GET') {
return json(route, 200, [...db.pages].sort((a, b) => a.slug.localeCompare(b.slug)).map(toSummary))
}
// GET /pages/{slug} · PUT /pages/{id}
m = path.match(/^\/pages\/([^/]+)$/)
if (m && method === 'GET') {
const p = db.pages.find((x) => x.slug === decodeURIComponent(m![1]))
return p ? json(route, 200, toPage(p)) : json(route, 404, { title: 'Not Found' })
}
// PUT /pages/{id}
if (m && method === 'PUT') {
const p = db.pages.find((x) => x.id === m![1])
if (!p) return json(route, 404, { title: 'Not Found' })
const body = request.postDataJSON() as Partial<typeof p>
if (typeof body.slug === 'string') p.slug = body.slug
if (typeof body.title === 'string') p.title = body.title
p.seoDescription = (body.seoDescription as string | null) ?? null
if (body.status === 'Draft' || body.status === 'Published') p.status = body.status
return json(route, 204)
}
// POST /pages/{pageId}/blocks
m = path.match(/^\/pages\/([^/]+)\/blocks$/)
if (m && method === 'POST') {
const p = db.pages.find((x) => x.id === m![1])
if (!p) return json(route, 404, { title: 'Not Found' })
const body = request.postDataJSON() as { type: string; data?: Record<string, unknown>; order?: number }
const order = body.order ?? (p.blocks.reduce((mx, b) => Math.max(mx, b.order), -1) + 1)
const block = { id: newId('blk'), order, type: body.type, data: body.data ?? {} }
p.blocks.push(block)
return json(route, 201, toBlock(block))
}
// PUT /pages/{pageId}/blocks/order (Body: { blockIds })
m = path.match(/^\/pages\/([^/]+)\/blocks\/order$/)
if (m && method === 'PUT') {
const p = db.pages.find((x) => x.id === m![1])
if (!p) return json(route, 404, { title: 'Not Found' })
const { blockIds } = request.postDataJSON() as { blockIds: string[] }
const byId = new Map(p.blocks.map((b) => [b.id, b]))
if (blockIds.length !== p.blocks.length || blockIds.some((id) => !byId.has(id)))
return json(route, 400, 'blockIds must list exactly the page block ids.')
blockIds.forEach((id, i) => { byId.get(id)!.order = i })
return json(route, 204)
}
// PUT/DELETE /blocks/{id}
m = path.match(/^\/blocks\/([^/]+)$/)
if (m) {
const pageOf = db.pages.find((p) => p.blocks.some((b) => b.id === m![1]))
const block = pageOf?.blocks.find((b) => b.id === m![1])
if (!pageOf || !block) return json(route, 404, { title: 'Not Found' })
if (method === 'PUT') {
const body = request.postDataJSON() as { type?: string; data?: Record<string, unknown>; order?: number }
if (typeof body.type === 'string') block.type = body.type
if (body.data) block.data = body.data
if (typeof body.order === 'number') block.order = body.order
return json(route, 204)
}
if (method === 'DELETE') {
pageOf.blocks = pageOf.blocks.filter((b) => b.id !== m![1])
return json(route, 204)
}
}
// ABGABE: POST /contracts — baut url-Feld und markiert Tiere als Abgegeben
if (path === '/contracts' && method === 'POST') {
const body = request.postDataJSON() as { contactId: string; gerbilIds: string[]; price: number; handoverDate: string; contractDate?: string | null }
const id = newId('contract')
const contract = {
id,
contactId: body.contactId,
gerbilIds: body.gerbilIds ?? [],
price: body.price,
handoverDate: body.handoverDate,
contractDate: body.contractDate ?? body.handoverDate,
fileName: `vertrag-${id}.docx`,
createdAt: new Date().toISOString(),
url: `/contracts/${id}/file`,
hasFile: true,
}
;(db.contracts as unknown as Row[]).push(contract as unknown as Row)
// Tiere auf GivenAway setzen (wie das echte Backend)
for (const gid of body.gerbilIds ?? []) {
const g = db.gerbils.find((x) => x.id === gid)
if (g) {
g.status = 'GivenAway'
g.receiverContactId = body.contactId
g.goHomeDate = body.handoverDate
}
}
return json(route, 201, contract)
}
// FEAT-NAMEGEN: /names/suggest
if (path === '/names/suggest' && method === 'GET') {
if (!db.namesConfigured) {
return json(route, 503, { code: 'NamesKeyMissing', message: 'Kein API-Key konfiguriert' })
}
const letter = url.searchParams.get('letter')?.toUpperCase()
const allSuggestions = [
{ name: 'Fenrir', meaning: 'Wolf aus der Nordischen Mythologie', origin: 'Nordisch' },
{ name: 'Freya', meaning: 'Göttin der Liebe und Fruchtbarkeit', origin: 'Nordisch' },
{ name: 'Artemis', meaning: 'Göttin der Jagd und des Mondlichts', origin: 'Griech. Mythologie' },
{ name: 'Kira', meaning: 'Strahlendes Licht', origin: 'Japanisch' },
{ name: 'Luna', meaning: 'Mondgöttin', origin: 'Griech. Mythologie' },
{ name: 'Baldur', meaning: 'Gott des Lichts und der Reinheit', origin: 'Nordisch' },
]
const result = letter
? allSuggestions.filter((s) => s.name.startsWith(letter))
: allSuggestions
return json(route, 200, result)
}
// FEEDBACK: "Fehler melden" — POST persistiert, GET listet (neueste zuerst).
if (path === '/feedback') {
if (method === 'POST') {
const body = request.postDataJSON() as Row
const created = {
id: newId('feedback'),
...body,
userAgent: request.headers()['user-agent'] ?? null,
createdAt: new Date().toISOString(),
status: 'Open',
resolvedAt: null,
question: null,
answer: null,
answeredAt: null,
fixNote: null,
agentContext: null,
thread: [],
reopenedAt: null,
deletedAt: null,
category: null,
helpful: null,
}
db.feedback.push(created)
return json(route, 201, created)
}
if (method === 'GET') {
return json(route, 200, [...db.feedback].reverse())
}
return json(route, 405)
}
// FEEDBACK-TICKETS: Wiederherstellen aus dem Papierkorb (Soft-Delete aufheben).
const restoreMatch = path.match(/^\/feedback\/([^/]+)\/restore$/)
if (restoreMatch) {
const fid = decodeURIComponent(restoreMatch[1])
const row = db.feedback.find((f) => f.id === fid)
if (!row) return json(route, 404, { title: 'Not Found' })
if (method === 'POST') {
row.deletedAt = null
return json(route, 200, row)
}
return json(route, 405)
}
// FEEDBACK-TICKETS: "Meine Tickets" — PUT (Nachricht/Status) und DELETE.
const fm = path.match(/^\/feedback\/([^/]+)$/)
if (fm) {
const fid = decodeURIComponent(fm[1])
const idx = db.feedback.findIndex((f) => f.id === fid)
if (idx < 0) return json(route, 404, { title: 'Not Found' })
if (method === 'PUT') {
const body = request.postDataJSON() as {
message?: string
status?: string
question?: string
answer?: string
fixNote?: string
agentContext?: string
category?: string
helpful?: boolean
}
const row = db.feedback[idx]
// Zustand vor den Mutationen (für die Wiederöffnen-Erkennung).
const wasResolved = row.status === 'Resolved'
const hadOpenRueckfrage = typeof row.question === 'string' && !!(row.question as string).trim()
if (typeof body.message === 'string') {
if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.')
row.message = body.message.trim()
}
// Rückfrage anhängen → NeedsInfo (außer bereits Resolved). Eine vorhandene
// Frage/Antwort-Runde wird zuerst in den Verlauf (thread) verschoben.
if (typeof body.question === 'string') {
const q = body.question.trim()
if (q.length > 0) {
const thread = (row.thread as Record<string, unknown>[] | undefined) ?? []
if (typeof row.question === 'string' && row.question)
thread.push({ role: 'maintainer', text: row.question, at: row.createdAt ?? null })
if (typeof row.answer === 'string' && row.answer)
thread.push({ role: 'breeder', text: row.answer, at: row.answeredAt ?? null })
row.thread = thread
row.answer = null
row.answeredAt = null
row.question = q
if (row.status !== 'Resolved') {
row.status = 'NeedsInfo'
row.resolvedAt = null
}
} else {
row.question = null
}
}
// Antwort der Züchterin → Answered + answeredAt.
if (typeof body.answer === 'string') {
const a = body.answer.trim()
if (a.length === 0) {
row.answer = null
row.answeredAt = null
} else {
row.answer = a
row.answeredAt = new Date().toISOString()
row.status = 'Answered'
row.resolvedAt = null
}
}
if (typeof body.status === 'string') {
const s = body.status
const resolved = s.toLowerCase() === 'resolved'
row.status = resolved
? 'Resolved'
: s === 'NeedsInfo' || s === 'Answered'
? s
: 'Open'
row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null
// „Wieder geöffnet am" nur, wenn ein echt gelöstes Ticket OHNE offene Rückfrage
// wieder geöffnet wird (s. Backend).
if (!resolved && wasResolved && !hadOpenRueckfrage)
row.reopenedAt = new Date().toISOString()
}
// Changelog (fixNote) — laienverständlich, sichtbar.
if (typeof body.fixNote === 'string') {
const note = body.fixNote.trim()
row.fixNote = note.length === 0 ? null : note
}
// Internes Agenten-Arbeitsgedächtnis — ändert den Status nicht.
if (typeof body.agentContext === 'string') {
const c = body.agentContext.trim()
row.agentContext = c.length === 0 ? null : c
}
if (typeof body.category === 'string') {
const cat = body.category.trim()
row.category = cat.length === 0 ? null : cat
}
if (typeof body.helpful === 'boolean') row.helpful = body.helpful
return json(route, 200, row)
}
if (method === 'DELETE') {
// Soft-Delete: in den Papierkorb verschieben (nicht entfernen).
const target = db.feedback[idx]
if (!target.deletedAt) target.deletedAt = new Date().toISOString()
return json(route, 204)
}
return json(route, 405)
}
// RPRO3: RennmausPro-III-Backup-Import (multipart-Upload). Mock liefert eine feste
// Auswertung bzw. ein Import-Ergebnis — keine echte Datei-Verarbeitung.
if (path === '/import/rpro3/analyze' && method === 'POST') {
return json(route, 200, db.rpro3.analyze)
}
if (path === '/import/rpro3/execute' && method === 'POST') {
return json(route, 200, db.rpro3.execute)
}
// ERWERB: Erwerb/Kauf je Tier — GET ?gerbilId= (Array, kein Gridify-Paging),
// POST/PUT/DELETE. Vor den generischen Kollektionen, weil GET kein {items}-Objekt
// liefert und nach gerbilId statt Gridify-filter selektiert.
const acqMatch = path.match(/^\/acquisitions(?:\/([^/]+))?$/)
if (acqMatch) {
const acqId = acqMatch[1] ? decodeURIComponent(acqMatch[1]) : null
if (!acqId) {
if (method === 'GET') {
const gid = url.searchParams.get('gerbilId')
const rows = db.acquisitions.filter((a) => !gid || a.gerbilId === gid)
return json(route, 200, [...rows].reverse())
}
if (method === 'POST') {
const created = {
id: newId('acq'),
sourceContactId: null,
date: null,
price: null,
note: null,
...(request.postDataJSON() as Row),
createdAt: new Date().toISOString(),
}
db.acquisitions.push(created)
return json(route, 201, created)
}
return json(route, 405)
}
const ai = db.acquisitions.findIndex((a) => a.id === acqId)
if (method === 'GET') {
return ai >= 0 ? json(route, 200, db.acquisitions[ai]) : json(route, 404, { title: 'Not Found' })
}
if (method === 'PUT') {
if (ai < 0) return json(route, 404, { title: 'Not Found' })
Object.assign(db.acquisitions[ai], request.postDataJSON() as Row)
return json(route, 204)
}
if (method === 'DELETE') {
if (ai < 0) return json(route, 404, { title: 'Not Found' })
db.acquisitions.splice(ai, 1)
return json(route, 204)
}
return json(route, 405)
}
// ABGABE-STATUS: /reservations — Reservierungs-/Abgabe-Status (verfügbar → reserviert → abgegeben).
if (path === '/reservations') {
const allowed = ['verfuegbar', 'reserviert', 'abgegeben']
const normStatus = (s: unknown) =>
typeof s === 'string' && allowed.includes(s.trim().toLowerCase()) ? s.trim().toLowerCase() : 'verfuegbar'
if (method === 'POST') {
const body = request.postDataJSON() as Row
if (!body.gerbilId) return json(route, 400, 'GerbilId ist erforderlich.')
const nowIso = new Date().toISOString()
const created = {
id: newId('reservation'),
gerbilId: body.gerbilId,
gerbilName: body.gerbilName ?? null,
status: normStatus(body.status),
reservedForContactId: body.reservedForContactId ?? null,
contactName: body.contactName ?? null,
appointmentDate: body.appointmentDate ?? null,
price: body.price ?? null,
note: body.note ?? null,
handedOverDate: body.handedOverDate ?? null,
createdAt: nowIso,
updatedAt: nowIso,
}
db.reservations.push(created)
return json(route, 201, created)
}
if (method === 'GET') {
const gid = url.searchParams.get('gerbilId')
const rows = gid ? db.reservations.filter((r) => r.gerbilId === gid) : [...db.reservations]
return json(route, 200, rows.slice().reverse())
}
return json(route, 405)
}
const resm = path.match(/^\/reservations\/([^/]+)$/)
if (resm) {
const allowed = ['verfuegbar', 'reserviert', 'abgegeben']
const normStatus = (s: unknown) =>
typeof s === 'string' && allowed.includes(s.trim().toLowerCase()) ? s.trim().toLowerCase() : 'verfuegbar'
const rid = decodeURIComponent(resm[1])
const idx = db.reservations.findIndex((r) => r.id === rid)
if (idx < 0) return json(route, 404, { title: 'Not Found' })
if (method === 'PUT') {
const body = request.postDataJSON() as Row
const row = db.reservations[idx]
if (typeof body.status === 'string') row.status = normStatus(body.status)
if ('gerbilName' in body) row.gerbilName = body.gerbilName ?? null
if ('contactName' in body) row.contactName = body.contactName ?? null
if ('note' in body) row.note = body.note ?? null
if ('reservedForContactId' in body) row.reservedForContactId = body.reservedForContactId ?? null
if ('appointmentDate' in body) row.appointmentDate = body.appointmentDate ?? null
if ('price' in body) row.price = body.price ?? null
if ('handedOverDate' in body) row.handedOverDate = body.handedOverDate ?? null
row.updatedAt = new Date().toISOString()
return json(route, 200, row)
}
if (method === 'DELETE') {
db.reservations.splice(idx, 1)
return json(route, 204)
}
return json(route, 405)
}
// WAITLIST: Warteliste/Nachfrage — GET liefert ein BLANKES Array (kein Gridify-Envelope),
// POST/PUT/DELETE wie eine einfache Kollektion. Vor den generischen Kollektionen, weil
// die GET-Antwort kein paginiertes Envelope ist.
const wlm = path.match(/^\/waiting-list(?:\/([^/]+))?$/)
if (wlm) {
const wlId = wlm[1] ? decodeURIComponent(wlm[1]) : null
const ALLOWED = ['offen', 'erfuellt', 'storniert']
const normStatus = (s: unknown): string | null => {
if (s === undefined || s === null || String(s).trim() === '') return 'offen'
const v = String(s).trim()
return ALLOWED.includes(v) ? v : null
}
if (!wlId) {
if (method === 'GET') {
// Neueste Anfrage zuerst (requestedAt desc, dann createdAt desc).
const sorted = [...db.waitingList].sort((a, b) => {
const ar = String(a.requestedAt ?? '')
const br = String(b.requestedAt ?? '')
if (ar !== br) return ar < br ? 1 : -1
return String(a.createdAt ?? '') < String(b.createdAt ?? '') ? 1 : -1
})
return json(route, 200, sorted)
}
if (method === 'POST') {
const body = request.postDataJSON() as Record<string, unknown>
const status = normStatus(body.status)
if (status === null) return json(route, 400, 'Ungültiger Status.')
if (!body.contactId && (!body.contactName || String(body.contactName).trim() === ''))
return json(route, 400, 'Kontakt oder Name ist erforderlich.')
const created = {
id: newId('wl'),
contactId: body.contactId ?? null,
contactName: body.contactName ?? null,
wishColor: body.wishColor ?? null,
wishGender: body.wishGender ?? null,
requestedAt: body.requestedAt ?? null,
status,
note: body.note ?? null,
createdAt: new Date().toISOString(),
}
db.waitingList.push(created)
return json(route, 201, created)
}
return json(route, 405)
}
const wlIdx = db.waitingList.findIndex((r) => r.id === wlId)
if (method === 'GET') {
return wlIdx >= 0 ? json(route, 200, db.waitingList[wlIdx]) : json(route, 404, { title: 'Not Found' })
}
if (method === 'PUT') {
if (wlIdx < 0) return json(route, 404, { title: 'Not Found' })
const body = request.postDataJSON() as Record<string, unknown>
const status = normStatus(body.status)
if (status === null) return json(route, 400, 'Ungültiger Status.')
if (!body.contactId && (!body.contactName || String(body.contactName).trim() === ''))
return json(route, 400, 'Kontakt oder Name ist erforderlich.')
Object.assign(db.waitingList[wlIdx], { ...body, status })
return json(route, 200, db.waitingList[wlIdx])
}
if (method === 'DELETE') {
if (wlIdx < 0) return json(route, 404, { title: 'Not Found' })
db.waitingList.splice(wlIdx, 1)
return json(route, 204)
}
return json(route, 405)
}
// RÜCKNAHMEN: zurückgenommene Tiere (CRUD /returns, GET optional ?gerbilId=).
// Eigene Route (kein Gridify-Paging — die API liefert ein flaches Array).
const ret = path.match(/^\/returns(?:\/([^/]+))?$/)
if (ret) {
const retId = ret[1] ? decodeURIComponent(ret[1]) : null
if (!retId) {
if (method === 'GET') {
const gid = url.searchParams.get('gerbilId')
const rows = gid ? db.returns.filter((r) => r.gerbilId === gid) : db.returns
return json(route, 200, [...rows].reverse())
}
if (method === 'POST') {
const body = request.postDataJSON() as Row
if (!body.gerbilId && !body.gerbilName) {
return json(route, 400, 'Es muss ein Tier angegeben werden.')
}
const created = { id: newId('return'), ...body, createdAt: new Date().toISOString() }
db.returns.push(created)
return json(route, 201, created)
}
return json(route, 405)
}
const idx = db.returns.findIndex((r) => r.id === retId)
if (method === 'PUT') {
if (idx < 0) return json(route, 404, { title: 'Not Found' })
Object.assign(db.returns[idx], request.postDataJSON() as Row)
return json(route, 200, db.returns[idx])
}
if (method === 'DELETE') {
if (idx < 0) return json(route, 404, { title: 'Not Found' })
db.returns.splice(idx, 1)
return json(route, 204)
}
return json(route, 405)
}
// EXHIBITION: Ausstellungs-/Show-Ergebnisse je Tier (POST/GET ?gerbilId=, PUT, DELETE).
// Plain-Liste (kein Gridify), gefiltert per ?gerbilId, neueste zuerst.
if (path === '/exhibitions') {
if (method === 'GET') {
const gerbilId = url.searchParams.get('gerbilId')
const rows = (db.exhibitions as Row[]).filter(
(r) => !gerbilId || r.gerbilId === gerbilId,
)
const sorted = [...rows].sort((a, b) =>
String(b.date ?? '').localeCompare(String(a.date ?? '')),
)
return json(route, 200, sorted)
}
if (method === 'POST') {
const body = request.postDataJSON() as Row
if (!String(body.eventName ?? '').trim())
return json(route, 400, 'EventName darf nicht leer sein.')
const created = {
id: newId('exhibition'),
gerbilId: body.gerbilId ?? null,
entityName: body.entityName ?? null,
eventName: String(body.eventName).trim(),
date: body.date ?? null,
placement: body.placement ?? null,
award: body.award ?? null,
note: body.note ?? null,
createdAt: new Date().toISOString(),
}
;(db.exhibitions as Row[]).push(created)
return json(route, 201, created)
}
return json(route, 405)
}
const em = path.match(/^\/exhibitions\/([^/]+)$/)
if (em) {
const eid = decodeURIComponent(em[1])
const idx = (db.exhibitions as Row[]).findIndex((r) => r.id === eid)
if (idx < 0) return json(route, 404, { title: 'Not Found' })
if (method === 'GET') return json(route, 200, db.exhibitions[idx])
if (method === 'PUT') {
const body = request.postDataJSON() as Row
if ('eventName' in body && !String(body.eventName ?? '').trim())
return json(route, 400, 'EventName darf nicht leer sein.')
Object.assign(db.exhibitions[idx], body)
return json(route, 200, db.exhibitions[idx])
}
if (method === 'DELETE') {
;(db.exhibitions as Row[]).splice(idx, 1)
return json(route, 204)
}
return json(route, 405)
}
// REFS: getippte Kurz-Verweise (Shortlinks) auflösen. Spiegelt das Backend:
// Präfix-Match (>=6 Hex, bindestrich-tolerant) auf der bindestrich-losen Id;
// mehrdeutig/unbekannt => id/name null.
if (path === '/refs/resolve' && method === 'POST') {
const body = request.postDataJSON() as { refs?: { type?: string; code?: string }[] }
const tableFor: Record<string, Row[]> = {
tier: db.gerbils as unknown as Row[],
kontakt: db.contacts as unknown as Row[],
wurf: db.litters as unknown as Row[],
gehege: db.enclosures as unknown as Row[],
}
const normCode = (code: string) => (code ?? '').toLowerCase().replace(/[^0-9a-f]/g, '')
const results = (body.refs ?? []).map((r) => {
const type = (r.type ?? '').toLowerCase()
const prefix = normCode(r.code ?? '')
const rows = tableFor[type]
if (!rows || prefix.length < 6) return { type: r.type, code: r.code, id: null, name: null }
const matches = rows.filter((row) =>
String(row.id ?? '').toLowerCase().replace(/-/g, '').startsWith(prefix),
)
if (matches.length === 1) {
return { type: r.type, code: r.code, id: String(matches[0].id), name: String(matches[0].name ?? '') }
}
return { type: r.type, code: r.code, id: null, name: null }
})
return json(route, 200, results)
}
// Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined
if (!m || !col) return json(route, 404, { title: 'Not Found' })
const id = m[2] ? decodeURIComponent(m[2]) : null
if (!id) {
if (method === 'GET') return json(route, 200, pagedResponse(col.rows, url))
if (method === 'POST') {
const body = request.postDataJSON() as Row
const created = { id: newId(col.idPrefix), ...body }
// Gehege: berechnetes Feld nextCleaningDate wie das echte Backend ableiten.
if (m[1] === 'enclosures') {
created.nextCleaningDate = nextCleaning(
created.lastCleanedDate as string | null,
created.cleaningCycleDays as number | null,
)
}
col.rows.push(created)
return json(route, 201, created)
}
return json(route, 405)
}
const idx = col.rows.findIndex((r) => r.id === id)
if (method === 'GET') {
return idx >= 0 ? json(route, 200, col.rows[idx]) : json(route, 404, { title: 'Not Found' })
}
if (method === 'PUT') {
if (idx < 0) return json(route, 404, { title: 'Not Found' })
Object.assign(col.rows[idx], request.postDataJSON() as Row)
if (m[1] === 'enclosures') {
col.rows[idx].nextCleaningDate = nextCleaning(
col.rows[idx].lastCleanedDate as string | null,
col.rows[idx].cleaningCycleDays as number | null,
)
}
return json(route, 200, col.rows[idx])
}
if (method === 'DELETE') {
if (idx < 0) return json(route, 404, { title: 'Not Found' })
if (col.deleteConflict?.(id, db)) return json(route, 409, { title: 'Conflict' })
col.rows.splice(idx, 1)
return json(route, 204)
}
return json(route, 405)
}
// Beide API-Basen abfangen: absolute Dev-URL und /api-relativ (OPS-1-Proxy).
// URL-Prädikat statt Glob: '**/api/**' würde auch Vites Modul-Requests
// (/src/api/client.ts …) treffen und die App selbst kaputt-intercepten.
await page.route(
(url) => url.origin === API_ORIGIN || url.pathname.startsWith('/api/'),
handler,
)
return db
}