Replace chapter setting with multi-select locations; add chapter character summary

Chapters now carry many Locations (new Tags-style entity with cross-referencing)
instead of a single free-text Setting field, with a Locations tab on the novel
for browsing them and seeing every chapter set at each one. Also surfaces the
distinct characters appearing in a chapter's beats, linked, under the beat/word
count on the outline tab.
This commit is contained in:
James Wampler
2026-08-18 11:36:13 -07:00
parent 4313c8f206
commit c620ddd626
27 changed files with 2380 additions and 44 deletions
@@ -0,0 +1,51 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Locations;
public static class LocationEndpoints
{
public static IEndpointRouteBuilder MapLocationEndpoints(this IEndpointRouteBuilder app)
{
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/locations").WithTags("Locations")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
novelScoped.MapGet("/", async (Guid novelId, LocationService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(novelId, ct)))
.WithSummary("List a novel's locations with usage counts.");
novelScoped.MapPost("/", async (
Guid novelId, CreateLocationRequest request, LocationService service, CancellationToken ct) =>
{
var location = await service.CreateAsync(novelId, request, ct);
if (location is null)
{
return Results.NotFound();
}
var created = location.ToResponse();
return Results.Created($"/api/locations/{created.Id}", created);
})
.WithSummary("Create a location. Locations are also created on demand when applied by name.");
var locations = app.MapGroup("/api/locations").WithTags("Locations")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
locations.MapGet("/{id:guid}/references", async (Guid id, LocationService service, CancellationToken ct) =>
(await service.GetReferencesAsync(id, ct))?.ToReferencesResponse().ToApiResult())
.WithSummary("Cross-reference: every chapter set at this location.");
locations.MapPatch("/{id:guid}", async (
Guid id, UpdateLocationRequest request, LocationService service, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Rename a location.");
locations.MapDelete("/{id:guid}", async (Guid id, LocationService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a location. Whatever carried it is left alone.");
return app;
}
}