using System.Text.Json;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
///
/// 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.
///
public static class FeedbackEndpoints
{
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/feedback").WithTags("Feedback");
group.MapPost("/", async Task, BadRequest>> (
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, NotFound, BadRequest>> (
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 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;
}
// 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));
});
group.MapDelete("/{id:guid}", async Task> (
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 readonly JsonSerializerOptions ThreadJson = new(JsonSerializerDefaults.Web);
///
/// 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).
///
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 DeserializeThread(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return Array.Empty();
try
{
return JsonSerializer.Deserialize>(json, ThreadJson)
?? new List();
}
catch (JsonException)
{
return Array.Empty();
}
}
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.FixNote, f.AgentContext,
DeserializeThread(f.Thread));
}
}