feat(warteliste): Nachfrage/Warteliste für Interessenten
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ public class ApplicationContext : DbContext
|
||||
public DbSet<Request> Requests => Set<Request>();
|
||||
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
|
||||
public DbSet<Feedback> Feedback => Set<Feedback>();
|
||||
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.
|
||||
@@ -212,6 +213,15 @@ public class ApplicationContext : DbContext
|
||||
e.HasIndex(f => f.CreatedAt);
|
||||
});
|
||||
|
||||
// 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)
|
||||
{
|
||||
|
||||
24
GerbilManagerWebAPI/Dtos/WaitingListDtos.cs
Normal file
24
GerbilManagerWebAPI/Dtos/WaitingListDtos.cs
Normal 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);
|
||||
}
|
||||
122
GerbilManagerWebAPI/Endpoints/WaitingListEndpoints.cs
Normal file
122
GerbilManagerWebAPI/Endpoints/WaitingListEndpoints.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
1586
GerbilManagerWebAPI/Migrations/20260622201541_AddWaitingList.Designer.cs
generated
Normal file
1586
GerbilManagerWebAPI/Migrations/20260622201541_AddWaitingList.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1362,6 +1362,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")
|
||||
|
||||
43
GerbilManagerWebAPI/Models/WaitingListEntry.cs
Normal file
43
GerbilManagerWebAPI/Models/WaitingListEntry.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,7 @@ app.MapCmsEndpoints();
|
||||
app.MapRequestEndpoints();
|
||||
app.MapNamesEndpoints();
|
||||
app.MapFeedbackEndpoints();
|
||||
app.MapWaitingListEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user