From 94f055f9bf78ed95c0eee08aad2fc36bb68de6d0 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 09:19:42 +0200 Subject: [PATCH 1/2] WEB-0: CMS content model + /api/site-snapshot + Page/Block CRUD - Entities: Site (singleton, navOrder JSON), Page (slug/title/seoDescription/status enum), Block (single table, Type enum + Data JSON; RichText=Markdown), Media. Migration AddCmsEntities, seeds 6 Jimdo-mirror pages (placeholder Heading each; abgabetiere also an auto AbgabetiereList block) + Site singleton nav. - GET /api/site-snapshot: one JSON doc (site nav + pages + ordered blocks {id,order,type,data}); AbgabetiereList(auto) resolved from live ForSale gerbils -> {name, farbschlag, group(=Becken), photos[urls], aiSaleText:null} (no price per god). SiteSnapshotService. - CRUD: GET/POST/PUT/DELETE pages; add/update/delete blocks; PUT .../blocks/order reorder. Block type allowlisted by enum; data validated as JSON object. --- GerbilManagerWebAPI/ApplicationContext.cs | 80 ++ .../Cms/SiteSnapshotService.cs | 124 ++ GerbilManagerWebAPI/Dtos/CmsDtos.cs | 26 + GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs | 153 ++ .../20260606071918_AddCmsEntities.Designer.cs | 1263 +++++++++++++++++ .../20260606071918_AddCmsEntities.cs | 141 ++ .../ApplicationContextModelSnapshot.cs | 227 +++ GerbilManagerWebAPI/Models/CmsModels.cs | 80 ++ GerbilManagerWebAPI/Program.cs | 1 + 9 files changed, 2095 insertions(+) create mode 100644 GerbilManagerWebAPI/Cms/SiteSnapshotService.cs create mode 100644 GerbilManagerWebAPI/Dtos/CmsDtos.cs create mode 100644 GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs create mode 100644 GerbilManagerWebAPI/Migrations/20260606071918_AddCmsEntities.Designer.cs create mode 100644 GerbilManagerWebAPI/Migrations/20260606071918_AddCmsEntities.cs create mode 100644 GerbilManagerWebAPI/Models/CmsModels.cs diff --git a/GerbilManagerWebAPI/ApplicationContext.cs b/GerbilManagerWebAPI/ApplicationContext.cs index 3a58929..a18ca88 100644 --- a/GerbilManagerWebAPI/ApplicationContext.cs +++ b/GerbilManagerWebAPI/ApplicationContext.cs @@ -17,6 +17,10 @@ public class ApplicationContext : DbContext public DbSet WeightRecords => Set(); public DbSet SaleContracts => Set(); public DbSet BreederSettings => Set(); + public DbSet Sites => Set(); + public DbSet Pages => Set(); + public DbSet Blocks => Set(); + public DbSet Media => 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. @@ -122,9 +126,85 @@ public class ApplicationContext : DbContext modelBuilder.Entity() .HasData(new BreederSettings { Id = GerbilManagerWebAPI.Models.BreederSettings.SingletonId }); + // WEB epic: CMS content model. + modelBuilder.Entity(e => + { + var navConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter, string>( + v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null), + v => string.IsNullOrEmpty(v) + ? new List() + : System.Text.Json.JsonSerializer.Deserialize>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List()); + var navComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer>( + (a, b) => (a ?? new List()).SequenceEqual(b ?? new List()), + v => v == null ? 0 : v.Aggregate(0, (h, x) => HashCode.Combine(h, x.GetHashCode())), + v => v.ToList()); + e.Property(s => s.NavOrder).HasConversion(navConverter, navComparer); + }); + + modelBuilder.Entity(e => + { + e.HasIndex(p => p.Slug).IsUnique(); + e.Property(p => p.Status).HasConversion(); + e.HasMany(p => p.Blocks).WithOne() + .HasForeignKey(b => b.PageId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(e => e.Property(b => b.Type).HasConversion()); + + SeedCms(modelBuilder); SeedColorVarieties(modelBuilder); } + /// + /// Seed the public website's CMS: one Site + the 6 Jimdo-mirror pages, each with a + /// placeholder Heading; the abgabetiere page also carries an auto AbgabetiereList block + /// (resolved from live ForSale animals at snapshot time). Hand-editable afterwards. + /// + private static void SeedCms(ModelBuilder modelBuilder) + { + Guid Pid(int n) => new($"51720001-0000-0000-0000-{n:D12}"); + Guid Bid(int n) => new($"51720002-0000-0000-0000-{n:D12}"); + + (int n, string Slug, string Title)[] pages = + { + (1, "start", "Startseite"), + (2, "ueber-die-zucht", "Über die Zucht"), + (3, "abgabetiere", "Abgabetiere"), + (4, "abgabebedingungen", "Abgabebedingungen"), + (5, "farben-genetik", "Farben & Genetik"), + (6, "kontakt", "Kontakt"), + }; + + var pageRows = new List(); + var blockRows = new List(); + foreach (var p in pages) + { + pageRows.Add(new Page { Id = Pid(p.n), Slug = p.Slug, Title = p.Title, Status = PageStatus.Published }); + // placeholder Heading per page + blockRows.Add(new Block + { + Id = Bid(p.n), PageId = Pid(p.n), Order = 0, Type = BlockType.Heading, + Data = $"{{\"text\":\"{p.Title}\",\"level\":1}}", + }); + } + // abgabetiere (n=3): auto AbgabetiereList as the second block + blockRows.Add(new Block + { + Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList, + Data = "{\"mode\":\"auto\",\"intro\":\"\"}", + }); + + modelBuilder.Entity().HasData(pageRows); + modelBuilder.Entity().HasData(blockRows); + + modelBuilder.Entity().HasData(new Site + { + Id = Site.SingletonId, + DefaultLocale = "de", + NavOrder = pages.Select(p => Pid(p.n)).ToList(), + }); + } + /// /// Seed the colour-variety catalog. Source of truth = Kevin's GEN-2 generated list /// (gerbil-manager-web/src/genetics/colorVarietySeed.generated.json): 73 varieties diff --git a/GerbilManagerWebAPI/Cms/SiteSnapshotService.cs b/GerbilManagerWebAPI/Cms/SiteSnapshotService.cs new file mode 100644 index 0000000..19aaf9b --- /dev/null +++ b/GerbilManagerWebAPI/Cms/SiteSnapshotService.cs @@ -0,0 +1,124 @@ +using System.Text.Json.Nodes; +using GerbilManagerWebAPI.Models; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Cms +{ + /// + /// Builds the full CMS snapshot consumed by the static site renderer (WEB-1). The + /// abgabetiere page's auto AbgabetiereList block is resolved here from live ForSale + /// animals — nothing private is ever exposed (publish = snapshot of public content). + /// + public sealed class SiteSnapshotService + { + private readonly ApplicationContext _db; + + public SiteSnapshotService(ApplicationContext db) => _db = db; + + public async Task BuildAsync() + { + var site = await _db.Sites.AsNoTracking().FirstOrDefaultAsync() + ?? new Site { Id = Site.SingletonId }; + var pages = await _db.Pages.AsNoTracking().Include(p => p.Blocks).ToListAsync(); + var pageById = pages.ToDictionary(p => p.Id); + + var navOrder = new JsonArray(); + foreach (var id in site.NavOrder) + if (pageById.TryGetValue(id, out var p)) + navOrder.Add(p.Slug); + + JsonArray? forSale = null; // resolved lazily, reused across blocks + + int NavIndex(Guid id) { var i = site.NavOrder.IndexOf(id); return i < 0 ? int.MaxValue : i; } + + var pagesArr = new JsonArray(); + foreach (var page in pages.OrderBy(p => NavIndex(p.Id)).ThenBy(p => p.Slug)) + { + var blocks = new JsonArray(); + foreach (var b in page.Blocks.OrderBy(b => b.Order)) + { + var data = JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data) as JsonObject + ?? new JsonObject(); + if (b.Type == BlockType.AbgabetiereList && AsString(data["mode"]) == "auto") + { + forSale ??= await BuildForSaleAsync(); + data["animals"] = forSale.DeepClone(); + } + blocks.Add(new JsonObject + { + ["id"] = b.Id.ToString(), + ["order"] = b.Order, + ["type"] = b.Type.ToString(), + ["data"] = data, + }); + } + pagesArr.Add(new JsonObject + { + ["slug"] = page.Slug, + ["title"] = page.Title, + ["seoDescription"] = page.SeoDescription, + ["status"] = page.Status.ToString(), + ["blocks"] = blocks, + }); + } + + return new JsonObject + { + ["site"] = new JsonObject + { + ["defaultLocale"] = site.DefaultLocale, + ["navOrder"] = navOrder, + }, + ["pages"] = pagesArr, + }; + } + + private async Task BuildForSaleAsync() + { + var animals = await _db.Gerbils.AsNoTracking() + .Where(g => g.Status == GerbilStatus.ForSale) + .OrderBy(g => g.Name) + .Select(g => new + { + g.Id, + g.Name, + Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null, + // group = Becken (enclosure) name — matches how the Abgabe composer + // groups ForSale animals by enclosure-mates (god ruling). null = single. + Group = g.Enclosure != null ? g.Enclosure.Name : null, + }) + .ToListAsync(); + + var ids = animals.Select(a => a.Id).ToList(); + var photos = await _db.GerbilPhotos.AsNoTracking() + .Where(p => ids.Contains(p.GerbilId)) + .OrderBy(p => p.SortOrder) + .Select(p => new { p.GerbilId, p.FileName }) + .ToListAsync(); + var photosByGerbil = photos + .GroupBy(p => p.GerbilId) + .ToDictionary(g => g.Key, g => g.Select(x => $"/photos/files/{x.FileName}").ToList()); + + var arr = new JsonArray(); + foreach (var a in animals) + { + var photoArr = new JsonArray(); + if (photosByGerbil.TryGetValue(a.Id, out var urls)) + foreach (var u in urls) photoArr.Add(u); + + arr.Add(new JsonObject + { + ["name"] = a.Name, + ["farbschlag"] = a.Farbschlag, + ["group"] = a.Group, + ["photos"] = photoArr, + ["aiSaleText"] = null, // populated by WEB-5 once a Gemini key exists + }); + } + return arr; + } + + private static string? AsString(JsonNode? node) => + node is JsonValue v && v.TryGetValue(out var s) ? s : null; + } +} diff --git a/GerbilManagerWebAPI/Dtos/CmsDtos.cs b/GerbilManagerWebAPI/Dtos/CmsDtos.cs new file mode 100644 index 0000000..dd6ea58 --- /dev/null +++ b/GerbilManagerWebAPI/Dtos/CmsDtos.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using GerbilManagerWebAPI.Models; + +namespace GerbilManagerWebAPI.Dtos +{ + // Response DTOs ---------------------------------------------------------- + + public record PageSummaryDto(Guid Id, string Slug, string Title, string? SeoDescription, PageStatus Status); + + public record PageDto( + Guid Id, string Slug, string Title, string? SeoDescription, PageStatus Status, + IReadOnlyList Blocks); + + /// Block.Data is emitted as a parsed JSON object (not a string). + public record BlockDto(Guid Id, int Order, BlockType Type, JsonNode? Data); + + // Request DTOs ----------------------------------------------------------- + + public record PageInput(string Slug, string Title, string? SeoDescription, PageStatus? Status); + + /// Data is the type-specific JSON object (e.g. {"markdown":"…"} for RichText). + public record BlockInput(BlockType Type, JsonElement Data, int? Order); + + public record BlockOrderInput(List BlockIds); +} diff --git a/GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs b/GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs new file mode 100644 index 0000000..1eaeb7c --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs @@ -0,0 +1,153 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using GerbilManagerWebAPI.Cms; +using GerbilManagerWebAPI.Dtos; +using GerbilManagerWebAPI.Models; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// WEB-0: CMS for the public website. GET /api/site-snapshot is the publish-time export + /// (with Abgabetiere resolved from live ForSale animals); the /api/pages + /api/blocks + /// CRUD edits content (hand-edit admin UI in WEB-0b, AI tools later). LAN-only like the + /// rest of the API. + /// + public static class CmsEndpoints + { + public static IEndpointRouteBuilder MapCmsEndpoints(this IEndpointRouteBuilder app) + { + var api = app.MapGroup("/api").WithTags("CMS"); + + // ---- snapshot (consumed by the static renderer) ---- + api.MapGet("/site-snapshot", async (ApplicationContext db) => + TypedResults.Ok(await new SiteSnapshotService(db).BuildAsync())); + + // ---- pages ---- + api.MapGet("/pages", async (ApplicationContext db) => + TypedResults.Ok(await db.Pages.AsNoTracking().OrderBy(p => p.Slug) + .Select(p => new PageSummaryDto(p.Id, p.Slug, p.Title, p.SeoDescription, p.Status)) + .ToListAsync())); + + api.MapGet("/pages/{slug}", async Task, NotFound>> (string slug, ApplicationContext db) => + { + var p = await db.Pages.AsNoTracking().Include(x => x.Blocks) + .FirstOrDefaultAsync(x => x.Slug == slug); + return p is null ? TypedResults.NotFound() : TypedResults.Ok(ToPageDto(p)); + }); + + api.MapPost("/pages", async Task, ValidationProblem, Conflict>> (PageInput input, ApplicationContext db) => + { + if (string.IsNullOrWhiteSpace(input.Slug) || string.IsNullOrWhiteSpace(input.Title)) + return TypedResults.ValidationProblem(new Dictionary { ["slug"] = ["Slug and title are required."] }); + if (await db.Pages.AnyAsync(p => p.Slug == input.Slug)) + return TypedResults.Conflict($"A page with slug '{input.Slug}' already exists."); + + var page = new Page + { + Id = Guid.NewGuid(), + Slug = input.Slug, + Title = input.Title, + SeoDescription = input.SeoDescription, + Status = input.Status ?? PageStatus.Draft, + }; + db.Pages.Add(page); + await db.SaveChangesAsync(); + return TypedResults.Created($"/api/pages/{page.Slug}", ToPageDto(page)); + }); + + api.MapPut("/pages/{id:guid}", async Task> (Guid id, PageInput input, ApplicationContext db) => + { + var page = await db.Pages.FirstOrDefaultAsync(p => p.Id == id); + if (page is null) return TypedResults.NotFound(); + page.Slug = input.Slug; + page.Title = input.Title; + page.SeoDescription = input.SeoDescription; + if (input.Status is PageStatus s) page.Status = s; + await db.SaveChangesAsync(); + return TypedResults.NoContent(); + }); + + api.MapDelete("/pages/{id:guid}", async Task> (Guid id, ApplicationContext db) => + { + var page = await db.Pages.FirstOrDefaultAsync(p => p.Id == id); + if (page is null) return TypedResults.NotFound(); + db.Pages.Remove(page); // cascades blocks + await db.SaveChangesAsync(); + return TypedResults.NoContent(); + }); + + // ---- blocks ---- + api.MapPost("/pages/{pageId:guid}/blocks", async Task, NotFound, BadRequest>> ( + Guid pageId, BlockInput input, ApplicationContext db) => + { + if (!await db.Pages.AnyAsync(p => p.Id == pageId)) return TypedResults.NotFound(); + if (input.Data.ValueKind is not (JsonValueKind.Object or JsonValueKind.Undefined)) + return TypedResults.BadRequest("Block data must be a JSON object."); + + int order = input.Order ?? ((await db.Blocks.Where(b => b.PageId == pageId) + .Select(b => (int?)b.Order).MaxAsync() ?? -1) + 1); + var block = new Block + { + Id = Guid.NewGuid(), + PageId = pageId, + Order = order, + Type = input.Type, + Data = input.Data.ValueKind == JsonValueKind.Object ? input.Data.GetRawText() : "{}", + }; + db.Blocks.Add(block); + await db.SaveChangesAsync(); + return TypedResults.Created($"/api/blocks/{block.Id}", ToBlockDto(block)); + }); + + api.MapPut("/blocks/{id:guid}", async Task>> ( + Guid id, BlockInput input, ApplicationContext db) => + { + var block = await db.Blocks.FirstOrDefaultAsync(b => b.Id == id); + if (block is null) return TypedResults.NotFound(); + if (input.Data.ValueKind is not (JsonValueKind.Object or JsonValueKind.Undefined)) + return TypedResults.BadRequest("Block data must be a JSON object."); + block.Type = input.Type; + if (input.Data.ValueKind == JsonValueKind.Object) block.Data = input.Data.GetRawText(); + if (input.Order is int o) block.Order = o; + await db.SaveChangesAsync(); + return TypedResults.NoContent(); + }); + + api.MapDelete("/blocks/{id:guid}", async Task> (Guid id, ApplicationContext db) => + { + var block = await db.Blocks.FirstOrDefaultAsync(b => b.Id == id); + if (block is null) return TypedResults.NotFound(); + db.Blocks.Remove(block); + await db.SaveChangesAsync(); + return TypedResults.NoContent(); + }); + + // reorder: body = ordered block ids; Order set to the list index + api.MapPut("/pages/{pageId:guid}/blocks/order", async Task>> ( + Guid pageId, BlockOrderInput input, ApplicationContext db) => + { + var blocks = await db.Blocks.Where(b => b.PageId == pageId).ToListAsync(); + if (blocks.Count == 0 && !await db.Pages.AnyAsync(p => p.Id == pageId)) + return TypedResults.NotFound(); + var byId = blocks.ToDictionary(b => b.Id); + if (input.BlockIds.Count != blocks.Count || input.BlockIds.Any(bid => !byId.ContainsKey(bid))) + return TypedResults.BadRequest("blockIds must list exactly the page's block ids."); + for (int i = 0; i < input.BlockIds.Count; i++) + byId[input.BlockIds[i]].Order = i; + await db.SaveChangesAsync(); + return TypedResults.NoContent(); + }); + + return app; + } + + private static BlockDto ToBlockDto(Block b) => + new(b.Id, b.Order, b.Type, JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data)); + + private static PageDto ToPageDto(Page p) => new( + p.Id, p.Slug, p.Title, p.SeoDescription, p.Status, + p.Blocks.OrderBy(b => b.Order).Select(ToBlockDto).ToList()); + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260606071918_AddCmsEntities.Designer.cs b/GerbilManagerWebAPI/Migrations/20260606071918_AddCmsEntities.Designer.cs new file mode 100644 index 0000000..843c62d --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260606071918_AddCmsEntities.Designer.cs @@ -0,0 +1,1263 @@ +// +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("20260606071918_AddCmsEntities")] + partial class AddCmsEntities + { + /// + 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" + }); + }); + + 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("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 = "", + 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"); + + 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 = "Pink Eyed White (PEW)", + 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 = "Schwarzschimmel", + SortOrder = 4 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000006"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Rotaugenschimmel", + SortOrder = 5 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000007"), + CanonicalGenotype = "AA CC DD EE GG PP spsp rere", + Name = "Agouti", + SortOrder = 6 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000008"), + CanonicalGenotype = "aa CC DD EE GG PP spsp rere", + Name = "Schwarz", + SortOrder = 7 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000009"), + CanonicalGenotype = "AA CC DD EE gg PP spsp rere", + Name = "Silberagouti", + SortOrder = 8 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000010"), + CanonicalGenotype = "aa CC DD EE gg PP spsp rere", + Name = "Anthrazit", + SortOrder = 9 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000011"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs", + SortOrder = 10 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000012"), + CanonicalGenotype = "aa CC dd EE GG PP spsp rere", + Name = "Blau", + SortOrder = 11 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000013"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold", + SortOrder = 12 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000014"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin", + SortOrder = 13 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000015"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs", + SortOrder = 14 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000016"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs", + SortOrder = 15 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000017"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "dd Gold", + SortOrder = 16 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000018"), + CanonicalGenotype = "aa CC dd EE GG pp spsp rere", + Name = "dd Platin", + SortOrder = 17 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000019"), + CanonicalGenotype = "aa CC DD EE gg pp spsp rere", + Name = "Altweiss (REW)", + SortOrder = 18 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000020"), + CanonicalGenotype = "AA CC DD ee gg pp spsp rere", + Name = "Apricot (Blassfuchs)", + SortOrder = 19 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000021"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs", + SortOrder = 20 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000022"), + CanonicalGenotype = "aa CC DD ee gg pp spsp rere", + Name = "C-Separator", + SortOrder = 21 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000023"), + CanonicalGenotype = "AA CC DD EE gg pp spsp rere", + Name = "Elfenbein", + SortOrder = 22 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000024"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs", + SortOrder = 23 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000025"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Marder", + SortOrder = 24 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000026"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Siam (Marder-Hell)", + SortOrder = 25 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000027"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs", + SortOrder = 26 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000028"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Saphir", + SortOrder = 27 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000029"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Schimmel (Orangeschimmel)", + SortOrder = 28 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000030"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Topas", + SortOrder = 29 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000031"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin-Hell", + SortOrder = 30 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000032"), + CanonicalGenotype = "AA CC dd EE GG PP spsp rere", + Name = "Agouti dd", + SortOrder = 31 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000033"), + CanonicalGenotype = "AA CC dd EE gg PP spsp rere", + Name = "Silberagouti dd", + SortOrder = 32 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000034"), + CanonicalGenotype = "aa CC dd ee GG PP spsp rere", + Name = "Kohlfuchs dd", + SortOrder = 33 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000035"), + CanonicalGenotype = "aa CC dd EE gg PP spsp rere", + Name = "Anthrazit dd", + SortOrder = 34 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000036"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "Agouti CP-Hell", + SortOrder = 35 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000037"), + CanonicalGenotype = "aa cchmcchm DD ee gg PP spsp rere", + Name = "Blaufuchs CP", + SortOrder = 36 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000038"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Polarfuchsschimmel", + SortOrder = 37 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000039"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Silberschimmel", + SortOrder = 38 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000040"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Algierfuchsschimmel", + SortOrder = 39 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000041"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "Polarfuchs-Hell CP", + SortOrder = 40 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000042"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel", + SortOrder = 41 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000043"), + CanonicalGenotype = "aa CC DD efef gg PP spsp rere", + Name = "Blaufuchsschimmel", + SortOrder = 42 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000044"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs, hell", + SortOrder = 43 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000045"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs, hell", + SortOrder = 44 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000046"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Goldfuchsschimmel", + SortOrder = 45 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000047"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold-Hell", + SortOrder = 46 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000048"), + CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere", + Name = "Siam (Marder-Hell) dd", + SortOrder = 47 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000049"), + CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere", + Name = "Marder dd", + SortOrder = 48 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000050"), + CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere", + Name = "Zobel-Hell", + SortOrder = 49 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000051"), + CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere", + Name = "Silberagouti dd CP", + SortOrder = 50 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000052"), + CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere", + Name = "Silberagouti-Hell dd CP", + SortOrder = 51 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000053"), + CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere", + Name = "Agouti dd CP", + SortOrder = 52 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000054"), + CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere", + Name = "Agouti-Hell dd CP", + SortOrder = 53 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000055"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs, hell", + SortOrder = 54 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000056"), + CanonicalGenotype = "aa CC DD efef GG pp spsp rere", + Name = "Rotfuchsschimmel", + SortOrder = 55 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000057"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs, hell", + SortOrder = 56 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000058"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel, hell", + SortOrder = 57 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000059"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs, hell", + SortOrder = 58 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000060"), + CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere", + Name = "Zobel dd", + SortOrder = 59 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000061"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs-Hell", + SortOrder = 60 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000062"), + CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere", + Name = "Kohlfuchs CP", + SortOrder = 61 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000063"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "Algierfuchs CP", + SortOrder = 62 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000064"), + CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere", + Name = "Silberagouti CP", + SortOrder = 63 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000065"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "Agouti CP", + SortOrder = 64 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000066"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "Algierfuchs-Hell CP", + SortOrder = 65 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000067"), + CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere", + Name = "Kohlfuchs,hell CP", + SortOrder = 66 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000068"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "Polarfuchs CP", + SortOrder = 67 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000069"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs, hell", + SortOrder = 68 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000070"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Topas dd", + SortOrder = 69 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000071"), + CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere", + Name = "Zobel-Hell dd", + SortOrder = 70 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000072"), + CanonicalGenotype = "aa cchmcchm DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel CP", + SortOrder = 71 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000073"), + CanonicalGenotype = "aa CC dd ee gg PP spsp rere", + Name = "Blaufuchs dd", + SortOrder = 72 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Phone") + .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.Gerbil", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CauseOfDeath") + .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("LitterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NameSearch") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OriginBreeder") + .HasColumnType("text"); + + b.Property("OriginContactId") + .HasColumnType("uuid"); + + b.Property("RawImportData") + .HasColumnType("text"); + + b.Property("ReceiverContactId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ColorVarietyId"); + + b.HasIndex("EnclosureId"); + + 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("ExpectedGoHomeDate") + .HasColumnType("date"); + + b.Property("FatherId") + .HasColumnType("uuid"); + + b.Property("MotherId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PairingCode") + .HasColumnType("text"); + + b.Property("TotalBorn") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FatherId"); + + b.HasIndex("MotherId"); + + b.ToTable("Litters"); + }); + + 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" + }); + }); + + 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.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.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.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.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/20260606071918_AddCmsEntities.cs b/GerbilManagerWebAPI/Migrations/20260606071918_AddCmsEntities.cs new file mode 100644 index 0000000..7ea2047 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260606071918_AddCmsEntities.cs @@ -0,0 +1,141 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class AddCmsEntities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Media", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + FileName = table.Column(type: "text", nullable: false), + Url = table.Column(type: "text", nullable: false), + Alt = table.Column(type: "text", nullable: true), + Width = table.Column(type: "integer", nullable: true), + Height = table.Column(type: "integer", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Media", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Pages", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Slug = table.Column(type: "text", nullable: false), + Title = table.Column(type: "text", nullable: false), + SeoDescription = table.Column(type: "text", nullable: true), + Status = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Pages", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Sites", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + DefaultLocale = table.Column(type: "text", nullable: false), + NavOrder = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Sites", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Blocks", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + PageId = table.Column(type: "uuid", nullable: false), + Order = table.Column(type: "integer", nullable: false), + Type = table.Column(type: "text", nullable: false), + Data = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Blocks", x => x.Id); + table.ForeignKey( + name: "FK_Blocks_Pages_PageId", + column: x => x.PageId, + principalTable: "Pages", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "Pages", + columns: new[] { "Id", "SeoDescription", "Slug", "Status", "Title" }, + values: new object[,] + { + { new Guid("51720001-0000-0000-0000-000000000001"), null, "start", "Published", "Startseite" }, + { new Guid("51720001-0000-0000-0000-000000000002"), null, "ueber-die-zucht", "Published", "Über die Zucht" }, + { new Guid("51720001-0000-0000-0000-000000000003"), null, "abgabetiere", "Published", "Abgabetiere" }, + { new Guid("51720001-0000-0000-0000-000000000004"), null, "abgabebedingungen", "Published", "Abgabebedingungen" }, + { new Guid("51720001-0000-0000-0000-000000000005"), null, "farben-genetik", "Published", "Farben & Genetik" }, + { new Guid("51720001-0000-0000-0000-000000000006"), null, "kontakt", "Published", "Kontakt" } + }); + + migrationBuilder.InsertData( + table: "Sites", + columns: new[] { "Id", "DefaultLocale", "NavOrder" }, + values: new object[] { new Guid("5172e000-0000-0000-0000-000000000001"), "de", "[\"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\"]" }); + + migrationBuilder.InsertData( + table: "Blocks", + columns: new[] { "Id", "Data", "Order", "PageId", "Type" }, + values: new object[,] + { + { new Guid("51720002-0000-0000-0000-000000000001"), "{\"text\":\"Startseite\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000001"), "Heading" }, + { new Guid("51720002-0000-0000-0000-000000000002"), "{\"text\":\"Über die Zucht\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000002"), "Heading" }, + { new Guid("51720002-0000-0000-0000-000000000003"), "{\"text\":\"Abgabetiere\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000003"), "Heading" }, + { new Guid("51720002-0000-0000-0000-000000000004"), "{\"text\":\"Abgabebedingungen\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000004"), "Heading" }, + { new Guid("51720002-0000-0000-0000-000000000005"), "{\"text\":\"Farben & Genetik\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000005"), "Heading" }, + { new Guid("51720002-0000-0000-0000-000000000006"), "{\"text\":\"Kontakt\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000006"), "Heading" }, + { new Guid("51720002-0000-0000-0000-000000000010"), "{\"mode\":\"auto\",\"intro\":\"\"}", 1, new Guid("51720001-0000-0000-0000-000000000003"), "AbgabetiereList" } + }); + + migrationBuilder.CreateIndex( + name: "IX_Blocks_PageId", + table: "Blocks", + column: "PageId"); + + migrationBuilder.CreateIndex( + name: "IX_Pages_Slug", + table: "Pages", + column: "Slug", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Blocks"); + + migrationBuilder.DropTable( + name: "Media"); + + migrationBuilder.DropTable( + name: "Sites"); + + migrationBuilder.DropTable( + name: "Pages"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index bcba41a..cebc813 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -21,6 +21,91 @@ namespace GerbilManagerWebAPI.Migrations 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" + }); + }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.BreederSettings", b => { b.Property("Id") @@ -842,6 +927,107 @@ namespace GerbilManagerWebAPI.Migrations b.ToTable("Litters"); }); + 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" + }); + }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => { b.Property("Id") @@ -890,6 +1076,33 @@ namespace GerbilManagerWebAPI.Migrations 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.WeightRecord", b => { b.Property("Id") @@ -915,6 +1128,15 @@ namespace GerbilManagerWebAPI.Migrations 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.Gerbil", b => { b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety") @@ -1030,6 +1252,11 @@ namespace GerbilManagerWebAPI.Migrations b.Navigation("Gerbils"); }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.Page", b => + { + b.Navigation("Blocks"); + }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => { b.Navigation("Animals"); diff --git a/GerbilManagerWebAPI/Models/CmsModels.cs b/GerbilManagerWebAPI/Models/CmsModels.cs new file mode 100644 index 0000000..d66dc28 --- /dev/null +++ b/GerbilManagerWebAPI/Models/CmsModels.cs @@ -0,0 +1,80 @@ +using System.ComponentModel.DataAnnotations; + +namespace GerbilManagerWebAPI.Models +{ + /// Publication state of a CMS page (serialised as string name). + public enum PageStatus + { + Draft = 0, + Published = 1, + } + + /// + /// CMS block kinds. RichText holds MARKDOWN (sanitised on render). AbgabetiereList is + /// dynamic — resolved from live ForSale animals at snapshot time. Serialised as string. + /// + public enum BlockType + { + Heading = 0, + RichText = 1, + Image = 2, + Gallery = 3, + ContactInfo = 4, + AbgabetiereList = 5, + } + + /// Singleton site settings (WEB epic CMS). + public class Site + { + /// Fixed id — there is exactly one Site row. + public static readonly Guid SingletonId = new("5172e000-0000-0000-0000-000000000001"); + + [Key] + public Guid Id { get; set; } + public string DefaultLocale { get; set; } = "de"; + /// Ordered page ids for the public nav (JSON list). + public List NavOrder { get; set; } = new(); + } + + /// A CMS page (one of the public website's pages). + public class Page + { + [Key] + public Guid Id { get; set; } + /// Stable URL slug (e.g. "abgabetiere") — the human key. + public required string Slug { get; set; } + public required string Title { get; set; } + public string? SeoDescription { get; set; } + public PageStatus Status { get; set; } = PageStatus.Draft; + + public ICollection Blocks { get; } = new List(); + } + + /// + /// A content block on a page (single table, discriminated by ). + /// Type-specific payload lives in as JSON (RichText = Markdown). + /// + public class Block + { + [Key] + public Guid Id { get; set; } + public Guid PageId { get; set; } + /// Explicit ordering within the page (AI tools reorder by this). + public int Order { get; set; } + public BlockType Type { get; set; } + /// Type-specific fields as a JSON object string. + public string Data { get; set; } = "{}"; + } + + /// A media asset referenced by Image/Gallery blocks. + public class Media + { + [Key] + public Guid Id { get; set; } + public required string FileName { get; set; } + public required string Url { get; set; } + public string? Alt { get; set; } + public int? Width { get; set; } + public int? Height { get; set; } + } +} diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index 76dd181..5891c35 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -76,6 +76,7 @@ app.MapImportEndpoints(); app.MapContractEndpoints(); app.MapSettingsEndpoints(); app.MapExportEndpoints(); +app.MapCmsEndpoints(); app.Run(); From f73ac0d4d8ef17a5546dccdad34703c8c1d062c2 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 09:21:19 +0200 Subject: [PATCH 2/2] =?UTF-8?q?WEB-0:=20CMS=20endpoint=20tests=20=E2=80=94?= =?UTF-8?q?=20snapshot=20shape/nav,=20Abgabetiere=20resolves=20ForSale=20(?= =?UTF-8?q?Becken=20group),=20block=20CRUD+reorder=20(SQLite=20host)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GerbilManager.Tests/CmsTests.cs | 83 +++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 GerbilManager.Tests/CmsTests.cs diff --git a/GerbilManager.Tests/CmsTests.cs b/GerbilManager.Tests/CmsTests.cs new file mode 100644 index 0000000..037b65a --- /dev/null +++ b/GerbilManager.Tests/CmsTests.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace GerbilManager.Tests; + +/// WEB-0: CMS snapshot shape, Abgabetiere auto-resolution from ForSale, block reorder. +public class CmsTests : IClassFixture +{ + private readonly HttpClient _client; + public CmsTests(ApiFactory factory) => _client = factory.CreateClient(); + + [Fact] + public async Task Snapshot_has_site_nav_and_six_seeded_pages() + { + var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot")); + var root = doc.RootElement; + + Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString()); + var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList(); + Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav); + + var pages = root.GetProperty("pages").EnumerateArray().ToList(); + Assert.Equal(6, pages.Count); + // abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array + var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere"); + var listBlock = abg.GetProperty("blocks").EnumerateArray() + .Single(b => b.GetProperty("type").GetString() == "AbgabetiereList"); + Assert.Equal("auto", listBlock.GetProperty("data").GetProperty("mode").GetString()); + Assert.Equal(JsonValueKind.Array, listBlock.GetProperty("data").GetProperty("animals").ValueKind); + } + + [Fact] + public async Task Abgabetiere_resolves_ForSale_animals_with_becken_group() + { + // a Becken + a ForSale gerbil in it + var encResp = await _client.PostAsJsonAsync("/enclosures", new { name = "Verkaufsbecken" }); + var encId = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); + await _client.PostAsJsonAsync("/gerbils", new { name = "Verkaufsmaus", gender = "female", status = "ForSale", enclosureId = encId }); + // a non-ForSale gerbil must NOT appear + await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Active" }); + + var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot")); + var animals = doc.RootElement.GetProperty("pages").EnumerateArray() + .Single(p => p.GetProperty("slug").GetString() == "abgabetiere") + .GetProperty("blocks").EnumerateArray() + .Single(b => b.GetProperty("type").GetString() == "AbgabetiereList") + .GetProperty("data").GetProperty("animals").EnumerateArray().ToList(); + + var sale = animals.SingleOrDefault(a => a.GetProperty("name").GetString() == "Verkaufsmaus"); + Assert.Equal(JsonValueKind.Object, sale.ValueKind); + Assert.Equal("Verkaufsbecken", sale.GetProperty("group").GetString()); + Assert.Equal(JsonValueKind.Array, sale.GetProperty("photos").ValueKind); + Assert.DoesNotContain(animals, a => a.GetProperty("name").GetString() == "Bleibtmaus"); + } + + [Fact] + public async Task Block_crud_and_reorder() + { + // create a page + two blocks + var pageResp = await _client.PostAsJsonAsync("/api/pages", new { slug = "test-page", title = "Testseite" }); + Assert.Equal(HttpStatusCode.Created, pageResp.StatusCode); + var pageId = JsonDocument.Parse(await pageResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); + + async Task AddBlock(string type, object data) => + JsonDocument.Parse(await (await _client.PostAsJsonAsync($"/api/pages/{pageId}/blocks", new { type, data })) + .Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetGuid(); + + var b1 = await AddBlock("Heading", new { text = "Erstes", level = 2 }); + var b2 = await AddBlock("RichText", new { markdown = "**hallo**" }); + + // reorder: b2 before b1 + var reorder = await _client.PutAsJsonAsync($"/api/pages/{pageId}/blocks/order", new { blockIds = new[] { b2, b1 } }); + Assert.Equal(HttpStatusCode.NoContent, reorder.StatusCode); + + var blocks = JsonDocument.Parse(await _client.GetStringAsync("/api/pages/test-page")) + .RootElement.GetProperty("blocks").EnumerateArray().ToList(); + Assert.Equal(b2.ToString(), blocks[0].GetProperty("id").GetString()); + Assert.Equal(b1.ToString(), blocks[1].GetProperty("id").GetString()); + // RichText markdown round-trips inside data + Assert.Equal("**hallo**", blocks[0].GetProperty("data").GetProperty("markdown").GetString()); + } +}