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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user