Tickets können jetzt eine Rückfrage (Question) tragen und die Züchterin kann
in-app antworten. Feedback um FK-freie Felder Question/Answer/AnsweredAt erweitert
(überlebt Ingest-Wipe); Status-Lebenszyklus Open → NeedsInfo (Rückfrage gestellt)
→ Answered (beantwortet) → Resolved. Migration AddFeedbackQuestionAnswer.
PUT /feedback/{id}: question → NeedsInfo, answer → Answered+AnsweredAt; DTO gibt
die Felder zurück. Tickets-Seite (/hilfe/tickets) zeigt die Rückfrage hervorgehoben
und bietet ein Antwort-Feld + „Antworten"; Status-Badges Offen/Rückfrage offen/
Beantwortet/Gelöst.
Tests: FeedbackEndpointTests (Frage→NeedsInfo, Antwort→Answered, übersteht Ingest),
e2e tickets.spec.ts. dotnet/vitest/playwright grün.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
152 lines
7.0 KiB
C#
152 lines
7.0 KiB
C#
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
|
|
{
|
|
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) =>
|
|
{
|
|
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column.
|
|
var rows = await db.Feedback.AsNoTracking().ToListAsync();
|
|
return TypedResults.Ok(rows
|
|
.OrderByDescending(f => f.CreatedAt)
|
|
.Select(ToDto)
|
|
.ToList());
|
|
});
|
|
|
|
group.MapPut("/{id:guid}", async Task<Results<Ok<FeedbackDto>, NotFound, BadRequest<string>>> (
|
|
Guid id, FeedbackUpdate input, ApplicationContext db) =>
|
|
{
|
|
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
|
if (entity is null)
|
|
return TypedResults.NotFound();
|
|
|
|
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 (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))
|
|
{
|
|
entity.Status = "NeedsInfo";
|
|
entity.ResolvedAt = 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;
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
return TypedResults.Ok(ToDto(entity));
|
|
});
|
|
|
|
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();
|
|
|
|
db.Feedback.Remove(entity);
|
|
await db.SaveChangesAsync();
|
|
return TypedResults.NoContent();
|
|
});
|
|
|
|
return app;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|