Merge branch 'worktree-agent-a0e6f31173772c798'

# Conflicts:
#	GerbilManagerWebAPI/ApplicationContext.cs
#	GerbilManagerWebAPI/Program.cs
#	gerbil-manager-web/e2e/mock-data.ts
#	gerbil-manager-web/src/strings/de.ts
This commit is contained in:
2026-06-22 23:01:14 +02:00
17 changed files with 2723 additions and 0 deletions

View File

@@ -0,0 +1,122 @@
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// WAITLIST (RennmausPro nachfrage_tb): a standing list of interested parties waiting
/// for a future animal matching their wish criteria (colour, gender).
/// GET /waiting-list -> list entries, newest request first
/// POST /waiting-list -> create an entry (returns 201)
/// PUT /waiting-list/{id} -> update / change status
/// DELETE /waiting-list/{id} -> remove an entry
/// Decoupled from contacts (loose nullable ContactId, no FK), so rows survive the
/// import re-ingest wipe — exactly like Feedback.
/// </summary>
public static class WaitingListEndpoints
{
/// <summary>Allowed workflow statuses (frontend contract).</summary>
private static readonly string[] AllowedStatuses = { "offen", "erfuellt", "storniert" };
private const string DefaultStatus = "offen";
public static IEndpointRouteBuilder MapWaitingListEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/waiting-list").WithTags("WaitingList");
group.MapGet("/", async (ApplicationContext db) =>
{
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column,
// and RequestedAt is nullable — sort entries with a date first, newest first.
var rows = await db.WaitingListEntries.AsNoTracking().ToListAsync();
return TypedResults.Ok(rows
.OrderByDescending(e => e.RequestedAt ?? DateTime.MinValue)
.ThenByDescending(e => e.CreatedAt)
.Select(ToDto)
.ToList());
});
group.MapGet("/{id:guid}", async Task<Results<Ok<WaitingListDto>, NotFound>> (
Guid id, ApplicationContext db) =>
{
var entity = await db.WaitingListEntries.AsNoTracking().FirstOrDefaultAsync(e => e.Id == id);
return entity is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(entity));
});
group.MapPost("/", async Task<Results<Created<WaitingListDto>, BadRequest<string>>> (
WaitingListInput input, ApplicationContext db) =>
{
var status = NormalizeStatus(input.Status);
if (status is null)
return TypedResults.BadRequest("Ungültiger Status.");
if (string.IsNullOrWhiteSpace(input.ContactName) && input.ContactId is null)
return TypedResults.BadRequest("Kontakt oder Name ist erforderlich.");
var entity = new WaitingListEntry
{
Id = Guid.NewGuid(),
ContactId = input.ContactId,
ContactName = Trim(input.ContactName),
WishColor = Trim(input.WishColor),
WishGender = Trim(input.WishGender),
RequestedAt = input.RequestedAt,
Status = status,
Note = Trim(input.Note),
CreatedAt = DateTimeOffset.UtcNow,
};
db.WaitingListEntries.Add(entity);
await db.SaveChangesAsync();
return TypedResults.Created($"/waiting-list/{entity.Id}", ToDto(entity));
});
group.MapPut("/{id:guid}", async Task<Results<Ok<WaitingListDto>, NotFound, BadRequest<string>>> (
Guid id, WaitingListInput input, ApplicationContext db) =>
{
var entity = await db.WaitingListEntries.FirstOrDefaultAsync(e => e.Id == id);
if (entity is null) return TypedResults.NotFound();
var status = NormalizeStatus(input.Status);
if (status is null)
return TypedResults.BadRequest("Ungültiger Status.");
if (string.IsNullOrWhiteSpace(input.ContactName) && input.ContactId is null)
return TypedResults.BadRequest("Kontakt oder Name ist erforderlich.");
entity.ContactId = input.ContactId;
entity.ContactName = Trim(input.ContactName);
entity.WishColor = Trim(input.WishColor);
entity.WishGender = Trim(input.WishGender);
entity.RequestedAt = input.RequestedAt;
entity.Status = status;
entity.Note = Trim(input.Note);
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.WaitingListEntries.FirstOrDefaultAsync(e => e.Id == id);
if (entity is null) return TypedResults.NotFound();
db.WaitingListEntries.Remove(entity);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
/// <summary>Empty/whitespace status defaults to "offen"; unknown values are rejected (null).</summary>
private static string? NormalizeStatus(string? status)
{
if (string.IsNullOrWhiteSpace(status)) return DefaultStatus;
var trimmed = status.Trim();
return AllowedStatuses.Contains(trimmed) ? trimmed : null;
}
private static string? Trim(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
private static WaitingListDto ToDto(WaitingListEntry e) =>
new(e.Id, e.ContactId, e.ContactName, e.WishColor, e.WishGender, e.RequestedAt, e.Status, e.Note, e.CreatedAt);
}
}