feat(hilfe): Eigene Tickets — Fehlerberichte ansehen, bearbeiten, löschen, lösen

Unter Hilfe (/hilfe/tickets) eine Verwaltung der eingereichten „Fehler melden"-
Berichte: Liste (neueste zuerst) mit Status-Badge (Offen / ✓ Gelöst), Kontext-
Label, betroffenem Objekt, Nachricht und Datum. Pro Ticket: als gelöst markieren/
wieder öffnen, Nachricht inline bearbeiten, löschen (mit Bestätigung).

Backend: Feedback um Status ("Open"/"Resolved", Default Open) + nullable ResolvedAt
erweitert (weiterhin FK-frei → übersteht Ingest-Wipe); Migration AddFeedbackStatus.
Endpoints: GET /feedback (neueste zuerst, inkl. Status), PUT /feedback/{id}
(Nachricht/Status; leer→400, fehlt→404; lösen setzt/öffnen löscht ResolvedAt),
DELETE /feedback/{id} (fehlt→404).

Tests: FeedbackEndpointTests (CRUD + Status-Lebenszyklus + übersteht Ingest),
e2e tickets.spec.ts; dotnet 215, vitest 129, playwright grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 17:52:50 +02:00
parent e1299a1a63
commit ac06cadb23
18 changed files with 2359 additions and 23 deletions

View File

@@ -25,17 +25,17 @@ test('Tierakte: "Fehler melden"-Button öffnet den Dialog, Senden zeigt Erfolg',
await expect(page.getByText(f.success)).toBeVisible()
await expect(dialog).not.toBeVisible()
// Der Bericht wurde mit Debug-Kontext gespeichert.
// Der Bericht wurde mit Debug-Kontext gespeichert (zuletzt eingereichter Eintrag;
// die Mock-DB ist mit Beispiel-Tickets vorbefüllt).
expect(mockDb).not.toBeNull()
const reports = mockDb!.feedback
expect(reports.length).toBe(1)
expect(reports[0]).toMatchObject({
const report = mockDb!.feedback.find((r) => r.message === 'Der Farbschlag stimmt nicht.')
expect(report).toMatchObject({
message: 'Der Farbschlag stimmt nicht.',
context: 'gerbil-detail',
gerbilId: 'kruemel',
})
expect(reports[0].url).toContain('/rennmaeuse/kruemel')
expect(typeof reports[0].clientTimestamp).toBe('string')
expect(report!.url).toContain('/rennmaeuse/kruemel')
expect(typeof report!.clientTimestamp).toBe('string')
})
test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wurf-Kontext', async ({ page, mockDb }) => {
@@ -51,9 +51,8 @@ test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wur
await dialog.getByRole('button', { name: f.submit }).click()
await expect(page.getByText(f.success)).toBeVisible()
const reports = mockDb!.feedback
expect(reports.length).toBe(1)
expect(reports[0]).toMatchObject({ context: 'litter-detail', litterId: 'w-kruemel' })
const report = mockDb!.feedback.find((r) => r.message === 'Die Wurfstärke ist falsch.')
expect(report).toMatchObject({ context: 'litter-detail', litterId: 'w-kruemel' })
})
test('Kontakt-Detail: "Fehler melden"-Button sendet mit Kontakt-Kontext', async ({ page, mockDb }) => {
@@ -69,9 +68,8 @@ test('Kontakt-Detail: "Fehler melden"-Button sendet mit Kontakt-Kontext', async
await dialog.getByRole('button', { name: f.submit }).click()
await expect(page.getByText(f.success)).toBeVisible()
const reports = mockDb!.feedback
expect(reports.length).toBe(1)
expect(reports[0]).toMatchObject({ context: 'contact-detail', contactId: 'con-meier' })
const report = mockDb!.feedback.find((r) => r.message === 'Die Adresse stimmt nicht.')
expect(report).toMatchObject({ context: 'contact-detail', contactId: 'con-meier' })
})
test('Stammbaum: Rechtsklick auf eine Karte zeigt "ID kopieren" + "Fehler melden"', async ({ page, mockDb }) => {
@@ -97,9 +95,8 @@ test('Stammbaum: Rechtsklick auf eine Karte zeigt "ID kopieren" + "Fehler melden
await dialog.getByRole('button', { name: f.submit }).click()
await expect(page.getByText(f.success)).toBeVisible()
const reports = mockDb!.feedback
expect(reports.length).toBe(1)
expect(reports[0]).toMatchObject({ context: 'stammbaum', gerbilId: 'kruemel' })
const report = mockDb!.feedback.find((r) => r.message === 'Stammbaum-Bug.')
expect(report).toMatchObject({ context: 'stammbaum', gerbilId: 'kruemel' })
})
test('Stammbaum: langer Druck (Long-Press) öffnet dasselbe Kontextmenü', async ({ page }, testInfo) => {

View File

@@ -407,6 +407,8 @@ export async function installMockApi(page: Page): Promise<MockDb> {
...body,
userAgent: request.headers()['user-agent'] ?? null,
createdAt: new Date().toISOString(),
status: 'Open',
resolvedAt: null,
}
db.feedback.push(created)
return json(route, 201, created)
@@ -416,6 +418,32 @@ export async function installMockApi(page: Page): Promise<MockDb> {
}
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 }
const row = db.feedback[idx]
if (typeof body.message === 'string') {
if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.')
row.message = body.message.trim()
}
if (typeof body.status === 'string') {
const resolved = body.status.toLowerCase() === 'resolved'
row.status = resolved ? 'Resolved' : 'Open'
row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null
}
return json(route, 200, row)
}
if (method === 'DELETE') {
db.feedback.splice(idx, 1)
return json(route, 204)
}
return json(route, 405)
}
// Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)

View File

@@ -391,6 +391,39 @@ export function seedDb(): MockDb {
],
saleAdConfigured: true,
namesConfigured: true,
feedback: [],
// FEEDBACK-TICKETS: ein offenes + ein gelöstes Ticket (GET liefert neueste zuerst →
// db.feedback wird im Mock umgekehrt; das offene steht hier zuletzt = erscheint oben).
feedback: [
{
id: 'feedback-resolved',
message: 'Der Stammbaum zeigt den falschen Vater bei Krümel.',
context: 'stammbaum',
gerbilId: 'kruemel',
litterId: null,
contactId: null,
entityName: 'Krümel',
url: 'http://localhost:5173/rennmaeuse/kruemel/stammbaum',
clientTimestamp: '2026-06-10T09:00:00Z',
userAgent: null,
createdAt: '2026-06-10T09:00:00Z',
status: 'Resolved',
resolvedAt: '2026-06-12T14:00:00Z',
},
{
id: 'feedback-open',
message: 'Das Gewicht wird auf der Verlaufskurve falsch gerundet.',
context: 'gerbil-detail',
gerbilId: 'fridolin',
litterId: null,
contactId: null,
entityName: 'Fridolin',
url: 'http://localhost:5173/rennmaeuse/fridolin',
clientTimestamp: '2026-06-15T11:30:00Z',
userAgent: null,
createdAt: '2026-06-15T11:30:00Z',
status: 'Open',
resolvedAt: null,
},
],
}
}

View File

@@ -0,0 +1,75 @@
/**
* FEEDBACK-TICKETS: "Meine Tickets" — Verwaltung der eingereichten Fehlerberichte.
* Erreichbar über Hilfe → „Meine Tickets". Liste mit Status-Badges (Offen/Gelöst),
* gelöst markieren / wieder öffnen, Nachricht bearbeiten, löschen.
*/
import { de, expect, gotoSection, skipUnlessMock, acceptNextDialog, test } from './fixtures'
const tt = de.feedback.tickets
test.describe('Meine Tickets', () => {
test.beforeEach(() => skipUnlessMock())
test('über Hilfe erreichbar, zeigt offenes und gelöstes Ticket mit Badge', async ({ page }) => {
await gotoSection(page, de.nav.help)
await page.getByRole('link', { name: tt.link }).click()
await expect(page.getByRole('heading', { name: tt.title })).toBeVisible()
// Offenes Ticket (Badge „Offen") + dessen Nachricht.
await expect(page.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible()
await expect(page.getByText(tt.statusOpen, { exact: true }).first()).toBeVisible()
// Gelöstes Ticket (Badge mit Häkchen + „Gelöst").
await expect(page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.')).toBeVisible()
await expect(page.getByText(tt.statusResolved, { exact: false }).first()).toBeVisible()
})
test('Ticket als gelöst markieren', async ({ page }) => {
await page.goto('/hilfe/tickets')
const openCard = page
.locator('.ticket-card')
.filter({ hasText: 'Das Gewicht wird auf der Verlaufskurve falsch gerundet.' })
await expect(openCard.getByText(tt.statusOpen, { exact: true })).toBeVisible()
await openCard.getByRole('button', { name: tt.markResolved }).click()
// Badge wechselt auf „Gelöst", Aktion bietet jetzt „Wieder öffnen".
// Auf das Badge (.ticket-badge) zielen — „Gelöst" steht sonst auch im Meta-Label „Gelöst am".
await expect(openCard.locator('.ticket-badge--resolved')).toBeVisible()
await expect(openCard.getByRole('button', { name: tt.reopen })).toBeVisible()
})
test('Nachricht eines Tickets bearbeiten', async ({ page }) => {
await page.goto('/hilfe/tickets')
// Karte über den Datensatz-Namen (bleibt auch im Bearbeiten-Modus sichtbar)
// adressieren — die Nachricht selbst wird beim Bearbeiten durch das Textfeld ersetzt.
const card = page.locator('.ticket-card').filter({ hasText: 'Fridolin' })
await expect(card.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible()
await card.getByRole('button', { name: tt.edit }).click()
const textarea = card.locator('textarea')
await textarea.fill('Korrigierte Beschreibung des Fehlers.')
await card.getByRole('button', { name: tt.save }).click()
await expect(page.getByText('Korrigierte Beschreibung des Fehlers.')).toBeVisible()
})
test('Ticket löschen', async ({ page }) => {
await page.goto('/hilfe/tickets')
const card = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(card).toBeVisible()
acceptNextDialog(page)
await card.getByRole('button', { name: tt.delete }).click()
await expect(
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
).toHaveCount(0)
})
})