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

@@ -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()
})
})