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.
This commit is contained in:
153
GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs
Normal file
153
GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user