feat(tickets): Foto-/Datei-Anhänge an Tickets

- Anhänge im Melde-Fenster (beim Erstellen) und direkt an bestehenden Tickets:
  Vorschaubilder, Öffnen im neuen Tab, Entfernen.
- Bytes liegen in eigener Tabelle (FeedbackAttachment, lose FeedbackId ohne FK →
  übersteht den Ingest-Wipe); GET /feedback liefert nur Metadaten (id/Name/Typ/Größe),
  die Bytes über /feedback/attachments/{id}. Größenlimit 10 MB.
- Endpoints: POST /feedback/{id}/attachments (base64), GET /feedback/attachments/{id}
  (Bytes), DELETE /feedback/attachments/{id}.

Migration FeedbackAttachments. Tests: 262 Backend grün (+Upload/Serve/Delete +Validierung),
e2e Tickets Desktop+Phone grün (+Foto-Upload), vitest 149.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 11:24:02 +02:00
parent 48b138bbfd
commit 750619d3d7
15 changed files with 2423 additions and 8 deletions

View File

@@ -9,7 +9,14 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { ApiError } from '../api/client'
import { listFeedback, submitFeedback, type FeedbackContext, type FeedbackTicket } from '../api/feedback'
import {
listFeedback,
readFileAsUpload,
submitFeedback,
uploadAttachment,
type FeedbackContext,
type FeedbackTicket,
} from '../api/feedback'
import { useToast } from './toast'
import './reportErrorDialog.css'
@@ -64,6 +71,7 @@ function ReportErrorDialogBody({
}
})
const [submitting, setSubmitting] = useState(false)
const [files, setFiles] = useState<File[]>([])
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
// Bereits gelöste Tickets einmalig laden, um beim Tippen ähnliche vorzuschlagen.
@@ -142,7 +150,7 @@ function ReportErrorDialogBody({
}
setSubmitting(true)
try {
await submitFeedback({
const created = await submitFeedback({
message: trimmed,
context: context.context,
gerbilId: context.gerbilId ?? null,
@@ -152,6 +160,14 @@ function ReportErrorDialogBody({
url: window.location.href,
clientTimestamp: new Date().toISOString(),
})
// Ausgewählte Anhänge nach dem Anlegen hochladen (Ticket-ID liegt erst jetzt vor).
for (const file of files) {
try {
await uploadAttachment(created.id, await readFileAsUpload(file))
} catch {
toast.error(t.attachmentError)
}
}
try {
localStorage.removeItem(draftKey)
} catch {
@@ -222,6 +238,26 @@ function ReportErrorDialogBody({
</div>
)}
<div className="report-error__attach">
<label className="report-error__label" htmlFor="report-error-files">
{t.attachLabel}
</label>
<input
id="report-error-files"
type="file"
accept="image/*"
multiple
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
/>
{files.length > 0 && (
<ul className="report-error__attach-list">
{files.map((f, i) => (
<li key={i}>{f.name}</li>
))}
</ul>
)}
</div>
<div className="report-error__debug">
<div className="report-error__debug-title">{t.debugTitle}</div>
<dl className="report-error__debug-list">