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:
@@ -11,6 +11,15 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? Url,
|
||||
DateTimeOffset? ClientTimestamp);
|
||||
|
||||
/// <summary>
|
||||
/// FEEDBACK: one past Q&A turn in the ticket's thread/history.
|
||||
/// Role is "maintainer" (Rückfrage) or "breeder" (Antwort).
|
||||
/// </summary>
|
||||
public record FeedbackThreadEntry(
|
||||
string Role,
|
||||
string Text,
|
||||
DateTimeOffset? At);
|
||||
|
||||
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
|
||||
public record FeedbackDto(
|
||||
Guid Id,
|
||||
@@ -28,15 +37,24 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
DateTimeOffset? ResolvedAt,
|
||||
string? Question,
|
||||
string? Answer,
|
||||
DateTimeOffset? AnsweredAt);
|
||||
DateTimeOffset? AnsweredAt,
|
||||
/// <summary>Breeder-friendly changelog written on resolve (shown in the UI).</summary>
|
||||
string? FixNote,
|
||||
/// <summary>INTERNAL agent working memory — exposed for tooling, NEVER shown to the breeder.</summary>
|
||||
string? AgentContext,
|
||||
/// <summary>Earlier Q&A rounds, oldest first; the current open exchange stays in Question/Answer.</summary>
|
||||
IReadOnlyList<FeedbackThreadEntry> Thread);
|
||||
|
||||
/// <summary>
|
||||
/// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status,
|
||||
/// attach a clarifying question (Rückfrage), or submit the breeder's answer.
|
||||
/// attach a clarifying question (Rückfrage), submit the breeder's answer, set the
|
||||
/// breeder-friendly fix note (changelog), or update the internal agent context.
|
||||
/// </summary>
|
||||
public record FeedbackUpdate(
|
||||
string? Message,
|
||||
string? Status,
|
||||
string? Question,
|
||||
string? Answer);
|
||||
string? Answer,
|
||||
string? FixNote,
|
||||
string? AgentContext);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
1573
GerbilManagerWebAPI/Migrations/20260622192338_AddFeedbackFixNoteContextThread.Designer.cs
generated
Normal file
1573
GerbilManagerWebAPI/Migrations/20260622192338_AddFeedbackFixNoteContextThread.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFeedbackFixNoteContextThread : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AgentContext",
|
||||
table: "Feedback",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "FixNote",
|
||||
table: "Feedback",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Thread",
|
||||
table: "Feedback",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AgentContext",
|
||||
table: "Feedback");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FixNote",
|
||||
table: "Feedback");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Thread",
|
||||
table: "Feedback");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -802,6 +802,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AgentContext")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Answer")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -824,6 +827,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<string>("EntityName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("FixNote")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
@@ -844,6 +850,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Thread")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.HasColumnType("text");
|
||||
|
||||
|
||||
@@ -63,5 +63,28 @@ namespace GerbilManagerWebAPI.Models
|
||||
|
||||
/// <summary>When the breeder answered the clarifying question; null until answered.</summary>
|
||||
public DateTimeOffset? AnsweredAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// FIX-NOTE: a breeder-friendly changelog written when the ticket is resolved — plain
|
||||
/// language ("was sich für SIE sichtbar geändert hat"), no technical jargon. Shown in the
|
||||
/// "Geschlossen" view. null while the ticket is open / unresolved.
|
||||
/// </summary>
|
||||
public string? FixNote { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// AGENT-CONTEXT (INTERNAL): the maintainer/agent's working memory — findings, suspected
|
||||
/// cause, files/data touched, the plan, what it is waiting on. MAY contain ids/filenames.
|
||||
/// Returned by the DTO for tooling, but NEVER rendered in the breeder UI. null if unused.
|
||||
/// </summary>
|
||||
public string? AgentContext { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// THREAD/HISTORY: a JSON array of past Q&A turns so earlier rounds aren't lost when a new
|
||||
/// question is asked. Each entry: { "role": "maintainer" | "breeder", "text": string,
|
||||
/// "at": ISO-8601 string }. The current open exchange stays in Question/Answer; when a NEW
|
||||
/// question is set while a previous Q&A exists, the previous (question, answer) is appended
|
||||
/// here first. Plain string column (no FK) so it survives the ingest wipe. null/empty = no history.
|
||||
/// </summary>
|
||||
public string? Thread { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user