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:
@@ -148,6 +148,64 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
||||
Assert.NotEqual(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Attachment_upload_list_serve_delete_lifecycle()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Foto vom Fellschlag.",
|
||||
context = "gerbil-detail",
|
||||
});
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// 1×1-PNG hochladen.
|
||||
const string pngB64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new
|
||||
{
|
||||
fileName = "maus.png",
|
||||
contentType = "image/png",
|
||||
dataBase64 = pngB64,
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, up.StatusCode);
|
||||
var att = JsonDocument.Parse(await up.Content.ReadAsStringAsync()).RootElement;
|
||||
var attId = att.GetProperty("id").GetString();
|
||||
Assert.Equal("maus.png", att.GetProperty("fileName").GetString());
|
||||
Assert.True(att.GetProperty("size").GetInt32() > 0);
|
||||
|
||||
// GET /feedback liefert die Metadaten (ohne Bytes).
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
var row = listed.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
|
||||
Assert.Equal(1, row.GetProperty("attachments").GetArrayLength());
|
||||
|
||||
// Bytes ausliefern.
|
||||
var bytes = await client.GetByteArrayAsync($"/feedback/attachments/{attId}");
|
||||
Assert.Equal(Convert.FromBase64String(pngB64).Length, bytes.Length);
|
||||
|
||||
// Löschen -> danach keine Anhänge mehr.
|
||||
var del = await client.DeleteAsync($"/feedback/attachments/{attId}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||
var after = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
var rowAfter = after.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
|
||||
Assert.Equal(0, rowAfter.GetProperty("attachments").GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Attachment_upload_rejects_invalid_base64()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new { message = "x", context = "gerbil-detail" });
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new
|
||||
{
|
||||
fileName = "x.png",
|
||||
contentType = "image/png",
|
||||
dataBase64 = "###nicht base64###",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, up.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reopen_resolved_ticket_that_had_a_Rueckfrage_keeps_question_and_no_ReopenedAt()
|
||||
{
|
||||
|
||||
@@ -25,6 +25,7 @@ public class ApplicationContext : DbContext
|
||||
public DbSet<Request> Requests => Set<Request>();
|
||||
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
|
||||
public DbSet<Feedback> Feedback => Set<Feedback>();
|
||||
public DbSet<FeedbackAttachment> FeedbackAttachments => Set<FeedbackAttachment>();
|
||||
public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
|
||||
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
|
||||
public DbSet<WaitingListEntry> WaitingListEntries => Set<WaitingListEntry>();
|
||||
|
||||
@@ -20,6 +20,19 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string Text,
|
||||
DateTimeOffset? At);
|
||||
|
||||
/// <summary>FEEDBACK: lightweight metadata for an attachment (no bytes).</summary>
|
||||
public record FeedbackAttachmentDto(
|
||||
Guid Id,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
int Size);
|
||||
|
||||
/// <summary>FEEDBACK: payload to upload an attachment (base64-encoded bytes).</summary>
|
||||
public record FeedbackAttachmentInput(
|
||||
string FileName,
|
||||
string ContentType,
|
||||
string DataBase64);
|
||||
|
||||
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
|
||||
public record FeedbackDto(
|
||||
Guid Id,
|
||||
@@ -51,7 +64,9 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
/// <summary>Optional AI-set category/topic for filtering (e.g. "Genetik", "Import"); null = none.</summary>
|
||||
string? Category = null,
|
||||
/// <summary>Was the resolution helpful? true=👍, false=👎, null=no feedback yet.</summary>
|
||||
bool? Helpful = null);
|
||||
bool? Helpful = null,
|
||||
/// <summary>Attachment metadata (no bytes); fetch bytes via /feedback/attachments/{id}.</summary>
|
||||
IReadOnlyList<FeedbackAttachmentDto>? Attachments = null);
|
||||
|
||||
/// <summary>
|
||||
/// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status,
|
||||
|
||||
@@ -21,6 +21,9 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
/// <summary>Aufbewahrungsfrist im Papierkorb: danach werden Tickets endgültig gelöscht.</summary>
|
||||
private const int TrashRetentionDays = 30;
|
||||
|
||||
/// <summary>Maximale Anhang-Größe (10 MB) — Fotos vom Handy passen locker, schützt aber die DB.</summary>
|
||||
private const int MaxAttachmentBytes = 10 * 1024 * 1024;
|
||||
|
||||
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/feedback").WithTags("Feedback");
|
||||
@@ -69,9 +72,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
rows = rows.Except(expired).ToList();
|
||||
}
|
||||
|
||||
// Anhang-Metadaten (OHNE Bytes) laden und je Ticket zuordnen.
|
||||
var attMeta = await db.FeedbackAttachments
|
||||
.Select(a => new { a.Id, a.FeedbackId, a.FileName, a.ContentType, a.Size })
|
||||
.ToListAsync();
|
||||
var byTicket = attMeta
|
||||
.GroupBy(a => a.FeedbackId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<FeedbackAttachmentDto>)g
|
||||
.Select(a => new FeedbackAttachmentDto(a.Id, a.FileName, a.ContentType, a.Size))
|
||||
.ToList());
|
||||
|
||||
return TypedResults.Ok(rows
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
.Select(ToDto)
|
||||
.Select(f => ToDto(f, byTicket.GetValueOrDefault(f.Id)))
|
||||
.ToList());
|
||||
});
|
||||
|
||||
@@ -222,6 +237,73 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.Ok(ToDto(entity));
|
||||
});
|
||||
|
||||
// ANHANG hochladen (base64). Bild/Datei zu einem Ticket. Größenlimit MaxAttachmentBytes.
|
||||
group.MapPost("/{id:guid}/attachments", async Task<Results<Created<FeedbackAttachmentDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, FeedbackAttachmentInput input, ApplicationContext db) =>
|
||||
{
|
||||
var ticket = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||
if (ticket is null)
|
||||
return TypedResults.NotFound();
|
||||
if (string.IsNullOrWhiteSpace(input.DataBase64) || string.IsNullOrWhiteSpace(input.FileName))
|
||||
return TypedResults.BadRequest("FileName und Daten sind erforderlich.");
|
||||
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
// erlaubt sowohl reines base64 als auch eine data:-URL
|
||||
var raw = input.DataBase64;
|
||||
var comma = raw.IndexOf(',');
|
||||
if (raw.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && comma >= 0)
|
||||
raw = raw[(comma + 1)..];
|
||||
bytes = Convert.FromBase64String(raw);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return TypedResults.BadRequest("Daten sind kein gültiges base64.");
|
||||
}
|
||||
if (bytes.Length == 0)
|
||||
return TypedResults.BadRequest("Datei ist leer.");
|
||||
if (bytes.Length > MaxAttachmentBytes)
|
||||
return TypedResults.BadRequest($"Datei zu groß (max. {MaxAttachmentBytes / (1024 * 1024)} MB).");
|
||||
|
||||
var att = new FeedbackAttachment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FeedbackId = id,
|
||||
FileName = input.FileName.Trim(),
|
||||
ContentType = string.IsNullOrWhiteSpace(input.ContentType) ? "application/octet-stream" : input.ContentType.Trim(),
|
||||
Size = bytes.Length,
|
||||
Data = bytes,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.FeedbackAttachments.Add(att);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/feedback/attachments/{att.Id}",
|
||||
new FeedbackAttachmentDto(att.Id, att.FileName, att.ContentType, att.Size));
|
||||
});
|
||||
|
||||
// ANHANG-Bytes ausliefern (für <img>/Download).
|
||||
group.MapGet("/attachments/{attId:guid}", async Task<Results<FileContentHttpResult, NotFound>> (
|
||||
Guid attId, ApplicationContext db) =>
|
||||
{
|
||||
var att = await db.FeedbackAttachments.AsNoTracking().FirstOrDefaultAsync(a => a.Id == attId);
|
||||
if (att is null)
|
||||
return TypedResults.NotFound();
|
||||
return TypedResults.File(att.Data, att.ContentType, att.FileName);
|
||||
});
|
||||
|
||||
// ANHANG löschen.
|
||||
group.MapDelete("/attachments/{attId:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
Guid attId, ApplicationContext db) =>
|
||||
{
|
||||
var att = await db.FeedbackAttachments.FirstOrDefaultAsync(a => a.Id == attId);
|
||||
if (att is null)
|
||||
return TypedResults.NotFound();
|
||||
db.FeedbackAttachments.Remove(att);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -271,10 +353,11 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
}
|
||||
}
|
||||
|
||||
private static FeedbackDto ToDto(Feedback f) =>
|
||||
private static FeedbackDto ToDto(Feedback f, IReadOnlyList<FeedbackAttachmentDto>? attachments = null) =>
|
||||
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url,
|
||||
f.ClientTimestamp, f.UserAgent, f.CreatedAt, f.Status, f.ResolvedAt,
|
||||
f.Question, f.Answer, f.AnsweredAt, f.FixNote, f.AgentContext,
|
||||
DeserializeThread(f.Thread), f.ReopenedAt, f.DeletedAt, f.Category, f.Helpful);
|
||||
DeserializeThread(f.Thread), f.ReopenedAt, f.DeletedAt, f.Category, f.Helpful,
|
||||
attachments ?? Array.Empty<FeedbackAttachmentDto>());
|
||||
}
|
||||
}
|
||||
|
||||
1832
GerbilManagerWebAPI/Migrations/20260623092227_FeedbackAttachments.Designer.cs
generated
Normal file
1832
GerbilManagerWebAPI/Migrations/20260623092227_FeedbackAttachments.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FeedbackAttachments : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FeedbackAttachments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FeedbackId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FileName = table.Column<string>(type: "text", nullable: false),
|
||||
ContentType = table.Column<string>(type: "text", nullable: false),
|
||||
Size = table.Column<int>(type: "integer", nullable: false),
|
||||
Data = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_FeedbackAttachments", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "FeedbackAttachments");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -960,6 +960,38 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.ToTable("Feedback");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.FeedbackAttachment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<byte[]>("Data")
|
||||
.IsRequired()
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.Property<Guid>("FeedbackId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Size")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("FeedbackAttachments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
31
GerbilManagerWebAPI/Models/FeedbackAttachment.cs
Normal file
31
GerbilManagerWebAPI/Models/FeedbackAttachment.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// An image/file attached to a feedback ticket (z. B. ein Foto vom Tier/Fellschlag/Stammbaum).
|
||||
/// Like <see cref="Feedback"/> it is decoupled (loose FeedbackId, no FK) so it survives the
|
||||
/// import re-ingest wipe. The bytes live in the DB (single-user app, gelegentliche Fotos) —
|
||||
/// die Liste GET /feedback liefert nur Metadaten, die Bytes kommen über einen eigenen Endpoint.
|
||||
/// </summary>
|
||||
public class FeedbackAttachment
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Loose reference (no FK) to the feedback ticket this belongs to.</summary>
|
||||
public Guid FeedbackId { get; set; }
|
||||
|
||||
public required string FileName { get; set; }
|
||||
|
||||
public required string ContentType { get; set; }
|
||||
|
||||
/// <summary>Größe in Bytes (separat gespeichert, damit Listen-Abfragen die Bytes nicht laden).</summary>
|
||||
public int Size { get; set; }
|
||||
|
||||
/// <summary>The raw file bytes.</summary>
|
||||
public required byte[] Data { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -435,15 +435,51 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
deletedAt: null,
|
||||
category: null,
|
||||
helpful: null,
|
||||
attachments: [],
|
||||
}
|
||||
db.feedback.push(created)
|
||||
return json(route, 201, created)
|
||||
}
|
||||
if (method === 'GET') {
|
||||
return json(route, 200, [...db.feedback].reverse())
|
||||
// attachments immer als Array liefern (Mock-Daten haben das Feld evtl. nicht).
|
||||
const rows = [...db.feedback].reverse().map((f) => ({ ...f, attachments: f.attachments ?? [] }))
|
||||
return json(route, 200, rows)
|
||||
}
|
||||
return json(route, 405)
|
||||
}
|
||||
// FEEDBACK-ANHÄNGE: hochladen (POST /feedback/{id}/attachments).
|
||||
const attUpload = path.match(/^\/feedback\/([^/]+)\/attachments$/)
|
||||
if (attUpload && method === 'POST') {
|
||||
const fid = decodeURIComponent(attUpload[1])
|
||||
const row = db.feedback.find((f) => f.id === fid)
|
||||
if (!row) return json(route, 404, { title: 'Not Found' })
|
||||
const body = request.postDataJSON() as { fileName?: string; contentType?: string; dataBase64?: string }
|
||||
const meta = {
|
||||
id: newId('att'),
|
||||
fileName: body.fileName ?? 'datei',
|
||||
contentType: body.contentType ?? 'application/octet-stream',
|
||||
size: (body.dataBase64 ?? '').length,
|
||||
}
|
||||
const list = (row.attachments as unknown[] | undefined) ?? []
|
||||
list.push(meta)
|
||||
row.attachments = list
|
||||
return json(route, 201, meta)
|
||||
}
|
||||
// FEEDBACK-ANHÄNGE: löschen (DELETE /feedback/attachments/{attId}).
|
||||
const attDelete = path.match(/^\/feedback\/attachments\/([^/]+)$/)
|
||||
if (attDelete && method === 'DELETE') {
|
||||
const attId = decodeURIComponent(attDelete[1])
|
||||
for (const f of db.feedback) {
|
||||
const list = (f.attachments as { id: string }[] | undefined) ?? []
|
||||
const i = list.findIndex((a) => a.id === attId)
|
||||
if (i >= 0) {
|
||||
list.splice(i, 1)
|
||||
f.attachments = list
|
||||
return json(route, 204)
|
||||
}
|
||||
}
|
||||
return json(route, 404, { title: 'Not Found' })
|
||||
}
|
||||
// FEEDBACK-TICKETS: Wiederherstellen aus dem Papierkorb (Soft-Delete aufheben).
|
||||
const restoreMatch = path.match(/^\/feedback\/([^/]+)\/restore$/)
|
||||
if (restoreMatch) {
|
||||
|
||||
@@ -311,4 +311,25 @@ test.describe('Meine Tickets', () => {
|
||||
await expect(reopened.locator('.ticket-badge--needsinfo')).toBeVisible()
|
||||
await expect(reopened.getByText(tt.helpfulReopenNote)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Foto an ein Ticket anhängen erscheint als Vorschaubild', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
// Offenes Ticket (Fridolin) — Anhang-Bereich mit „Foto anhängen".
|
||||
const card = page.locator('.ticket-card').filter({ hasText: 'Fridolin' })
|
||||
await expect(card.getByText(tt.attachmentsLabel, { exact: true })).toBeVisible()
|
||||
|
||||
// 1×1-PNG als Datei hochladen.
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
)
|
||||
await card.locator('input[type="file"]').setInputFiles({
|
||||
name: 'maus.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: png,
|
||||
})
|
||||
|
||||
// Nach dem Upload erscheint ein Vorschaubild im Anhang-Raster.
|
||||
await expect(card.locator('.ticket-card__attachment img')).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
/** FEEDBACK: API client for the "Fehler melden" report sink (POST /feedback). */
|
||||
import { api } from './client'
|
||||
import { api, API_BASE_URL } from './client'
|
||||
|
||||
const RESOURCE = '/feedback'
|
||||
|
||||
/** Metadaten eines Ticket-Anhangs (Bytes separat über attachmentUrl). */
|
||||
export interface FeedbackAttachment {
|
||||
id: string
|
||||
fileName: string
|
||||
contentType: string
|
||||
size: number
|
||||
}
|
||||
|
||||
/** Which view a report was filed from (matches the backend Context contract). */
|
||||
export type FeedbackContext = 'stammbaum' | 'gerbil-detail' | 'litter-detail' | 'contact-detail'
|
||||
|
||||
@@ -64,6 +72,8 @@ export interface Feedback {
|
||||
category: string | null
|
||||
/** War die Lösung hilfreich? true=👍, false=👎, null=keine Rückmeldung. */
|
||||
helpful: boolean | null
|
||||
/** Angehängte Dateien/Fotos (nur Metadaten; Bytes über attachmentUrl laden). */
|
||||
attachments: FeedbackAttachment[]
|
||||
/** Rückfrage einer/eines Betreuenden an die Züchterin (falls vorhanden). */
|
||||
question: string | null
|
||||
/** Antwort der Züchterin auf die Rückfrage (falls vorhanden). */
|
||||
@@ -133,3 +143,41 @@ export function deleteFeedback(id: string): Promise<void> {
|
||||
export function restoreFeedback(id: string): Promise<Feedback> {
|
||||
return api.post<Feedback>(`${RESOURCE}/${id}/restore`, {})
|
||||
}
|
||||
|
||||
/** Volle URL zu den Bytes eines Anhangs (für <img src> / Download). */
|
||||
export function attachmentUrl(attachmentId: string): string {
|
||||
return `${API_BASE_URL}${RESOURCE}/attachments/${attachmentId}`
|
||||
}
|
||||
|
||||
/** Einen Anhang (base64) an ein Ticket hochladen. */
|
||||
export function uploadAttachment(
|
||||
feedbackId: string,
|
||||
body: { fileName: string; contentType: string; dataBase64: string },
|
||||
): Promise<FeedbackAttachment> {
|
||||
return api.post<FeedbackAttachment>(`${RESOURCE}/${feedbackId}/attachments`, body)
|
||||
}
|
||||
|
||||
/** Einen Anhang löschen. */
|
||||
export function deleteAttachment(attachmentId: string): Promise<void> {
|
||||
return api.delete(`${RESOURCE}/attachments/${attachmentId}`)
|
||||
}
|
||||
|
||||
/** Eine Browser-Datei als Upload-Payload (base64) einlesen. */
|
||||
export function readFileAsUpload(
|
||||
file: File,
|
||||
): Promise<{ fileName: string; contentType: string; dataBase64: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result)
|
||||
const comma = result.indexOf(',')
|
||||
resolve({
|
||||
fileName: file.name,
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
dataBase64: comma >= 0 ? result.slice(comma + 1) : result,
|
||||
})
|
||||
}
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsDataURL(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">
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
updateFeedback,
|
||||
deleteFeedback,
|
||||
restoreFeedback,
|
||||
uploadAttachment,
|
||||
deleteAttachment,
|
||||
attachmentUrl,
|
||||
readFileAsUpload,
|
||||
type FeedbackTicket,
|
||||
} from '../api/feedback'
|
||||
import { getGerbil } from '../api/gerbils'
|
||||
@@ -342,6 +346,26 @@ export default function TicketsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddAttachment(ticket: FeedbackTicket, file: File) {
|
||||
try {
|
||||
await uploadAttachment(ticket.id, await readFileAsUpload(file))
|
||||
tickets.reload()
|
||||
toast.success(t.attachmentAdded)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t.attachmentError)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAttachment(attachmentId: string) {
|
||||
try {
|
||||
await deleteAttachment(attachmentId)
|
||||
tickets.reload()
|
||||
toast.success(t.attachmentRemoved)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t.attachmentError)
|
||||
}
|
||||
}
|
||||
|
||||
const rows = tickets.data
|
||||
|
||||
// Verlinkung von Ticket zu Ticket: /hilfe/tickets?focus=<id> wechselt in die passende
|
||||
@@ -610,6 +634,8 @@ export default function TicketsPage() {
|
||||
onDelete={() => handleDelete(ticket)}
|
||||
onRestore={() => handleRestore(ticket)}
|
||||
onHelpful={(helpful) => handleHelpful(ticket, helpful)}
|
||||
onAddAttachment={(file) => handleAddAttachment(ticket, file)}
|
||||
onDeleteAttachment={handleDeleteAttachment}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -629,6 +655,8 @@ interface TicketCardProps {
|
||||
onDelete: () => void
|
||||
onRestore: () => void
|
||||
onHelpful: (helpful: boolean) => void
|
||||
onAddAttachment: (file: File) => void
|
||||
onDeleteAttachment: (attachmentId: string) => void
|
||||
}
|
||||
|
||||
/** Status-Badge: Beschriftung + Modifier-Klasse je Lebenszyklus-Zustand. */
|
||||
@@ -656,6 +684,8 @@ function TicketCard({
|
||||
onDelete,
|
||||
onRestore,
|
||||
onHelpful,
|
||||
onAddAttachment,
|
||||
onDeleteAttachment,
|
||||
}: TicketCardProps) {
|
||||
const toast = useToast()
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -915,6 +945,73 @@ function TicketCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(ticket.attachments.length > 0 || !deleted) && (
|
||||
<div className="ticket-card__attachments">
|
||||
<span className="ticket-card__attachments-label">{t.attachmentsLabel}</span>
|
||||
<div className="ticket-card__attachments-grid">
|
||||
{ticket.attachments.map((att) =>
|
||||
att.contentType.startsWith('image/') ? (
|
||||
<a
|
||||
key={att.id}
|
||||
href={attachmentUrl(att.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ticket-card__attachment"
|
||||
>
|
||||
<img src={attachmentUrl(att.id)} alt={att.fileName} loading="lazy" />
|
||||
{!deleted && (
|
||||
<button
|
||||
type="button"
|
||||
className="ticket-card__attachment-remove"
|
||||
title={t.removeAttachment}
|
||||
aria-label={t.removeAttachment}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
onDeleteAttachment(att.id)
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</a>
|
||||
) : (
|
||||
<span key={att.id} className="ticket-card__attachment ticket-card__attachment--file">
|
||||
<a href={attachmentUrl(att.id)} target="_blank" rel="noopener noreferrer">
|
||||
{att.fileName}
|
||||
</a>
|
||||
{!deleted && (
|
||||
<button
|
||||
type="button"
|
||||
className="ticket-card__attachment-remove"
|
||||
title={t.removeAttachment}
|
||||
aria-label={t.removeAttachment}
|
||||
onClick={() => onDeleteAttachment(att.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
{!deleted && (
|
||||
<label className="ticket-card__attachment-add">
|
||||
+ {t.addAttachment}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) onAddAttachment(f)
|
||||
e.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleted && trashDaysLeft !== null && (
|
||||
<p className="ticket-card__trash-notice">
|
||||
{trashDaysLeft === 0
|
||||
|
||||
@@ -365,6 +365,82 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Anhänge (Fotos/Dateien) auf einem Ticket. */
|
||||
.ticket-card__attachments {
|
||||
margin: 0 0 0.85rem;
|
||||
}
|
||||
.ticket-card__attachments-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-muted, #6b7280);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.ticket-card__attachments-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.ticket-card__attachment {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ticket-card__attachment img {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.ticket-card__attachment--file {
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
.ticket-card__attachment-remove {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 1;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.ticket-card__attachment--file .ticket-card__attachment-remove {
|
||||
position: static;
|
||||
background: transparent;
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
.ticket-card__attachment-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border: 1px dashed var(--color-border, #9ca3af);
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
color: var(--color-muted, #6b7280);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
/* Papierkorb-Countdown: Hinweis, wann das Ticket endgültig gelöscht wird. */
|
||||
.ticket-card__trash-notice {
|
||||
border-left: 3px solid var(--color-danger, #ef4444);
|
||||
|
||||
@@ -1204,6 +1204,9 @@ export const de = {
|
||||
/** „Ähnliche bereits gelöste Tickets" im Melde-Fenster. */
|
||||
similarTitle: 'Schon mal gelöst? Vielleicht hilft eines davon:',
|
||||
similarOpen: 'ansehen',
|
||||
/** Datei-Anhänge. */
|
||||
attachLabel: 'Fotos anhängen (optional)',
|
||||
attachmentError: 'Ein Anhang konnte nicht hochgeladen werden.',
|
||||
/** Toast nach erfolgreichem "ID kopieren". */
|
||||
idCopied: 'ID kopiert',
|
||||
idCopyFailed: 'ID konnte nicht kopiert werden.',
|
||||
@@ -1290,6 +1293,13 @@ export const de = {
|
||||
helpfulNo: '👎 Nein',
|
||||
helpfulThanks: 'Danke für die Rückmeldung!',
|
||||
helpfulReopenNote: 'Schade — ich öffne das Ticket wieder. Bitte schreib kurz, was noch fehlt.',
|
||||
/** Anhänge auf einem Ticket. */
|
||||
attachmentsLabel: 'Anhänge',
|
||||
addAttachment: 'Foto anhängen',
|
||||
removeAttachment: 'Anhang entfernen',
|
||||
attachmentAdded: 'Anhang hinzugefügt.',
|
||||
attachmentRemoved: 'Anhang entfernt.',
|
||||
attachmentError: 'Anhang konnte nicht verarbeitet werden.',
|
||||
/** Changelog (fixNote) auf geschlossenen Tickets. */
|
||||
changelogLabel: 'Was wurde geändert',
|
||||
/** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */
|
||||
|
||||
Reference in New Issue
Block a user