diff --git a/GerbilManager.Tests/WaitingListEndpointTests.cs b/GerbilManager.Tests/WaitingListEndpointTests.cs new file mode 100644 index 0000000..df27775 --- /dev/null +++ b/GerbilManager.Tests/WaitingListEndpointTests.cs @@ -0,0 +1,204 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using GerbilManagerWebAPI.Import; +using GerbilManagerWebAPI.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace GerbilManager.Tests; + +/// +/// WAITLIST (RennmausPro nachfrage_tb): the prospective-buyer waiting list. +/// - Full CRUD round-trip: POST -> GET (list + by id) -> PUT (status change) -> DELETE. +/// - Validation: an entry with neither contact nor name, and an unknown status, are 400. +/// - CRITICAL: waiting-list rows survive the import re-ingest wipe (loose, FK-free ContactId). +/// +public class WaitingListEndpointTests : IClassFixture +{ + private readonly ApiFactory _factory; + public WaitingListEndpointTests(ApiFactory factory) => _factory = factory; + + [Fact] + public async Task Crud_round_trip() + { + var client = _factory.CreateClient(); + + // CREATE + var contactId = Guid.NewGuid(); + var create = await client.PostAsJsonAsync("/waiting-list", new + { + contactId, + contactName = "Familie Sonntag", + wishColor = "Schwarz", + wishGender = "female", + requestedAt = "2026-06-01T00:00:00Z", + status = "offen", + note = "möchte zwei Weibchen", + }); + Assert.Equal(HttpStatusCode.Created, create.StatusCode); + var created = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement; + var id = created.GetProperty("id").GetString()!; + Assert.Equal("offen", created.GetProperty("status").GetString()); + Assert.Equal("Schwarz", created.GetProperty("wishColor").GetString()); + Assert.Equal(contactId.ToString(), created.GetProperty("contactId").GetString()); + + // GET by id + var byId = JsonDocument.Parse(await client.GetStringAsync($"/waiting-list/{id}")).RootElement; + Assert.Equal("Familie Sonntag", byId.GetProperty("contactName").GetString()); + + // LIST contains it + var list = JsonDocument.Parse(await client.GetStringAsync("/waiting-list")).RootElement; + Assert.Contains(list.EnumerateArray(), e => e.GetProperty("id").GetString() == id); + + // UPDATE: set fulfilled + var update = await client.PutAsJsonAsync($"/waiting-list/{id}", new + { + contactId, + contactName = "Familie Sonntag", + wishColor = "Schwarz", + wishGender = "female", + requestedAt = "2026-06-01T00:00:00Z", + status = "erfuellt", + note = "erledigt", + }); + Assert.Equal(HttpStatusCode.OK, update.StatusCode); + var updated = JsonDocument.Parse(await update.Content.ReadAsStringAsync()).RootElement; + Assert.Equal("erfuellt", updated.GetProperty("status").GetString()); + + // DELETE + var del = await client.DeleteAsync($"/waiting-list/{id}"); + Assert.Equal(HttpStatusCode.NoContent, del.StatusCode); + var after = await client.GetAsync($"/waiting-list/{id}"); + Assert.Equal(HttpStatusCode.NotFound, after.StatusCode); + } + + [Fact] + public async Task Post_rejects_empty_contact_and_name() + { + var client = _factory.CreateClient(); + var resp = await client.PostAsJsonAsync("/waiting-list", new { status = "offen", wishColor = "Gold" }); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + } + + [Fact] + public async Task Post_rejects_unknown_status() + { + var client = _factory.CreateClient(); + var resp = await client.PostAsJsonAsync("/waiting-list", new { contactName = "Test", status = "irgendwas" }); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + } + + [Fact] + public async Task Empty_status_defaults_to_offen() + { + var client = _factory.CreateClient(); + var resp = await client.PostAsJsonAsync("/waiting-list", new { contactName = "Ohne Status" }); + Assert.Equal(HttpStatusCode.Created, resp.StatusCode); + var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement; + Assert.Equal("offen", created.GetProperty("status").GetString()); + } + + [Fact] + public async Task WaitingList_survives_ingest_wipe() + { + // Fresh in-memory DB seeded with a resolved import file (mirrors FeedbackEndpointTests). + var dir = Path.Combine(Path.GetTempPath(), "waitlist-ingest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var contactId = Guid.NewGuid(); + var fatherId = Guid.NewGuid(); + var motherId = Guid.NewGuid(); + var litterId = Guid.NewGuid(); + + var data = new + { + Contacts = new[] + { + new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null } + }, + Litters = new[] + { + new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" } + }, + Gerbils = new[] + { + Animal(fatherId, "Papa", "male", contactId), + Animal(motherId, "Mama", "female", contactId), + }, + GerbilPhotos = Array.Empty(), + }; + File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data)); + + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase("waitlist-ingest-" + Guid.NewGuid().ToString("N")) + .Options; + using var db = new ApplicationContext(opts); + db.Database.EnsureCreated(); + + // A waiting-list entry referencing the contact that the wipe will delete. + var entryId = Guid.NewGuid(); + db.WaitingListEntries.Add(new WaitingListEntry + { + Id = entryId, + ContactId = contactId, + ContactName = "Test Breeder", + WishColor = "Schwarz", + WishGender = "female", + RequestedAt = DateTime.UtcNow, + Status = "offen", + Note = "wartet", + CreatedAt = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Import:SourcePath", dir } }) + .Build(); + + // Run the ingest wipe + reload. + var result = await new IngestResolvedService(db, config, null!).RunAsync(); + Assert.Contains("Ingestion successful!", result); + + // Contacts were wiped & re-created, but the waiting-list entry is untouched. + var survivor = await db.WaitingListEntries.SingleAsync(e => e.Id == entryId); + Assert.Equal(contactId, survivor.ContactId); // loose id preserved even though the contact row was deleted/recreated + Assert.Equal("Test Breeder", survivor.ContactName); + Assert.Equal("offen", survivor.Status); + Assert.Equal(1, await db.WaitingListEntries.CountAsync()); + } + finally + { + try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ } + } + } + + private static object Animal(Guid id, string name, string gender, Guid contactId) => new + { + Id = id, + Name = name, + Gender = gender, + Status = "Breeding", + LitterId = (Guid?)null, + OriginContactId = contactId, + ReceiverContactId = (Guid?)null, + EnclosureId = (Guid?)null, + ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), + DateOfBirth = "2025-01-01", + DateOfDeath = (string?)null, + CauseOfDeath = (string?)null, + GoHomeDate = (string?)null, + Genotype = "aa CC DD EE GG PP spsp rere", + Notes = "", + ImportSource = "docx-export", + ExternalRef = "ext-" + name, + RawImportData = "{}", + OriginBreeder = "Test Zucht", + NameSearch = name.ToLowerInvariant(), + CharacterTraits = Array.Empty(), + CharacterNote = (string?)null, + IsDeaf = false, + IsResident = true, + }; +} diff --git a/GerbilManagerWebAPI/ApplicationContext.cs b/GerbilManagerWebAPI/ApplicationContext.cs index d770881..485895d 100644 --- a/GerbilManagerWebAPI/ApplicationContext.cs +++ b/GerbilManagerWebAPI/ApplicationContext.cs @@ -27,6 +27,7 @@ public class ApplicationContext : DbContext public DbSet Feedback => Set(); public DbSet AcquisitionRecords => Set(); public DbSet SaleReservations => Set(); + public DbSet WaitingListEntries => Set(); // 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(e => + { + e.HasIndex(w => w.CreatedAt); + // DB-4: German collation on remaining searched/sorted text columns (Npgsql-only). if (isNpgsql) { diff --git a/GerbilManagerWebAPI/Dtos/WaitingListDtos.cs b/GerbilManagerWebAPI/Dtos/WaitingListDtos.cs new file mode 100644 index 0000000..386236d --- /dev/null +++ b/GerbilManagerWebAPI/Dtos/WaitingListDtos.cs @@ -0,0 +1,24 @@ +namespace GerbilManagerWebAPI.Dtos +{ + /// WAITLIST: payload for POST/PUT /waiting-list. + public record WaitingListInput( + Guid? ContactId, + string? ContactName, + string? WishColor, + string? WishGender, + DateTime? RequestedAt, + string? Status, + string? Note); + + /// WAITLIST: response DTO for a stored waiting-list entry. + public record WaitingListDto( + Guid Id, + Guid? ContactId, + string? ContactName, + string? WishColor, + string? WishGender, + DateTime? RequestedAt, + string Status, + string? Note, + DateTimeOffset CreatedAt); +} diff --git a/GerbilManagerWebAPI/Endpoints/WaitingListEndpoints.cs b/GerbilManagerWebAPI/Endpoints/WaitingListEndpoints.cs new file mode 100644 index 0000000..31477bd --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/WaitingListEndpoints.cs @@ -0,0 +1,122 @@ +using GerbilManagerWebAPI.Dtos; +using GerbilManagerWebAPI.Models; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// 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. + /// + public static class WaitingListEndpoints + { + /// Allowed workflow statuses (frontend contract). + 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, 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, BadRequest>> ( + 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, NotFound, BadRequest>> ( + 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> ( + 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; + } + + /// Empty/whitespace status defaults to "offen"; unknown values are rejected (null). + 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); + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260622201541_AddWaitingList.Designer.cs b/GerbilManagerWebAPI/Migrations/20260622201541_AddWaitingList.Designer.cs new file mode 100644 index 0000000..abd68ae --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260622201541_AddWaitingList.Designer.cs @@ -0,0 +1,1586 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + [DbContext(typeof(ApplicationContext))] + [Migration("20260622201541_AddWaitingList")] + partial class AddWaitingList + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Block", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("PageId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("PageId"); + + b.ToTable("Blocks"); + + b.HasData( + new + { + Id = new Guid("51720002-0000-0000-0000-000000000001"), + Data = "{\"text\":\"Startseite\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000001"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000002"), + Data = "{\"text\":\"Über die Zucht\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000002"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000003"), + Data = "{\"text\":\"Abgabetiere\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000003"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000004"), + Data = "{\"text\":\"Abgabebedingungen\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000004"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000005"), + Data = "{\"text\":\"Farben & Genetik\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000005"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000006"), + Data = "{\"text\":\"Kontakt\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000006"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000010"), + Data = "{\"mode\":\"auto\",\"intro\":\"\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000003"), + Type = "AbgabetiereList" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000007"), + Data = "{\"text\":\"Impressum\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000007"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000070"), + Data = "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000007"), + Type = "RichText" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000008"), + Data = "{\"text\":\"Datenschutz\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000008"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000080"), + Data = "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000008"), + Type = "RichText" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.BreederSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("City") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Homepage") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NameSuffix") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("ZuchtName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("BreederSettings"); + + b.HasData( + new + { + Id = new Guid("11111111-1111-1111-1111-000000000001"), + Address = "", + City = "", + Email = "", + Homepage = "", + Name = "", + NameSuffix = "", + Phone = "", + ZuchtName = "" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.ColorVariety", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CanonicalGenotype") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ColorVarieties"); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + CanonicalGenotype = "AA chch DD EE GG pp spsp rere", + Name = "REW", + SortOrder = 0 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000002"), + CanonicalGenotype = "aa chch DD EE GG PP spsp rere", + Name = "Hermelin", + SortOrder = 1 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000003"), + CanonicalGenotype = "AA chch DD EE GG PP spsp rere", + Name = "Himalaya", + SortOrder = 2 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000004"), + CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere", + Name = "Zobel", + SortOrder = 3 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000005"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Rotaugenschimmel", + SortOrder = 4 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000006"), + CanonicalGenotype = "AA CC DD EE GG PP spsp rere", + Name = "Agouti", + SortOrder = 5 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000007"), + CanonicalGenotype = "aa CC DD EE GG PP spsp rere", + Name = "Schwarz", + SortOrder = 6 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000008"), + CanonicalGenotype = "AA CC DD EE gg PP spsp rere", + Name = "Silberagouti", + SortOrder = 7 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000009"), + CanonicalGenotype = "aa CC DD EE gg PP spsp rere", + Name = "Anthrazit", + SortOrder = 8 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000010"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs", + SortOrder = 9 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000011"), + CanonicalGenotype = "aa CC dd EE GG PP spsp rere", + Name = "Blau", + SortOrder = 10 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000012"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold", + SortOrder = 11 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000013"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin", + SortOrder = 12 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000014"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs", + SortOrder = 13 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000015"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs", + SortOrder = 14 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000016"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Dilute Gold", + SortOrder = 15 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000017"), + CanonicalGenotype = "aa CC dd EE GG pp spsp rere", + Name = "Dilute Platin", + SortOrder = 16 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000018"), + CanonicalGenotype = "aa CC DD EE gg pp spsp rere", + Name = "Altweiss (REW)", + SortOrder = 17 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000019"), + CanonicalGenotype = "AA CC DD ee gg pp spsp rere", + Name = "Apricot (Blassfuchs)", + SortOrder = 18 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000020"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs", + SortOrder = 19 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000021"), + CanonicalGenotype = "aa CC DD ee gg pp spsp rere", + Name = "C-Separator", + SortOrder = 20 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000022"), + CanonicalGenotype = "AA CC DD EE gg pp spsp rere", + Name = "Elfenbein", + SortOrder = 21 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000023"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs", + SortOrder = 22 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000024"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs", + SortOrder = 23 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000025"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Saphir", + SortOrder = 24 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000026"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Orangeschimmel", + SortOrder = 25 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000027"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Topas", + SortOrder = 26 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000028"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin-Hell", + SortOrder = 27 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000029"), + CanonicalGenotype = "AA CC dd EE GG PP spsp rere", + Name = "Dilute Agouti", + SortOrder = 28 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000030"), + CanonicalGenotype = "AA CC dd EE gg PP spsp rere", + Name = "Dilute Silberagouti", + SortOrder = 29 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000031"), + CanonicalGenotype = "aa CC dd ee GG PP spsp rere", + Name = "Dilute Kohlfuchs", + SortOrder = 30 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000032"), + CanonicalGenotype = "aa CC dd EE gg PP spsp rere", + Name = "Dilute Anthrazit", + SortOrder = 31 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000033"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Silberschimmel", + SortOrder = 36 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000034"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Polarfuchsschimmel", + SortOrder = 37 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000035"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Algierfuchsschimmel", + SortOrder = 38 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000036"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel", + SortOrder = 39 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000037"), + CanonicalGenotype = "aa CC DD efef gg PP spsp rere", + Name = "Blaufuchsschimmel", + SortOrder = 40 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000038"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs, hell", + SortOrder = 41 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000039"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs, hell", + SortOrder = 42 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000040"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Goldfuchsschimmel", + SortOrder = 43 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000041"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold-Hell", + SortOrder = 44 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000042"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs, hell", + SortOrder = 45 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000043"), + CanonicalGenotype = "aa CC DD efef GG pp spsp rere", + Name = "Rotfuchsschimmel", + SortOrder = 46 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000044"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs, hell", + SortOrder = 47 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000045"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel, hell", + SortOrder = 48 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000046"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs, hell", + SortOrder = 49 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000047"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs-Hell", + SortOrder = 50 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000048"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs, hell", + SortOrder = 51 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000049"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Dilute Topas", + SortOrder = 52 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000050"), + CanonicalGenotype = "aa CC dd ee gg pp spsp rere", + Name = "Dilute Blaufuchs", + SortOrder = 53 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000051"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Marder", + SortOrder = 54 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000052"), + CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere", + Name = "Siam", + SortOrder = 55 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000053"), + CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere", + Name = "Zobel-Hell", + SortOrder = 56 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000054"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "CP-Agouti", + SortOrder = 57 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000055"), + CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere", + Name = "CP-Silberagouti", + SortOrder = 59 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000056"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "CP-Algierfuchs", + SortOrder = 61 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000057"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "CP-Polarfuchs", + SortOrder = 63 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000058"), + CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere", + Name = "CP-Fuchs", + SortOrder = 65 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000059"), + CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere", + Name = "CP-Fuchs-Hell", + SortOrder = 66 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000060"), + CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere", + Name = "CP-Blaufuchs", + SortOrder = 67 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000061"), + CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere", + Name = "CP-Orangeschimmel", + SortOrder = 68 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000062"), + CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere", + Name = "CP-Agouti-Hell", + SortOrder = 58 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000063"), + CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere", + Name = "CP-Silberagouti-Hell", + SortOrder = 60 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000064"), + CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere", + Name = "CP-Algierfuchs-Hell", + SortOrder = 62 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000065"), + CanonicalGenotype = "AA cchmch DD ee gg PP spsp rere", + Name = "CP-Polarfuchs-Hell", + SortOrder = 64 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000066"), + CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere", + Name = "CP-Orangeschimmel-Hell", + SortOrder = 69 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000067"), + CanonicalGenotype = "AA CC dd ee GG PP spsp rere", + Name = "Dilute Algierfuchs", + SortOrder = 32 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000068"), + CanonicalGenotype = "AA CC dd ee GG pp spsp rere", + Name = "Dilute Goldfuchs", + SortOrder = 33 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000069"), + CanonicalGenotype = "aa CC dd ee GG pp spsp rere", + Name = "Dilute Rotfuchs", + SortOrder = 34 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000070"), + CanonicalGenotype = "AA CC dd ee gg PP spsp rere", + Name = "Dilute Polarfuchs", + SortOrder = 35 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("IsBreeder") + .HasColumnType("boolean"); + + b.Property("IsReceiver") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("NameSuffix") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Provenance") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Enclosures"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Caption") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnclosureId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EnclosureId"); + + b.ToTable("EnclosurePhotos"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Feedback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityName") + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserAgent") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CauseOfDeath") + .HasColumnType("text"); + + b.Property("CharacterNote") + .HasColumnType("text"); + + b.Property("CharacterTraits") + .IsRequired() + .HasColumnType("text"); + + b.Property("ColorVarietyId") + .HasColumnType("uuid"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DateOfDeath") + .HasColumnType("date"); + + b.Property("EnclosureId") + .HasColumnType("uuid"); + + b.Property("ExternalRef") + .HasColumnType("text"); + + b.Property("Gender") + .IsRequired() + .HasColumnType("text"); + + b.Property("Genotype") + .HasColumnType("text"); + + b.Property("GoHomeDate") + .HasColumnType("date"); + + b.Property("ImportSource") + .HasColumnType("text"); + + b.Property("IsCastrated") + .HasColumnType("boolean"); + + b.Property("IsDeaf") + .HasColumnType("boolean"); + + b.Property("IsResident") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("NameSearch") + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OriginBreeder") + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("OriginContactId") + .HasColumnType("uuid"); + + b.Property("Provenance") + .HasColumnType("text"); + + b.Property("RawImportData") + .HasColumnType("text"); + + b.Property("ReceiverContactId") + .HasColumnType("uuid"); + + b.Property("SpottingType") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ColorVarietyId"); + + b.HasIndex("EnclosureId"); + + b.HasIndex("ExternalRef") + .IsUnique() + .HasFilter("\"ExternalRef\" IS NOT NULL"); + + b.HasIndex("LitterId"); + + b.HasIndex("OriginContactId"); + + b.HasIndex("ReceiverContactId"); + + b.ToTable("Gerbils"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Caption") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("GerbilPhotos"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("Veterinarian") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("HealthRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeathsWithin8Weeks") + .HasColumnType("integer"); + + b.Property("ExpectedGoHomeDate") + .HasColumnType("date"); + + b.Property("ExternalRef") + .HasColumnType("text"); + + b.Property("FatherId") + .HasColumnType("uuid"); + + b.Property("LitterLetter") + .HasColumnType("text"); + + b.Property("MotherId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PairingCode") + .HasColumnType("text"); + + b.Property("Provenance") + .HasColumnType("text"); + + b.Property("TotalBorn") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ExternalRef") + .IsUnique() + .HasFilter("\"ExternalRef\" IS NOT NULL"); + + b.HasIndex("FatherId"); + + b.HasIndex("MotherId"); + + b.ToTable("Litters"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.MailSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppPasswordProtected") + .HasColumnType("text"); + + b.Property("BackgroundPollEnabled") + .HasColumnType("boolean"); + + b.Property("Folder") + .IsRequired() + .HasColumnType("text"); + + b.Property("GmailAddress") + .HasColumnType("text"); + + b.Property("LastUid") + .HasColumnType("bigint"); + + b.Property("PollIntervalMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("MailSettings"); + + b.HasData( + new + { + Id = new Guid("ab0c0000-0000-0000-0000-000000000001"), + BackgroundPollEnabled = false, + Folder = "INBOX", + LastUid = 0L, + PollIntervalMinutes = 15 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Media", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Alt") + .HasColumnType("text"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Media"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Page", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("SeoDescription") + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Pages"); + + b.HasData( + new + { + Id = new Guid("51720001-0000-0000-0000-000000000001"), + Slug = "start", + Status = "Published", + Title = "Startseite" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000002"), + Slug = "ueber-die-zucht", + Status = "Published", + Title = "Über die Zucht" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000003"), + Slug = "abgabetiere", + Status = "Published", + Title = "Abgabetiere" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000004"), + Slug = "abgabebedingungen", + Status = "Published", + Title = "Abgabebedingungen" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000005"), + Slug = "farben-genetik", + Status = "Published", + Title = "Farben & Genetik" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000006"), + Slug = "kontakt", + Status = "Published", + Title = "Kontakt" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000007"), + Slug = "impressum", + Status = "Published", + Title = "Impressum" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000008"), + Slug = "datenschutz", + Status = "Published", + Title = "Datenschutz" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Request", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnsweredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedContactId") + .HasColumnType("uuid"); + + b.Property("BodyText") + .HasColumnType("text"); + + b.Property("DraftReply") + .HasColumnType("text"); + + b.Property("FromAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("FromName") + .HasColumnType("text"); + + b.Property("GmailMessageId") + .IsRequired() + .HasColumnType("text"); + + b.Property("InReplyToMessageId") + .HasColumnType("text"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferencesHeader") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .HasColumnType("text"); + + b.Property("ThreadId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AssignedContactId"); + + b.HasIndex("GmailMessageId") + .IsUnique(); + + b.ToTable("Requests"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("ContractDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("HandoverDate") + .HasColumnType("date"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("SaleContracts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b => + { + b.Property("SaleContractId") + .HasColumnType("uuid"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("PhotoId") + .HasColumnType("uuid"); + + b.HasKey("SaleContractId", "GerbilId"); + + b.HasIndex("GerbilId"); + + b.ToTable("SaleContractAnimal"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultLocale") + .IsRequired() + .HasColumnType("text"); + + b.Property("NavOrder") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Sites"); + + b.HasData( + new + { + Id = new Guid("5172e000-0000-0000-0000-000000000001"), + DefaultLocale = "de", + NavOrder = "[\"51720001-0000-0000-0000-000000000001\",\"51720001-0000-0000-0000-000000000002\",\"51720001-0000-0000-0000-000000000003\",\"51720001-0000-0000-0000-000000000004\",\"51720001-0000-0000-0000-000000000005\",\"51720001-0000-0000-0000-000000000006\"]" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.WaitingListEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("ContactName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("WishColor") + .HasColumnType("text"); + + b.Property("WishGender") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("WaitingListEntries"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("WeightGrams") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("WeightRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Block", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Page", null) + .WithMany("Blocks") + .HasForeignKey("PageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Enclosure", null) + .WithMany() + .HasForeignKey("EnclosureId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b => + { + b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety") + .WithMany() + .HasForeignKey("ColorVarietyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Enclosure", "Enclosure") + .WithMany("Gerbils") + .HasForeignKey("EnclosureId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Litter", "Litter") + .WithMany() + .HasForeignKey("LitterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Contact", "OriginContact") + .WithMany() + .HasForeignKey("OriginContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GerbilManagerWebAPI.Models.Contact", "ReceiverContact") + .WithMany() + .HasForeignKey("ReceiverContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ColorVariety"); + + b.Navigation("Enclosure"); + + b.Navigation("Litter"); + + b.Navigation("OriginContact"); + + b.Navigation("ReceiverContact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Father") + .WithMany() + .HasForeignKey("FatherId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Mother") + .WithMany() + .HasForeignKey("MotherId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Father"); + + b.Navigation("Mother"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Request", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Contact", "AssignedContact") + .WithMany() + .HasForeignKey("AssignedContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignedContact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Contact", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Gerbil") + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GerbilManagerWebAPI.Models.SaleContract", null) + .WithMany("Animals") + .HasForeignKey("SaleContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Gerbil"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b => + { + b.Navigation("Gerbils"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Page", b => + { + b.Navigation("Blocks"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.Navigation("Animals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260622201541_AddWaitingList.cs b/GerbilManagerWebAPI/Migrations/20260622201541_AddWaitingList.cs new file mode 100644 index 0000000..a47b82f --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260622201541_AddWaitingList.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class AddWaitingList : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "WaitingListEntries", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ContactId = table.Column(type: "uuid", nullable: true), + ContactName = table.Column(type: "text", nullable: true), + WishColor = table.Column(type: "text", nullable: true), + WishGender = table.Column(type: "text", nullable: true), + RequestedAt = table.Column(type: "timestamp with time zone", nullable: true), + Status = table.Column(type: "text", nullable: false), + Note = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "WaitingListEntries"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index 2395e3f..063fdb5 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -1479,6 +1479,44 @@ namespace GerbilManagerWebAPI.Migrations }); }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.WaitingListEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("ContactName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("WishColor") + .HasColumnType("text"); + + b.Property("WishGender") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("WaitingListEntries"); + }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b => { b.Property("Id") diff --git a/GerbilManagerWebAPI/Models/WaitingListEntry.cs b/GerbilManagerWebAPI/Models/WaitingListEntry.cs new file mode 100644 index 0000000..9c302a3 --- /dev/null +++ b/GerbilManagerWebAPI/Models/WaitingListEntry.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations; + +namespace GerbilManagerWebAPI.Models +{ + /// + /// 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. + /// + public class WaitingListEntry + { + [Key] + public Guid Id { get; set; } + + /// Loose reference (no FK) to the interested contact, if one exists. + public Guid? ContactId { get; set; } + + /// Free-text name of the interested party (used when no contact is linked). + public string? ContactName { get; set; } + + /// Wished-for colour variety (free text / catalog name), if any. + public string? WishColor { get; set; } + + /// Wished-for gender: "male" | "female" | null (no preference). + public string? WishGender { get; set; } + + /// When the request was made. + public DateTime? RequestedAt { get; set; } + + /// Workflow status: "offen" | "erfuellt" | "storniert". + public required string Status { get; set; } + + /// Optional free-text note. + public string? Note { get; set; } + + /// Server-side creation time. + public DateTimeOffset CreatedAt { get; set; } + } +} diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index 16a071a..256c82b 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -129,6 +129,7 @@ app.MapNamesEndpoints(); app.MapFeedbackEndpoints(); app.MapAcquisitionEndpoints(); app.MapSaleReservationEndpoints(); +app.MapWaitingListEndpoints(); app.Run(); diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index 99c568e..aab8a4c 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -639,6 +639,73 @@ export async function installMockApi(page: Page): Promise { return json(route, 405) } + // WAITLIST: Warteliste/Nachfrage — GET liefert ein BLANKES Array (kein Gridify-Envelope), + // POST/PUT/DELETE wie eine einfache Kollektion. Vor den generischen Kollektionen, weil + // die GET-Antwort kein paginiertes Envelope ist. + const wlm = path.match(/^\/waiting-list(?:\/([^/]+))?$/) + if (wlm) { + const wlId = wlm[1] ? decodeURIComponent(wlm[1]) : null + const ALLOWED = ['offen', 'erfuellt', 'storniert'] + const normStatus = (s: unknown): string | null => { + if (s === undefined || s === null || String(s).trim() === '') return 'offen' + const v = String(s).trim() + return ALLOWED.includes(v) ? v : null + } + if (!wlId) { + if (method === 'GET') { + // Neueste Anfrage zuerst (requestedAt desc, dann createdAt desc). + const sorted = [...db.waitingList].sort((a, b) => { + const ar = String(a.requestedAt ?? '') + const br = String(b.requestedAt ?? '') + if (ar !== br) return ar < br ? 1 : -1 + return String(a.createdAt ?? '') < String(b.createdAt ?? '') ? 1 : -1 + }) + return json(route, 200, sorted) + } + if (method === 'POST') { + const body = request.postDataJSON() as Record + const status = normStatus(body.status) + if (status === null) return json(route, 400, 'Ungültiger Status.') + if (!body.contactId && (!body.contactName || String(body.contactName).trim() === '')) + return json(route, 400, 'Kontakt oder Name ist erforderlich.') + const created = { + id: newId('wl'), + contactId: body.contactId ?? null, + contactName: body.contactName ?? null, + wishColor: body.wishColor ?? null, + wishGender: body.wishGender ?? null, + requestedAt: body.requestedAt ?? null, + status, + note: body.note ?? null, + createdAt: new Date().toISOString(), + } + db.waitingList.push(created) + return json(route, 201, created) + } + return json(route, 405) + } + const wlIdx = db.waitingList.findIndex((r) => r.id === wlId) + if (method === 'GET') { + return wlIdx >= 0 ? json(route, 200, db.waitingList[wlIdx]) : json(route, 404, { title: 'Not Found' }) + } + if (method === 'PUT') { + if (wlIdx < 0) return json(route, 404, { title: 'Not Found' }) + const body = request.postDataJSON() as Record + const status = normStatus(body.status) + if (status === null) return json(route, 400, 'Ungültiger Status.') + if (!body.contactId && (!body.contactName || String(body.contactName).trim() === '')) + return json(route, 400, 'Kontakt oder Name ist erforderlich.') + Object.assign(db.waitingList[wlIdx], { ...body, status }) + return json(route, 200, db.waitingList[wlIdx]) + } + if (method === 'DELETE') { + if (wlIdx < 0) return json(route, 404, { title: 'Not Found' }) + db.waitingList.splice(wlIdx, 1) + return json(route, 204) + } + return json(route, 405) + } + // Generische Kollektionen: / und // m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/) const col = m ? collections[m[1]] : undefined diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts index 2299740..c114902 100644 --- a/gerbil-manager-web/e2e/mock-data.ts +++ b/gerbil-manager-web/e2e/mock-data.ts @@ -89,6 +89,8 @@ export interface MockDb { acquisitions: Record[] // ABGABE-STATUS: Reservierungs-/Abgabe-Status (POST/PUT/DELETE /reservations) reservations: Record[] + // WAITLIST: Warteliste/Nachfrage (RennmausPro nachfrage_tb) + waitingList: Record[] } function gerbil( @@ -591,5 +593,29 @@ export function seedDb(): MockDb { updatedAt: '2026-06-16T09:00:00Z', }, ], + waitingList: [ + { + id: 'wl-seed-1', + contactId: contacts[0]?.id ?? null, + contactName: contacts[0]?.name ?? 'Familie Sonntag', + wishColor: 'Schwarz', + wishGender: 'female', + requestedAt: '2026-05-01T00:00:00Z', + status: 'offen', + note: 'möchte zwei Weibchen', + createdAt: '2026-05-01T00:00:00Z', + }, + { + id: 'wl-seed-2', + contactId: null, + contactName: 'Herr Maier', + wishColor: null, + wishGender: 'male', + requestedAt: '2026-04-10T00:00:00Z', + status: 'erfuellt', + note: null, + createdAt: '2026-04-10T00:00:00Z', + }, + ], } } diff --git a/gerbil-manager-web/e2e/warteliste.spec.ts b/gerbil-manager-web/e2e/warteliste.spec.ts new file mode 100644 index 0000000..348f911 --- /dev/null +++ b/gerbil-manager-web/e2e/warteliste.spec.ts @@ -0,0 +1,73 @@ +/** WAITLIST: Warteliste/Nachfrage — Liste, Anlegen, Status setzen, "nur offene" filtern. */ +import { acceptNextDialog, de, expect, gotoSection, skipUnlessMock, test } from './fixtures' + +const t = de.pages.warteliste + +test('Warteliste ist über die Navigation erreichbar und zeigt Seed-Einträge', async ({ page }) => { + skipUnlessMock() + await gotoSection(page, de.nav.waitingList) + await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible() + + // Seed: ein Kontakt-Link + ein freitextlicher Name. + await expect(page.getByRole('link', { name: 'Zoohandlung Meier' })).toBeVisible() + await expect(page.getByText('Herr Maier')).toBeVisible() +}) + +test('"Nur offene" filtert erfüllte Einträge aus', async ({ page }) => { + skipUnlessMock() + await page.goto('/warteliste') + await expect(page.getByText('Herr Maier')).toBeVisible() // erfüllt + + await page.getByLabel(t.onlyOpen).check() + await expect(page.getByText('Herr Maier')).toHaveCount(0) + await expect(page.getByRole('link', { name: 'Zoohandlung Meier' })).toBeVisible() // offen +}) + +test('Neuer Eintrag anlegen erscheint in der Liste', async ({ page, mockDb }) => { + skipUnlessMock() + await page.goto('/warteliste') + + await page.getByRole('button', { name: `+ ${t.newButton}` }).click() + const form = page.getByRole('form', { name: t.formTitleNew }) + await expect(form).toBeVisible() + + await form.getByLabel(t.fields.contactName).fill('Neuinteressent Test') + await form.getByLabel(t.fields.wishColor).selectOption({ label: 'Schwarz' }) + await form.getByLabel(t.fields.wishGender).selectOption({ label: t.wishGender.male }) + await form.getByRole('button', { name: t.save }).click() + + await expect(page.getByText(de.common.saved)).toBeVisible() + await expect(page.getByText('Neuinteressent Test')).toBeVisible() + + expect(mockDb).not.toBeNull() + const created = mockDb!.waitingList.find((e) => e.contactName === 'Neuinteressent Test') + expect(created).toBeTruthy() + expect(created).toMatchObject({ status: 'offen', wishColor: 'Schwarz', wishGender: 'male' }) +}) + +test('Status je Eintrag direkt umstellbar', async ({ page, mockDb }) => { + skipUnlessMock() + await page.goto('/warteliste') + + // Status-Select des offenen Seed-Eintrags (Zoohandlung Meier) auf "erfüllt" stellen. + const statusSelect = page.getByLabel(`${t.fields.status} Zoohandlung Meier`) + await statusSelect.selectOption({ label: t.status.erfuellt }) + await expect(page.getByText(de.common.saved)).toBeVisible() + + expect(mockDb!.waitingList.find((e) => e.id === 'wl-seed-1')?.status).toBe('erfuellt') +}) + +test('Eintrag löschen entfernt ihn aus der Liste', async ({ page, mockDb }) => { + skipUnlessMock() + await page.goto('/warteliste') + await expect(page.getByText('Herr Maier')).toBeVisible() + + acceptNextDialog(page) + // Löschen-Button im Karten-Block von Herr Maier (zweiter Eintrag). + const card = page.locator('li', { hasText: 'Herr Maier' }) + await card.getByRole('button', { name: t.delete }).click() + + await expect(page.getByText(de.common.deleted)).toBeVisible() + await expect(page.getByText('Herr Maier')).toHaveCount(0) + expect(mockDb!.waitingList.find((e) => e.id === 'wl-seed-2')).toBeUndefined() +}) diff --git a/gerbil-manager-web/src/App.tsx b/gerbil-manager-web/src/App.tsx index 59d7ba0..efb001f 100644 --- a/gerbil-manager-web/src/App.tsx +++ b/gerbil-manager-web/src/App.tsx @@ -30,6 +30,7 @@ import WebseiteEditorPage from './pages/WebseiteEditorPage' import WebseiteVorschauPage from './pages/WebseiteVorschauPage' import AnfragenPage from './pages/AnfragenPage' import AnfrageDetailPage from './pages/AnfrageDetailPage' +import WartelistePage from './pages/WartelistePage' function BeckenRedirect() { const { '*': splat } = useParams() @@ -101,6 +102,8 @@ export default function App() { } /> } /> + {/* WAITLIST: Warteliste/Nachfrage (RennmausPro nachfrage_tb) */} + } /> } /> diff --git a/gerbil-manager-web/src/api/waitingList.ts b/gerbil-manager-web/src/api/waitingList.ts new file mode 100644 index 0000000..a235be7 --- /dev/null +++ b/gerbil-manager-web/src/api/waitingList.ts @@ -0,0 +1,56 @@ +/** + * WAITLIST (RennmausPro nachfrage_tb): API client for the prospective-buyer waiting list. + * Full CRUD against /waiting-list (GET list, POST, PUT /{id}, DELETE /{id}). + * + * Entries are decoupled from contacts on the backend (loose nullable contactId, no FK), + * so they survive the import re-ingest wipe — same pattern as Feedback. + */ +import { api } from './client' + +const RESOURCE = '/waiting-list' + +/** Workflow status (matches the backend contract). */ +export type WaitingListStatus = 'offen' | 'erfuellt' | 'storniert' +export const WAITING_LIST_STATUSES: WaitingListStatus[] = ['offen', 'erfuellt', 'storniert'] + +/** Wished-for gender; null = no preference. */ +export type WishGender = 'male' | 'female' | null + +export interface WaitingListEntry { + id: string + contactId: string | null + contactName: string | null + wishColor: string | null + wishGender: string | null + requestedAt: string | null + status: string + note: string | null + createdAt: string +} + +/** Payload for POST/PUT /waiting-list. */ +export interface WaitingListInput { + contactId?: string | null + contactName?: string | null + wishColor?: string | null + wishGender?: string | null + requestedAt?: string | null + status: WaitingListStatus + note?: string | null +} + +export function listWaitingList(): Promise { + return api.get(RESOURCE) +} + +export function createWaitingListEntry(body: WaitingListInput): Promise { + return api.post(RESOURCE, body) +} + +export function updateWaitingListEntry(id: string, body: WaitingListInput): Promise { + return api.put(`${RESOURCE}/${id}`, body) +} + +export function deleteWaitingListEntry(id: string): Promise { + return api.delete(`${RESOURCE}/${id}`) +} diff --git a/gerbil-manager-web/src/components/AppShell.tsx b/gerbil-manager-web/src/components/AppShell.tsx index 8d2073c..dd7592c 100644 --- a/gerbil-manager-web/src/components/AppShell.tsx +++ b/gerbil-manager-web/src/components/AppShell.tsx @@ -28,6 +28,8 @@ const SECONDARY: NavItem[] = [ { to: '/reservierungen', label: de.nav.reservations, icon: '🔖' }, // INBOX-1: Anfragen-Posteingang { to: '/anfragen', label: de.nav.requests, icon: '📨' }, + // WAITLIST: Warteliste/Nachfrage (RennmausPro nachfrage_tb) + { to: '/warteliste', label: de.nav.waitingList, icon: '📝' }, { to: '/statistik', label: de.nav.statistics, icon: '📊' }, // FEAT-13: Abgabeverträge + Zuchtprofil { to: '/vertraege', label: de.nav.contracts, icon: '📄' }, diff --git a/gerbil-manager-web/src/pages/WartelistePage.tsx b/gerbil-manager-web/src/pages/WartelistePage.tsx new file mode 100644 index 0000000..635ee62 --- /dev/null +++ b/gerbil-manager-web/src/pages/WartelistePage.tsx @@ -0,0 +1,375 @@ +/** + * WAITLIST (RennmausPro nachfrage_tb): Warteliste/Nachfrage. + * + * Interessenten mit Wunschkriterien (Farbschlag, Geschlecht), Anfragedatum, + * Status (offen | erfuellt | storniert) und Notiz. Anlegen/Bearbeiten über ein + * Inline-Formular; Status direkt je Eintrag umstellbar; Filter „nur offene". + * Wunsch-Farbschlag aus dem bestehenden Farbkatalog wählbar; Kontakt-Bezug als Link. + * + * Entkoppelt vom Kontakt (lose contactId ohne FK) → überlebt den Import-Re-Ingest. + */ +import { useMemo, useState, type FormEvent } from 'react' +import { Link } from 'react-router-dom' +import { de } from '../strings/de' +import { useApi, useMutation } from '../hooks/useApi' +import { useToast } from '../components/toast' +import { listContacts, listColorVarieties } from '../api/lookups' +import { + listWaitingList, + createWaitingListEntry, + updateWaitingListEntry, + deleteWaitingListEntry, + WAITING_LIST_STATUSES, + type WaitingListEntry, + type WaitingListInput, + type WaitingListStatus, +} from '../api/waitingList' + +interface FormState { + contactId: string + contactName: string + wishColor: string + wishGender: '' | 'male' | 'female' + requestedAt: string + status: WaitingListStatus + note: string +} + +const EMPTY: FormState = { + contactId: '', + contactName: '', + wishColor: '', + wishGender: '', + requestedAt: '', + status: 'offen', + note: '', +} + +/** "" -> null, sonst der Wert. */ +const nn = (s: string): string | null => (s.trim() === '' ? null : s) + +function isStatus(value: string): value is WaitingListStatus { + return (WAITING_LIST_STATUSES as string[]).includes(value) +} + +export default function WartelistePage() { + const t = de.pages.warteliste + const toast = useToast() + + const entries = useApi(() => listWaitingList(), []) + const contacts = useApi(() => listContacts(), []) + const colors = useApi(() => listColorVarieties(), []) + + const [onlyOpen, setOnlyOpen] = useState(false) + const [editingId, setEditingId] = useState(null) // null = nicht im Formular, '' = neu + const [form, setForm] = useState(EMPTY) + + const set = (key: K, value: FormState[K]) => + setForm((f) => ({ ...f, [key]: value })) + + const saveMutation = useMutation((args: { id: string | null; body: WaitingListInput }) => + args.id ? updateWaitingListEntry(args.id, args.body) : createWaitingListEntry(args.body), + ) + const deleteMutation = useMutation((id: string) => deleteWaitingListEntry(id)) + + const contactNameById = useMemo(() => { + const map = new Map() + for (const c of contacts.data ?? []) map.set(c.id, c.name) + return map + }, [contacts.data]) + + const visible = useMemo(() => { + const rows = entries.data ?? [] + return onlyOpen ? rows.filter((e) => e.status === 'offen') : rows + }, [entries.data, onlyOpen]) + + function openNew() { + setForm(EMPTY) + setEditingId('') + } + + function openEdit(e: WaitingListEntry) { + setForm({ + contactId: e.contactId ?? '', + contactName: e.contactName ?? '', + wishColor: e.wishColor ?? '', + wishGender: e.wishGender === 'male' || e.wishGender === 'female' ? e.wishGender : '', + requestedAt: e.requestedAt ? e.requestedAt.slice(0, 10) : '', + status: isStatus(e.status) ? e.status : 'offen', + note: e.note ?? '', + }) + setEditingId(e.id) + } + + function closeForm() { + setEditingId(null) + } + + function buildBody(): WaitingListInput { + return { + contactId: nn(form.contactId), + contactName: nn(form.contactName), + wishColor: nn(form.wishColor), + wishGender: form.wishGender === '' ? null : form.wishGender, + requestedAt: form.requestedAt ? new Date(`${form.requestedAt}T00:00:00Z`).toISOString() : null, + status: form.status, + note: nn(form.note), + } + } + + async function onSubmit(ev: FormEvent) { + ev.preventDefault() + if (!form.contactId && form.contactName.trim() === '') { + toast.error(t.validationName) + return + } + const result = await saveMutation.run({ id: editingId || null, body: buildBody() }) + if (result.ok) { + toast.success(de.common.saved) + closeForm() + entries.reload() + } else { + toast.error(result.error) + } + } + + /** Schnell-Statuswechsel direkt in der Liste (sendet den vollständigen Eintrag mit). */ + async function changeStatus(entry: WaitingListEntry, status: WaitingListStatus) { + const body: WaitingListInput = { + contactId: entry.contactId, + contactName: entry.contactName, + wishColor: entry.wishColor, + wishGender: entry.wishGender, + requestedAt: entry.requestedAt, + status, + note: entry.note, + } + const result = await saveMutation.run({ id: entry.id, body }) + if (result.ok) { + toast.success(de.common.saved) + entries.reload() + } else { + toast.error(result.error) + } + } + + async function onDelete(entry: WaitingListEntry) { + if (!window.confirm(t.deleteConfirm)) return + const result = await deleteMutation.run(entry.id) + if (result.ok) { + toast.success(de.common.deleted) + entries.reload() + } else { + toast.error(result.error) + } + } + + const statusLabel = (status: string): string => + isStatus(status) ? t.status[status] : status + + const genderLabel = (g: string | null): string => + g === 'male' ? t.wishGender.male : g === 'female' ? t.wishGender.female : t.wishGender.any + + return ( +
+
+
+

{t.title}

+

{t.subtitle}

+ {!entries.loading && !entries.error && ( +

+ {visible.length} {t.countLabel} +

+ )} +
+ +
+ +
+ +
+ + {editingId !== null && ( +
+

{editingId ? t.formTitleEdit : t.formTitleNew}

+ + + + + + + + + + + + + +