Daten-Fixes (conflict-decisions.json, re-ingest-stabil) für ~30 Tickets:
Merges (Jamie/Hiro/Mino/Jana/Blacky/Sakura/Malou/Socke→Marty), Eltern-Korrekturen
(Jacky/Idefix/Ichika/Roni/Ethan), Kruke→Kuke (+ Todesdatum), Targa-Wurf R14 + Druna,
Stacy/Merle/Domi/Eliza; Joghurt-Phantomwurf entfernt.
Code-Fixes:
- Gaida & alle Verstorbenen: Status wird aus Todesdatum/Abgabe abgeleitet
(Program.cs Startup-Sweep heilt Altfälle; IngestResolved re-derived nach Freeze).
- CoCo: Scheckungsart wird bei jeder Schecke angezeigt (Platzhalter wenn leer).
- M-Wurf/Gale: über-gemergte Fremdtiere via neuem litterChildren-Override entfernt.
- renameTo eltern-verknüpfungssicher (Quell-Name im Index); dateOfDeath als Override.
Prod-fähige Triage (API):
- GET /feedback/{id} + GET /feedback?status= (kein 2-MB-Dump).
- POST /import/ingest-resolved/upload (multipart) → Ingest gegen Prod ohne SSH.
Tests: 280 Backend, 149 Frontend, alle Python, betroffene Playwright grün.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
419 lines
20 KiB
C#
419 lines
20 KiB
C#
using System.Text.Json;
|
|
using GerbilManagerWebAPI.Dtos;
|
|
using GerbilManagerWebAPI.Models;
|
|
using Microsoft.AspNetCore.Http.HttpResults;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace GerbilManagerWebAPI.Endpoints
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static class FeedbackEndpoints
|
|
{
|
|
/// <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");
|
|
|
|
group.MapPost("/", async Task<Results<Created<FeedbackDto>, BadRequest<string>>> (
|
|
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<FeedbackAttachmentDto>)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<Results<Ok<FeedbackDto>, 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<Results<Ok<FeedbackDto>, NotFound, BadRequest<string>>> (
|
|
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<Results<NoContent, NotFound>> (
|
|
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<Results<Ok<FeedbackDto>, 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<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;
|
|
}
|
|
|
|
private static readonly JsonSerializerOptions ThreadJson = new(JsonSerializerDefaults.Web);
|
|
|
|
/// <summary>Für Push-Texte: leeren Wert zu null, sonst auf ~140 Zeichen kürzen.</summary>
|
|
private static string? Trim(string? s)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(s)) return null;
|
|
var t = s.Trim();
|
|
return t.Length > 140 ? t[..139] + "…" : t;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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<FeedbackThreadEntry> DeserializeThread(string? json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json))
|
|
return Array.Empty<FeedbackThreadEntry>();
|
|
try
|
|
{
|
|
return JsonSerializer.Deserialize<List<FeedbackThreadEntry>>(json, ThreadJson)
|
|
?? new List<FeedbackThreadEntry>();
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return Array.Empty<FeedbackThreadEntry>();
|
|
}
|
|
}
|
|
|
|
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,
|
|
attachments ?? Array.Empty<FeedbackAttachmentDto>());
|
|
}
|
|
}
|