Deployment: - custom-app.compose.yaml: self-contained Compose fuer TrueNAS "Custom App" (absolute Host-Bind-Pfade, postgres:18, pull_policy always, Port 8090) - scripts/truenas-deploy.sh: Host-Skript create/redeploy via midclt (App bleibt unter Apps sichtbar) inkl. Image-Pull + Health-Check - ci.yml Deploy-Job: laeuft auf ubuntu-latest-Runner, kopiert Deploy-Dateien per SSH auf den NAS-Host und triggert truenas-deploy.sh (statt runs-on goldeye) - compose.yaml/.env.example: postgres:18 (Locale-Match zur Quell-DB), Port 8090 - .gitignore: .agents/, tools/rag/, deploy/truenas/.env (Secrets/Scratch) Aufgelaufene Feature-Arbeit (verified/Freeze, Migrationen, Import-Triage): - GerbilOverride/VerifiedGerbil-Endpoints + GerbilSnapshotService + Tests - EF-Migrationen (ShowInChronicle, Stillborn, BirthOrder, ManualFlag, DSGVO) - Frontend VerifizierteTierePage + verified-API + e2e-Spec - diverse Import-/Triage-Skripte und -Tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
997 lines
42 KiB
TypeScript
997 lines
42 KiB
TypeScript
/**
|
||
* 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)
|
||
}
|
||
|
||
// GEPRÜFTE TIERE: „vollständig korrekt"-Markierung / Schutz (/verified-gerbils).
|
||
if (path === '/verified-gerbils' && method === 'GET') {
|
||
return json(route, 200, [...db.verified].reverse())
|
||
}
|
||
const verMatch = path.match(/^\/verified-gerbils\/([^/]+)$/)
|
||
if (verMatch) {
|
||
const gid = decodeURIComponent(verMatch[1])
|
||
const vIdx = db.verified.findIndex((v) => v.gerbilId === gid)
|
||
if (method === 'GET') {
|
||
return vIdx >= 0 ? json(route, 200, db.verified[vIdx]) : json(route, 404, { title: 'Not Found' })
|
||
}
|
||
if (method === 'POST') {
|
||
const g = db.gerbils.find((x) => x.id === gid)
|
||
if (!g) return json(route, 404, { title: 'Not Found' })
|
||
const body = (request.postDataJSON() ?? {}) as { note?: string | null }
|
||
const row = {
|
||
gerbilId: gid,
|
||
entityName: g.name ?? null,
|
||
isVerified: true,
|
||
status: 'unchanged',
|
||
verifiedAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
note: body.note ?? null,
|
||
protectedFields: ['name', 'gender', 'dateOfBirth', 'genotype'],
|
||
importDiff: [],
|
||
}
|
||
if (vIdx >= 0) db.verified[vIdx] = row
|
||
else db.verified.push(row)
|
||
return json(route, 200, row)
|
||
}
|
||
if (method === 'DELETE') {
|
||
if (vIdx < 0) return json(route, 404, { title: 'Not Found' })
|
||
db.verified.splice(vIdx, 1)
|
||
return json(route, 204)
|
||
}
|
||
return json(route, 405)
|
||
}
|
||
|
||
// 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,
|
||
attachments: [],
|
||
}
|
||
db.feedback.push(created)
|
||
return json(route, 201, created)
|
||
}
|
||
if (method === 'GET') {
|
||
// attachments immer als Array liefern (Mock-Daten haben das Feld evtl. nicht).
|
||
const rows = [...db.feedback].reverse().map((f) => ({ ...f, attachments: f.attachments ?? [] }))
|
||
return json(route, 200, rows)
|
||
}
|
||
return json(route, 405)
|
||
}
|
||
// WEB-PUSH: im Mock deaktiviert (keine VAPID-Schlüssel) → PushToggle blendet sich aus.
|
||
if (path === '/push/vapid-public-key' && method === 'GET') {
|
||
return json(route, 200, { enabled: false, publicKey: null })
|
||
}
|
||
if ((path === '/push/subscribe' || path === '/push/unsubscribe') && method === 'POST') {
|
||
return json(route, 200, {})
|
||
}
|
||
// FEEDBACK-ANHÄNGE: hochladen (POST /feedback/{id}/attachments).
|
||
const attUpload = path.match(/^\/feedback\/([^/]+)\/attachments$/)
|
||
if (attUpload && method === 'POST') {
|
||
const fid = decodeURIComponent(attUpload[1])
|
||
const row = db.feedback.find((f) => f.id === fid)
|
||
if (!row) return json(route, 404, { title: 'Not Found' })
|
||
const body = request.postDataJSON() as { fileName?: string; contentType?: string; dataBase64?: string }
|
||
const meta = {
|
||
id: newId('att'),
|
||
fileName: body.fileName ?? 'datei',
|
||
contentType: body.contentType ?? 'application/octet-stream',
|
||
size: (body.dataBase64 ?? '').length,
|
||
}
|
||
const list = (row.attachments as unknown[] | undefined) ?? []
|
||
list.push(meta)
|
||
row.attachments = list
|
||
return json(route, 201, meta)
|
||
}
|
||
// FEEDBACK-ANHÄNGE: löschen (DELETE /feedback/attachments/{attId}).
|
||
const attDelete = path.match(/^\/feedback\/attachments\/([^/]+)$/)
|
||
if (attDelete && method === 'DELETE') {
|
||
const attId = decodeURIComponent(attDelete[1])
|
||
for (const f of db.feedback) {
|
||
const list = (f.attachments as { id: string }[] | undefined) ?? []
|
||
const i = list.findIndex((a) => a.id === attId)
|
||
if (i >= 0) {
|
||
list.splice(i, 1)
|
||
f.attachments = list
|
||
return json(route, 204)
|
||
}
|
||
}
|
||
return json(route, 404, { title: 'Not Found' })
|
||
}
|
||
// 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
|
||
}
|