feat(tickets): Wiederöffnen→Rückfrage mit Zeitstempel + Soft-Delete-Papierkorb + Scroll-Erhalt

Wiederöffnen eines gelösten Tickets:
- landet jetzt in „Rückfragen" (NeedsInfo) statt „Offen".
- War es ein echt gelöstes Ticket OHNE Rückfrage, wird um mehr Infos gebeten und
  „Wieder geöffnet am" (ReopenedAt) gestempelt.
- Hatte es bereits eine offene Rückfrage (z. B. manuell gelöste Rückfrage), kehrt es
  ohne neuen Hinweis und ohne Zeitstempel in genau diese Frage zurück.

Löschen über die UI = Soft-Delete:
- Tickets wandern in die neue Kategorie „Gelöscht" (Papierkorb) statt entfernt zu werden.
- Von dort wiederherstellbar (POST /feedback/{id}/restore → DeletedAt = null).
- Countdown „Wird in N Tagen endgültig gelöscht"; nach 30 Tagen endgültige Löschung
  (lazy beim Abruf in GET /feedback).

Außerdem: Scrollposition bleibt beim Zurück-Navigieren (z. B. nach Klick auf einen
Tier-Link) erhalten, statt an den Seitenanfang zu springen.

EF-Migration FeedbackReopenedAndSoftDelete (ReopenedAt, DeletedAt — beide nullable).
Tests: 260 Backend grün (+2 Reopen-Fälle, CRUD auf Soft-Delete umgestellt), e2e Tickets
Desktop+Phone grün (+Soft-Delete/Restore/Reopen), vitest 149.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 11:02:04 +02:00
parent a0fa33da42
commit c0a7b1a5ee
13 changed files with 2237 additions and 43 deletions

View File

@@ -431,6 +431,8 @@ export async function installMockApi(page: Page): Promise<MockDb> {
fixNote: null,
agentContext: null,
thread: [],
reopenedAt: null,
deletedAt: null,
}
db.feedback.push(created)
return json(route, 201, created)
@@ -440,6 +442,18 @@ export async function installMockApi(page: Page): Promise<MockDb> {
}
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) {
@@ -456,6 +470,9 @@ export async function installMockApi(page: Page): Promise<MockDb> {
agentContext?: string
}
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()
@@ -504,6 +521,10 @@ export async function installMockApi(page: Page): Promise<MockDb> {
? 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') {
@@ -518,7 +539,9 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 200, row)
}
if (method === 'DELETE') {
db.feedback.splice(idx, 1)
// 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)

View File

@@ -188,7 +188,9 @@ test.describe('Meine Tickets', () => {
await expect(card.locator('.ticket-card__answer-form textarea')).toBeVisible()
})
test('Ticket löschen', async ({ page }) => {
test('Ticket löschen = Soft-Delete: wandert in den Papierkorb, mit Countdown + Wiederherstellen', async ({
page,
}) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
@@ -200,8 +202,50 @@ test.describe('Meine Tickets', () => {
acceptNextDialog(page)
await card.getByRole('button', { name: tt.delete }).click()
// Aus „Geschlossen" verschwunden …
await expect(
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
).toHaveCount(0)
// … aber im Papierkorb („Gelöscht") vorhanden, mit Countdown + Wiederherstellen-Button.
await selectView(page, tt.filters.deleted)
const trashed = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(trashed).toBeVisible()
await expect(trashed.locator('.ticket-card__trash-notice')).toBeVisible()
await expect(trashed.getByText('endgültig gelöscht')).toBeVisible()
// Wiederherstellen → zurück aus dem Papierkorb (kehrt in „Geschlossen" zurück).
await trashed.getByRole('button', { name: tt.restore }).click()
await expect(
page.locator('.ticket-card').filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' }),
).toHaveCount(0)
await selectView(page, tt.filters.closed)
await expect(
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
).toBeVisible()
})
test('Wiederöffnen eines gelösten Tickets ohne Rückfrage → Rückfragen + Bitte um Infos + Zeitstempel', async ({
page,
}) => {
await page.goto('/hilfe/tickets')
await selectView(page, tt.filters.closed)
// „Krümel"-Ticket ist Resolved und hatte KEINE Rückfrage (question === null).
const card = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await card.getByRole('button', { name: tt.reopen }).click()
// Landet in den „Rückfragen" mit der Bitte um mehr Infos + „Wieder geöffnet am".
await selectView(page, tt.filters.dialog)
const reopened = page
.locator('.ticket-card')
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
await expect(reopened.locator('.ticket-badge--needsinfo')).toBeVisible()
await expect(reopened.getByText(tt.reopenRequest)).toBeVisible()
await expect(reopened.getByText(tt.reopenedLabel)).toBeVisible()
})
})