Merge feature/web-0: CMS model (Site/Page/Block/Media) + /api/site-snapshot (Abgabetiere auto from ForSale) + Page/Block CRUD [god-QA: 61]

This commit is contained in:
2026-06-06 09:27:12 +02:00
10 changed files with 2178 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace GerbilManager.Tests;
/// <summary>WEB-0: CMS snapshot shape, Abgabetiere auto-resolution from ForSale, block reorder.</summary>
public class CmsTests : IClassFixture<ApiFactory>
{
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<Guid> 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());
}
}

View File

@@ -17,6 +17,10 @@ public class ApplicationContext : DbContext
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
public DbSet<BreederSettings> BreederSettings => Set<BreederSettings>();
public DbSet<Site> Sites => Set<Site>();
public DbSet<Page> Pages => Set<Page>();
public DbSet<Block> Blocks => Set<Block>();
public DbSet<Media> Media => Set<Media>();
// 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<BreederSettings>()
.HasData(new BreederSettings { Id = GerbilManagerWebAPI.Models.BreederSettings.SingletonId });
// WEB epic: CMS content model.
modelBuilder.Entity<Site>(e =>
{
var navConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<Guid>, string>(
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
v => string.IsNullOrEmpty(v)
? new List<Guid>()
: System.Text.Json.JsonSerializer.Deserialize<List<Guid>>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List<Guid>());
var navComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<List<Guid>>(
(a, b) => (a ?? new List<Guid>()).SequenceEqual(b ?? new List<Guid>()),
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<Page>(e =>
{
e.HasIndex(p => p.Slug).IsUnique();
e.Property(p => p.Status).HasConversion<string>();
e.HasMany(p => p.Blocks).WithOne()
.HasForeignKey(b => b.PageId).OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<Block>(e => e.Property(b => b.Type).HasConversion<string>());
SeedCms(modelBuilder);
SeedColorVarieties(modelBuilder);
}
/// <summary>
/// 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.
/// </summary>
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<Page>();
var blockRows = new List<Block>();
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<Page>().HasData(pageRows);
modelBuilder.Entity<Block>().HasData(blockRows);
modelBuilder.Entity<Site>().HasData(new Site
{
Id = Site.SingletonId,
DefaultLocale = "de",
NavOrder = pages.Select(p => Pid(p.n)).ToList(),
});
}
/// <summary>
/// Seed the colour-variety catalog. Source of truth = Kevin's GEN-2 generated list
/// (gerbil-manager-web/src/genetics/colorVarietySeed.generated.json): 73 varieties

View File

@@ -0,0 +1,124 @@
using System.Text.Json.Nodes;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Cms
{
/// <summary>
/// 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).
/// </summary>
public sealed class SiteSnapshotService
{
private readonly ApplicationContext _db;
public SiteSnapshotService(ApplicationContext db) => _db = db;
public async Task<JsonObject> 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<JsonArray> 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<string>(out var s) ? s : null;
}
}

View File

@@ -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<BlockDto> Blocks);
/// <summary>Block.Data is emitted as a parsed JSON object (not a string).</summary>
public record BlockDto(Guid Id, int Order, BlockType Type, JsonNode? Data);
// Request DTOs -----------------------------------------------------------
public record PageInput(string Slug, string Title, string? SeoDescription, PageStatus? Status);
/// <summary>Data is the type-specific JSON object (e.g. {"markdown":"…"} for RichText).</summary>
public record BlockInput(BlockType Type, JsonElement Data, int? Order);
public record BlockOrderInput(List<Guid> BlockIds);
}

View File

@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<Results<Ok<PageDto>, 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<Results<Created<PageDto>, ValidationProblem, Conflict<string>>> (PageInput input, ApplicationContext db) =>
{
if (string.IsNullOrWhiteSpace(input.Slug) || string.IsNullOrWhiteSpace(input.Title))
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["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<Results<NoContent, NotFound>> (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<Results<NoContent, NotFound>> (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<Results<Created<BlockDto>, NotFound, BadRequest<string>>> (
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<Results<NoContent, NotFound, BadRequest<string>>> (
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<Results<NoContent, NotFound>> (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<Results<NoContent, NotFound, BadRequest<string>>> (
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());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddCmsEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Media",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FileName = table.Column<string>(type: "text", nullable: false),
Url = table.Column<string>(type: "text", nullable: false),
Alt = table.Column<string>(type: "text", nullable: true),
Width = table.Column<int>(type: "integer", nullable: true),
Height = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Media", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Pages",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Slug = table.Column<string>(type: "text", nullable: false),
Title = table.Column<string>(type: "text", nullable: false),
SeoDescription = table.Column<string>(type: "text", nullable: true),
Status = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Pages", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Sites",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
DefaultLocale = table.Column<string>(type: "text", nullable: false),
NavOrder = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Sites", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Blocks",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
PageId = table.Column<Guid>(type: "uuid", nullable: false),
Order = table.Column<int>(type: "integer", nullable: false),
Type = table.Column<string>(type: "text", nullable: false),
Data = table.Column<string>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Blocks");
migrationBuilder.DropTable(
name: "Media");
migrationBuilder.DropTable(
name: "Sites");
migrationBuilder.DropTable(
name: "Pages");
}
}
}

View File

@@ -21,6 +21,91 @@ namespace GerbilManagerWebAPI.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("GerbilManagerWebAPI.Models.Block", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Data")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Order")
.HasColumnType("integer");
b.Property<Guid>("PageId")
.HasColumnType("uuid");
b.Property<string>("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<Guid>("Id")
@@ -842,6 +927,107 @@ namespace GerbilManagerWebAPI.Migrations
b.ToTable("Litters");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Media", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Alt")
.HasColumnType("text");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("Height")
.HasColumnType("integer");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("Width")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Media");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Page", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("SeoDescription")
.HasColumnType("text");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<string>("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<Guid>("Id")
@@ -890,6 +1076,33 @@ namespace GerbilManagerWebAPI.Migrations
b.ToTable("SaleContractAnimal");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Site", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("DefaultLocale")
.IsRequired()
.HasColumnType("text");
b.Property<string>("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<Guid>("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");

View File

@@ -0,0 +1,80 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>Publication state of a CMS page (serialised as string name).</summary>
public enum PageStatus
{
Draft = 0,
Published = 1,
}
/// <summary>
/// CMS block kinds. RichText holds MARKDOWN (sanitised on render). AbgabetiereList is
/// dynamic — resolved from live ForSale animals at snapshot time. Serialised as string.
/// </summary>
public enum BlockType
{
Heading = 0,
RichText = 1,
Image = 2,
Gallery = 3,
ContactInfo = 4,
AbgabetiereList = 5,
}
/// <summary>Singleton site settings (WEB epic CMS).</summary>
public class Site
{
/// <summary>Fixed id — there is exactly one Site row.</summary>
public static readonly Guid SingletonId = new("5172e000-0000-0000-0000-000000000001");
[Key]
public Guid Id { get; set; }
public string DefaultLocale { get; set; } = "de";
/// <summary>Ordered page ids for the public nav (JSON list).</summary>
public List<Guid> NavOrder { get; set; } = new();
}
/// <summary>A CMS page (one of the public website's pages).</summary>
public class Page
{
[Key]
public Guid Id { get; set; }
/// <summary>Stable URL slug (e.g. "abgabetiere") — the human key.</summary>
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<Block> Blocks { get; } = new List<Block>();
}
/// <summary>
/// A content block on a page (single table, discriminated by <see cref="Type"/>).
/// Type-specific payload lives in <see cref="Data"/> as JSON (RichText = Markdown).
/// </summary>
public class Block
{
[Key]
public Guid Id { get; set; }
public Guid PageId { get; set; }
/// <summary>Explicit ordering within the page (AI tools reorder by this).</summary>
public int Order { get; set; }
public BlockType Type { get; set; }
/// <summary>Type-specific fields as a JSON object string.</summary>
public string Data { get; set; } = "{}";
}
/// <summary>A media asset referenced by Image/Gallery blocks.</summary>
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; }
}
}

View File

@@ -76,6 +76,7 @@ app.MapImportEndpoints();
app.MapContractEndpoints();
app.MapSettingsEndpoints();
app.MapExportEndpoints();
app.MapCmsEndpoints();
app.Run();