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

@@ -159,6 +159,97 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
Assert.Equal("Noch eine Frage?", dto.GetProperty("question").GetString());
}
[Fact]
public async Task Put_resolve_with_fixNote_stores_breeder_changelog()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/feedback", new { message = "Vater falsch.", context = "stammbaum", entityName = "Yuki" });
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
// Resolve the ticket together with a breeder-friendly fix note (changelog).
var resolve = await client.PutAsJsonAsync($"/feedback/{id}", new
{
status = "Resolved",
fixNote = "Die Mutter von Yuki war falsch — sie ist jetzt Izumi. Bitte den Stammbaum neu laden.",
});
Assert.Equal(HttpStatusCode.OK, resolve.StatusCode);
var dto = JsonDocument.Parse(await resolve.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("Resolved", dto.GetProperty("status").GetString());
Assert.Equal("Die Mutter von Yuki war falsch — sie ist jetzt Izumi. Bitte den Stammbaum neu laden.",
dto.GetProperty("fixNote").GetString());
Assert.NotEqual(JsonValueKind.Null, dto.GetProperty("resolvedAt").ValueKind);
// The fix note survives a re-fetch via GET.
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
Assert.Contains(listed.EnumerateArray(),
f => f.GetProperty("id").GetString() == id
&& f.GetProperty("fixNote").GetString()!.StartsWith("Die Mutter von Yuki war falsch"));
}
[Fact]
public async Task Put_agentContext_round_trips_without_changing_status()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/feedback", new { message = "Gewicht falsch.", context = "gerbil-detail" });
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
// Setting only the internal agent context must NOT change the lifecycle status.
var put = await client.PutAsJsonAsync($"/feedback/{id}", new
{
agentContext = "{\"rootCause\":\"rounding in WeightChart\",\"files\":[\"WeightChart.tsx\"],\"plan\":\"fix toFixed\"}",
});
Assert.Equal(HttpStatusCode.OK, put.StatusCode);
var dto = JsonDocument.Parse(await put.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("Open", dto.GetProperty("status").GetString());
Assert.Equal("{\"rootCause\":\"rounding in WeightChart\",\"files\":[\"WeightChart.tsx\"],\"plan\":\"fix toFixed\"}",
dto.GetProperty("agentContext").GetString());
// It is returned by GET as well (for tooling) but the UI never renders it.
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
Assert.Contains(listed.EnumerateArray(),
f => f.GetProperty("id").GetString() == id
&& f.GetProperty("agentContext").GetString()!.Contains("rootCause"));
}
[Fact]
public async Task New_question_pushes_prior_QandA_into_thread()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/feedback", new { message = "Wurf C unklar.", context = "litter-detail", entityName = "Wurf C" });
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
// Round 1: maintainer asks, breeder answers.
await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Wie heißt die Mutter?" });
await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Die Mutter ist Luna." });
// Round 2: a NEW question is asked while the prior Q&A exists.
var ask2 = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Und wer ist der Vater?" });
Assert.Equal(HttpStatusCode.OK, ask2.StatusCode);
var dto = JsonDocument.Parse(await ask2.Content.ReadAsStringAsync()).RootElement;
// Current open exchange = the new question, fresh (no answer yet), status NeedsInfo.
Assert.Equal("NeedsInfo", dto.GetProperty("status").GetString());
Assert.Equal("Und wer ist der Vater?", dto.GetProperty("question").GetString());
Assert.Equal(JsonValueKind.Null, dto.GetProperty("answer").ValueKind);
Assert.Equal(JsonValueKind.Null, dto.GetProperty("answeredAt").ValueKind);
// The prior round was archived into the thread (oldest first): maintainer Q then breeder A.
var thread = dto.GetProperty("thread");
Assert.Equal(JsonValueKind.Array, thread.ValueKind);
Assert.Equal(2, thread.GetArrayLength());
Assert.Equal("maintainer", thread[0].GetProperty("role").GetString());
Assert.Equal("Wie heißt die Mutter?", thread[0].GetProperty("text").GetString());
Assert.Equal("breeder", thread[1].GetProperty("role").GetString());
Assert.Equal("Die Mutter ist Luna.", thread[1].GetProperty("text").GetString());
// Round 2 answered, then round 3 question → thread now holds 4 entries (two full rounds).
await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Der Vater ist Max." });
var ask3 = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Stimmt das Geburtsdatum?" });
var dto3 = JsonDocument.Parse(await ask3.Content.ReadAsStringAsync()).RootElement;
Assert.Equal(4, dto3.GetProperty("thread").GetArrayLength());
Assert.Equal("Stimmt das Geburtsdatum?", dto3.GetProperty("question").GetString());
}
[Fact]
public async Task Put_and_delete_unknown_id_return_404()
{
@@ -231,6 +322,9 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
Question = "Welches Datum stimmt?",
Answer = "Der 2. Juni.",
AnsweredAt = DateTimeOffset.UtcNow,
FixNote = "Das Geburtsdatum von Papa wurde korrigiert.",
AgentContext = "{\"plan\":\"awaiting DOB confirmation\"}",
Thread = "[{\"role\":\"maintainer\",\"text\":\"Erste Frage?\",\"at\":\"2026-06-20T10:00:00Z\"},{\"role\":\"breeder\",\"text\":\"Erste Antwort.\",\"at\":\"2026-06-20T11:00:00Z\"}]",
});
// A contact-scoped feedback report — the ContactId is a loose (FK-free) id,
// so it must survive the contact-table wipe just like gerbil/litter ids.
@@ -265,6 +359,10 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
Assert.Equal("Welches Datum stimmt?", survivor.Question);
Assert.Equal("Der 2. Juni.", survivor.Answer);
Assert.NotNull(survivor.AnsweredAt);
// The new fix-note / agent-context / thread columns survive the wipe too.
Assert.Equal("Das Geburtsdatum von Papa wurde korrigiert.", survivor.FixNote);
Assert.Equal("{\"plan\":\"awaiting DOB confirmation\"}", survivor.AgentContext);
Assert.Contains("Erste Frage?", survivor.Thread);
// The contact-scoped report also survives the contacts wipe (loose ContactId).
var contactSurvivor = await db.Feedback.SingleAsync(f => f.Id == contactFeedbackId);