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())); // ---- static render dry-run (WEB-1: returns rendered file map as JSON) ---- api.MapGet("/render-site", async (ApplicationContext db) => { var snapshot = await new SiteSnapshotService(db).BuildAsync(); var files = SiteRenderer.Render(snapshot); return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList()); }); // ---- 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()); } }