using System.Text.Json; using GerbilManagerWebAPI.Dtos; using GerbilManagerWebAPI.Models; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.EntityFrameworkCore; namespace GerbilManagerWebAPI.Endpoints { /// /// FEEDBACK: the "Fehler melden" report sink + ticket management ("Meine Tickets"). /// POST /feedback -> persist a user bug report (with captured debug context), returns 201. /// GET /feedback -> list reports, newest first (for the ticket list). /// PUT /feedback/{id} -> edit message, toggle status, attach a clarifying question (Rückfrage), /// or submit the breeder's answer. Question -> NeedsInfo; Answer -> Answered + AnsweredAt. /// DELETE /feedback/{id} -> remove a report. 404 on missing id. /// Feedback is decoupled from gerbils/litters (loose nullable Guid columns, no FK), so /// rows survive the import re-ingest wipe. /// public static class FeedbackEndpoints { /// Aufbewahrungsfrist im Papierkorb: danach werden Tickets endgültig gelöscht. private const int TrashRetentionDays = 30; /// Maximale Anhang-Größe (10 MB) — Fotos vom Handy passen locker, schützt aber die DB. private const int MaxAttachmentBytes = 10 * 1024 * 1024; public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/feedback").WithTags("Feedback"); group.MapPost("/", async Task, BadRequest>> ( FeedbackInput input, ApplicationContext db, HttpContext http) => { if (string.IsNullOrWhiteSpace(input.Message)) return TypedResults.BadRequest("Message darf nicht leer sein."); var entity = new Feedback { Id = Guid.NewGuid(), Message = input.Message.Trim(), Context = string.IsNullOrWhiteSpace(input.Context) ? "unknown" : input.Context.Trim(), GerbilId = input.GerbilId, LitterId = input.LitterId, ContactId = input.ContactId, EntityName = input.EntityName, Url = input.Url, ClientTimestamp = input.ClientTimestamp, UserAgent = http.Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null, CreatedAt = DateTimeOffset.UtcNow, Status = "Open", ResolvedAt = null, }; db.Feedback.Add(entity); await db.SaveChangesAsync(); return TypedResults.Created($"/feedback/{entity.Id}", ToDto(entity)); }); group.MapGet("/", async (ApplicationContext db, string? status) => { // In memory verarbeiten: SQLite (Test-Host) kann weder ORDER BY noch WHERE-Vergleiche // auf DateTimeOffset-Spalten übersetzen. var rows = await db.Feedback.ToListAsync(); // Optionaler Status-Filter (?status=Open,Answered) — kommagetrennt, case-insensitiv. // Spart der Triage den 2-MB-Volldump; soft-gelöschte Tickets werden dann ausgeblendet. if (!string.IsNullOrWhiteSpace(status)) { var wanted = status.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToHashSet(StringComparer.OrdinalIgnoreCase); rows = rows.Where(f => f.DeletedAt is null && wanted.Contains(f.Status)).ToList(); } // Aufräumen: soft-gelöschte Tickets werden nach TrashRetentionDays endgültig entfernt // (lazy beim Abruf — genügt für Single-User, kein Hintergrunddienst nötig). var cutoff = DateTimeOffset.UtcNow.AddDays(-TrashRetentionDays); var expired = rows.Where(f => f.DeletedAt is { } d && d < cutoff).ToList(); if (expired.Count > 0) { db.Feedback.RemoveRange(expired); await db.SaveChangesAsync(); 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)g .Select(a => new FeedbackAttachmentDto(a.Id, a.FileName, a.ContentType, a.Size)) .ToList()); return TypedResults.Ok(rows .OrderByDescending(f => f.CreatedAt) .Select(f => ToDto(f, byTicket.GetValueOrDefault(f.Id))) .ToList()); }); // Einzelnes Ticket (inkl. Anhang-Metadaten). Praktisch für die Triage, um ein Ticket // gezielt zu laden, statt die ganze Liste zu ziehen. 404 bei unbekannter Id. group.MapGet("/{id:guid}", async Task, NotFound>> ( Guid id, ApplicationContext db) => { var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id); if (entity is null) return TypedResults.NotFound(); var atts = await db.FeedbackAttachments .Where(a => a.FeedbackId == id) .Select(a => new FeedbackAttachmentDto(a.Id, a.FileName, a.ContentType, a.Size)) .ToListAsync(); return TypedResults.Ok(ToDto(entity, atts)); }); group.MapPut("/{id:guid}", async Task, NotFound, BadRequest>> ( Guid id, FeedbackUpdate input, ApplicationContext db, Push.PushNotifier push) => { var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id); if (entity is null) return TypedResults.NotFound(); // Zustand VOR den Mutationen merken — für die Wiederöffnen-Erkennung weiter unten. var wasResolved = entity.Status.Equals("Resolved", StringComparison.OrdinalIgnoreCase); var hadOpenRueckfrage = !string.IsNullOrWhiteSpace(entity.Question); if (input.Message is not null) { if (string.IsNullOrWhiteSpace(input.Message)) return TypedResults.BadRequest("Message darf nicht leer sein."); entity.Message = input.Message.Trim(); } // Attach a clarifying question (Rückfrage). A non-empty question moves the ticket to // NeedsInfo (waiting on the breeder) unless it is already Resolved. A blank/whitespace // question clears it. If a PREVIOUS Q&A exchange already exists, archive it into the // thread (oldest first) before overwriting — so earlier rounds aren't lost — and clear // the current Answer so the new question starts a fresh open exchange. if (input.Question is not null) { var q = input.Question.Trim(); if (q.Length > 0) { ArchiveCurrentExchange(entity); entity.Question = q; if (!entity.Status.Equals("Resolved", StringComparison.OrdinalIgnoreCase)) { entity.Status = "NeedsInfo"; entity.ResolvedAt = null; } } else { entity.Question = null; } } // The breeder's answer. A non-empty answer stamps AnsweredAt and moves to Answered. if (input.Answer is not null) { var a = input.Answer.Trim(); if (a.Length == 0) { entity.Answer = null; entity.AnsweredAt = null; } else { entity.Answer = a; entity.AnsweredAt = DateTimeOffset.UtcNow; entity.Status = "Answered"; entity.ResolvedAt = null; } } if (input.Status is not null) { // Normalize to a known lifecycle state. Resolving stamps ResolvedAt; any other // state clears it. Open/NeedsInfo/Answered/Resolved are accepted (case-insensitive), // anything else falls back to Open. var status = input.Status.Trim(); var resolved = status.Equals("Resolved", StringComparison.OrdinalIgnoreCase); var normalized = status switch { _ when status.Equals("Resolved", StringComparison.OrdinalIgnoreCase) => "Resolved", _ when status.Equals("NeedsInfo", StringComparison.OrdinalIgnoreCase) => "NeedsInfo", _ when status.Equals("Answered", StringComparison.OrdinalIgnoreCase) => "Answered", _ => "Open", }; entity.Status = normalized; entity.ResolvedAt = resolved ? (entity.ResolvedAt ?? DateTimeOffset.UtcNow) : null; // Wiederöffnen-Zeitstempel: nur, wenn ein ECHT gelöstes Ticket (ohne offene // Rückfrage) wieder geöffnet wird. Tickets, die nur eine offene Rückfrage hatten // und manuell auf gelöst gesetzt wurden, kehren beim Wiederöffnen einfach in // ihre Rückfrage zurück und bekommen KEINEN Zeitstempel. if (!resolved && wasResolved && !hadOpenRueckfrage) entity.ReopenedAt = DateTimeOffset.UtcNow; } // FIX-NOTE: breeder-friendly changelog (typically set together with status=Resolved). // A blank/whitespace value clears it. if (input.FixNote is not null) { var note = input.FixNote.Trim(); entity.FixNote = note.Length == 0 ? null : note; } // AGENT-CONTEXT (internal working memory): updated without changing the status. // A blank/whitespace value clears it. if (input.AgentContext is not null) { var c = input.AgentContext.Trim(); entity.AgentContext = c.Length == 0 ? null : c; } // KATEGORIE: frei wählbares Thema für Filter/Übersicht; leer = löschen. if (input.Category is not null) { var cat = input.Category.Trim(); entity.Category = cat.Length == 0 ? null : cat; } // HILFREICH (👍/👎): nur setzen, wenn übermittelt (null = unverändert). if (input.Helpful is not null) entity.Helpful = input.Helpful; await db.SaveChangesAsync(); // Push an die Züchterin, wenn die KI das anfordert (notify=true). Nachricht aus dem // resultierenden Status ableiten. Fehler dürfen die Antwort nicht stören. if (input.Notify == true && push.Enabled) { var name = string.IsNullOrWhiteSpace(entity.EntityName) ? "" : $" ({entity.EntityName})"; var (title, body) = entity.Status switch { "NeedsInfo" => ("Neue Rückfrage" + name, Trim(entity.Question) ?? "Bitte schau in deine Tickets."), "Resolved" => ("Ticket gelöst" + name, Trim(entity.FixNote) ?? Trim(entity.Message) ?? "Erledigt."), _ => ("Neues zu deinem Ticket" + name, Trim(entity.Message) ?? ""), }; try { await push.NotifyAllAsync(title, body, $"/hilfe/tickets?focus={entity.Id}"); } catch (Exception) { // Push ist best-effort — niemals die API-Antwort daran scheitern lassen. } } return TypedResults.Ok(ToDto(entity)); }); // SOFT-DELETE: das Ticket wird NICHT entfernt, sondern als gelöscht markiert // (DeletedAt = jetzt) und wandert in die „Gelöscht"-Kategorie. Wiederherstellbar // über POST /feedback/{id}/restore. group.MapDelete("/{id:guid}", async Task> ( Guid id, ApplicationContext db) => { var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id); if (entity is null) return TypedResults.NotFound(); entity.DeletedAt ??= DateTimeOffset.UtcNow; await db.SaveChangesAsync(); return TypedResults.NoContent(); }); // WIEDERHERSTELLEN: hebt das Soft-Delete auf (DeletedAt → null); das Ticket kehrt in // seinen vorherigen Status (Offen/Rückfrage/…) zurück. group.MapPost("/{id:guid}/restore", async Task, NotFound>> ( Guid id, ApplicationContext db) => { var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id); if (entity is null) return TypedResults.NotFound(); entity.DeletedAt = null; await db.SaveChangesAsync(); return TypedResults.Ok(ToDto(entity)); }); // ANHANG hochladen (base64). Bild/Datei zu einem Ticket. Größenlimit MaxAttachmentBytes. group.MapPost("/{id:guid}/attachments", async Task, NotFound, BadRequest>> ( 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 /Download). group.MapGet("/attachments/{attId:guid}", async Task> ( 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> ( 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; } private static readonly JsonSerializerOptions ThreadJson = new(JsonSerializerDefaults.Web); /// Für Push-Texte: leeren Wert zu null, sonst auf ~140 Zeichen kürzen. private static string? Trim(string? s) { if (string.IsNullOrWhiteSpace(s)) return null; var t = s.Trim(); return t.Length > 140 ? t[..139] + "…" : t; } /// /// Append the ticket's current (Question, Answer) exchange to the thread/history JSON /// before it gets overwritten by a new round, then clear the current Answer. Only the /// non-empty parts are archived (a question with no answer yet is still preserved). /// private static void ArchiveCurrentExchange(Feedback entity) { var history = DeserializeThread(entity.Thread).ToList(); var archivedAny = false; if (!string.IsNullOrWhiteSpace(entity.Question)) { history.Add(new FeedbackThreadEntry("maintainer", entity.Question!, entity.CreatedAt)); archivedAny = true; } if (!string.IsNullOrWhiteSpace(entity.Answer)) { history.Add(new FeedbackThreadEntry("breeder", entity.Answer!, entity.AnsweredAt)); archivedAny = true; } if (archivedAny) entity.Thread = JsonSerializer.Serialize(history, ThreadJson); // The new question starts a fresh open exchange — clear the prior answer. entity.Answer = null; entity.AnsweredAt = null; } private static IReadOnlyList DeserializeThread(string? json) { if (string.IsNullOrWhiteSpace(json)) return Array.Empty(); try { return JsonSerializer.Deserialize>(json, ThreadJson) ?? new List(); } catch (JsonException) { return Array.Empty(); } } private static FeedbackDto ToDto(Feedback f, IReadOnlyList? 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, attachments ?? Array.Empty()); } }