Files
novelly/src/Novelly.Api/Beats/BeatEndpoints.cs
T
James Wampler 17facba3b9 Group character beats into arc-stage sections; reciprocal relationship types
Arc stages now group the beats that establish or pay off that stage of a
character's arc (many-to-many via ArcStageBeats), and carry a Result field
(renamed from Description) describing what the stage results in for the
character. Assigning a beat to a stage moves it out of any other stage of
the same character. New endpoint POST /api/arc-stages/{id}/beats, MCP tool
set_arc_stage_beats, and frontend grouping UI in CharacterArc/CharacterBeats.

Also records a relationship's reciprocal type so both characters' dossiers
show the correct direction (e.g. "sister" / "brother") instead of mirroring
the same label.
2026-08-17 22:45:07 -07:00

73 lines
3.8 KiB
C#

using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Beats;
public static class BeatEndpoints
{
public static IEndpointRouteBuilder MapBeatEndpoints(this IEndpointRouteBuilder app)
{
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/beats").WithTags("Beats")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
chapterScoped.MapGet("/", async (Guid chapterId, BeatService service, CancellationToken ct) =>
Results.Ok((await service.ListAsync(chapterId, ct)).Select(b => b.ToResponse())))
.WithSummary("Read a chapter's outline: its beats, in order.");
chapterScoped.MapPost("/", async (
Guid chapterId, CreateBeatRequest request, BeatService service, CancellationToken ct) =>
{
var beat = await service.CreateAsync(chapterId, request, ct);
if (beat is null)
{
return Results.NotFound();
}
var created = beat.ToResponse();
return Results.Created($"/api/beats/{created.Id}", created);
})
.WithSummary("Add a beat to a chapter's outline.");
chapterScoped.MapPost("/reorder", async (
Guid chapterId, ReorderBeatsRequest request, BeatService service, CancellationToken ct) =>
(await service.ReorderAsync(chapterId, request, ct))?.Select(b => b.ToResponse()).ToList().ToApiResult())
.WithSummary("Renumber a chapter's beats to match the order given.");
chapterScoped.MapPost("/assign-character", async (
Guid chapterId, AssignCharacterToBeatsRequest request, BeatService service, CancellationToken ct) =>
(await service.AssignCharacterAsync(chapterId, request, ct))?.Select(b => b.ToResponse()).ToList().ToApiResult())
.WithSummary("Add a character to several beats at once, leaving each beat's existing characters alone.");
chapterScoped.MapPost("/move", async (
Guid chapterId, MoveBeatsRequest request, BeatService service, CancellationToken ct) =>
(await service.MoveAsync(chapterId, request, ct))?.Select(b => b.ToResponse()).ToList().ToApiResult())
.WithSummary("Move one or more beats to another chapter, appending them to its end.");
app.MapGet("/api/characters/{characterId:guid}/beats", async (
Guid characterId, BeatService service, CancellationToken ct) =>
(await service.ListForCharacterAsync(characterId, ct))?.Select(b => b.ToCharacterBeatResponse(characterId)).ToList().ToApiResult())
.WithTags("Beats")
.WithSummary("Every beat this character appears in, in manuscript order.");
var beats = app.MapGroup("/api/beats").WithTags("Beats")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
beats.MapGet("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) =>
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult())
.WithSummary("Read one beat.");
beats.MapPatch("/{id:guid}", async (
Guid id, UpdateBeatRequest request, BeatService service, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Update a beat. Sending a tag list replaces the beat's tags.");
beats.MapDelete("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a beat.");
return app;
}
}