feat(tickets): Rückfragen-Dialog, Verlinkungen, Entwurfssicherung + PWA-Vorbereitung

- Feedback: fixNote (Changelog), agentContext (intern), thread (Q&A-Verlauf);
  3 gefilterte Ansichten (Rückfragen/Offen/Geschlossen)
- Tickets-Text: klickbare Tier-Links (ID→Name), Ticket→Ticket-Links (?focus=),
  Markdown-Links; Antwort-Entwurf pro Ticket in localStorage
- "Fehler melden": Entwurf übersteht App-Wechsel/Schließen (ein gemeinsamer Entwurf)
- entityName als Hyperlink zum referenzierten Datensatz

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 22:38:23 +02:00
parent b9021fe577
commit dcf0e40f9b
18 changed files with 2592 additions and 154 deletions

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
@@ -74,15 +75,25 @@ namespace GerbilManagerWebAPI.Endpoints
// 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.
// 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();
entity.Question = q.Length == 0 ? null : q;
if (entity.Question is not null && !entity.Status.Equals("Resolved", StringComparison.OrdinalIgnoreCase))
if (q.Length > 0)
{
entity.Status = "NeedsInfo";
entity.ResolvedAt = null;
ArchiveCurrentExchange(entity);
entity.Question = q;
if (!entity.Status.Equals("Resolved", StringComparison.OrdinalIgnoreCase))
{
entity.Status = "NeedsInfo";
entity.ResolvedAt = null;
}
}
else
{
entity.Question = null;
}
}
@@ -124,6 +135,22 @@ namespace GerbilManagerWebAPI.Endpoints
: null;
}
// 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;
}
await db.SaveChangesAsync();
return TypedResults.Ok(ToDto(entity));
});
@@ -143,9 +170,56 @@ namespace GerbilManagerWebAPI.Endpoints
return app;
}
private static readonly JsonSerializerOptions ThreadJson = new(JsonSerializerDefaults.Web);
/// <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) =>
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.Question, f.Answer, f.AnsweredAt, f.FixNote, f.AgentContext,
DeserializeThread(f.Thread));
}
}