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 (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> ( 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); } }