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

@@ -27,6 +27,7 @@ public class ApplicationContext : DbContext
public DbSet<Feedback> Feedback => Set<Feedback>();
public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
public DbSet<WaitingListEntry> WaitingListEntries => Set<WaitingListEntry>();
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
// so it can never drift from Name regardless of which code path mutates the entity.
@@ -234,6 +235,14 @@ public class ApplicationContext : DbContext
e.HasIndex(r => r.GerbilId);
});
// WAITLIST: same relationship-free pattern as Feedback. ContactId is a plain
// nullable Guid column (no navigation property → EF creates NO foreign key), so
// the import re-ingest wipe of Contacts never cascades into — or breaks —
// waiting-list rows. They survive re-ingest, which is the whole point.
modelBuilder.Entity<WaitingListEntry>(e =>
{
e.HasIndex(w => w.CreatedAt);
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
if (isNpgsql)
{

View File

@@ -0,0 +1,24 @@
namespace GerbilManagerWebAPI.Dtos
{
/// <summary>WAITLIST: payload for POST/PUT /waiting-list.</summary>
public record WaitingListInput(
Guid? ContactId,
string? ContactName,
string? WishColor,
string? WishGender,
DateTime? RequestedAt,
string? Status,
string? Note);
/// <summary>WAITLIST: response DTO for a stored waiting-list entry.</summary>
public record WaitingListDto(
Guid Id,
Guid? ContactId,
string? ContactName,
string? WishColor,
string? WishGender,
DateTime? RequestedAt,
string Status,
string? Note,
DateTimeOffset CreatedAt);
}

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddWaitingList : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "WaitingListEntries",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ContactId = table.Column<Guid>(type: "uuid", nullable: true),
ContactName = table.Column<string>(type: "text", nullable: true),
WishColor = table.Column<string>(type: "text", nullable: true),
WishGender = table.Column<string>(type: "text", nullable: true),
RequestedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Status = table.Column<string>(type: "text", nullable: false),
Note = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_WaitingListEntries", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_WaitingListEntries_CreatedAt",
table: "WaitingListEntries",
column: "CreatedAt");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "WaitingListEntries");
}
}
}

View File

@@ -1479,6 +1479,44 @@ namespace GerbilManagerWebAPI.Migrations
});
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.WaitingListEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("ContactId")
.HasColumnType("uuid");
b.Property<string>("ContactName")
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Note")
.HasColumnType("text");
b.Property<DateTime?>("RequestedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<string>("WishColor")
.HasColumnType("text");
b.Property<string>("WishGender")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.ToTable("WaitingListEntries");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
{
b.Property<Guid>("Id")

View File

@@ -0,0 +1,43 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>
/// WAITLIST (RennmausPro nachfrage_tb): a prospective buyer's standing request for a
/// future animal with wish criteria (colour, gender). Decoupled from the rest of the
/// model on purpose — ContactId is a plain nullable Guid column (NOT an enforced
/// foreign key), so the import re-ingest wipe (IngestResolvedService) can delete and
/// recreate contacts without deleting or breaking waiting-list rows. The captured
/// ContactName keeps the entry human-readable even when no contact exists yet (or the
/// referenced contact is gone). Same survives-the-wipe pattern as Feedback.
/// </summary>
public class WaitingListEntry
{
[Key]
public Guid Id { get; set; }
/// <summary>Loose reference (no FK) to the interested contact, if one exists.</summary>
public Guid? ContactId { get; set; }
/// <summary>Free-text name of the interested party (used when no contact is linked).</summary>
public string? ContactName { get; set; }
/// <summary>Wished-for colour variety (free text / catalog name), if any.</summary>
public string? WishColor { get; set; }
/// <summary>Wished-for gender: "male" | "female" | null (no preference).</summary>
public string? WishGender { get; set; }
/// <summary>When the request was made.</summary>
public DateTime? RequestedAt { get; set; }
/// <summary>Workflow status: "offen" | "erfuellt" | "storniert".</summary>
public required string Status { get; set; }
/// <summary>Optional free-text note.</summary>
public string? Note { get; set; }
/// <summary>Server-side creation time.</summary>
public DateTimeOffset CreatedAt { get; set; }
}
}

View File

@@ -129,6 +129,7 @@ app.MapNamesEndpoints();
app.MapFeedbackEndpoints();
app.MapAcquisitionEndpoints();
app.MapSaleReservationEndpoints();
app.MapWaitingListEndpoints();
app.Run();