Add outline import feature; drop Dto naming, map entities at the API boundary

Services now return entities; endpoints (and the agent toolsets) map to
*Response records instead of services building wire DTOs themselves.
Also brings in the outline-import agent, MCP tool, ledger and web dialog
that were already in progress on disk.
This commit is contained in:
James Wampler
2026-08-06 18:36:40 -07:00
parent 40f93e40a8
commit 189ebf3237
66 changed files with 3310 additions and 364 deletions
@@ -6,7 +6,7 @@ using Novelly.Api.Tags;
namespace Novelly.Api.Chapters;
public record ChapterSummaryDto(
public record ChapterSummaryResponse(
Guid Id,
Guid ProjectId,
int Number,
@@ -20,13 +20,13 @@ public record ChapterSummaryDto(
int BeatCount,
int SceneCount,
int WordCount,
IReadOnlyList<TagDto> Tags);
IReadOnlyList<TagResponse> Tags);
/// <summary>
/// A chapter in full: the outline (a paragraph of summary plus an ordered beat table)
/// and the prose layer (scenes).
/// </summary>
public record ChapterDto(
public record ChapterResponse(
Guid Id,
Guid ProjectId,
int Number,
@@ -38,9 +38,9 @@ public record ChapterDto(
string? Notes,
DraftStatus Status,
int? TargetWordCount,
IReadOnlyList<BeatDto> Beats,
IReadOnlyList<SceneDto> Scenes,
IReadOnlyList<TagDto> Tags,
IReadOnlyList<BeatResponse> Beats,
IReadOnlyList<SceneResponse> Scenes,
IReadOnlyList<TagResponse> Tags,
DateTimeOffset UpdatedAt);
public record CreateChapterRequest(
@@ -133,18 +133,18 @@ file static class ChapterValidation
public static class ChapterMapping
{
public static ChapterDto ToDto(this Chapter c) => new(
public static ChapterResponse ToResponse(this Chapter c) => new(
c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Notes,
c.Status, c.TargetWordCount,
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToDto())],
[.. c.Scenes.OrderBy(s => s.SortOrder).Select(s => s.ToDto())],
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())],
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
[.. c.Scenes.OrderBy(s => s.SortOrder).Select(s => s.ToResponse())],
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
c.UpdatedAt);
public static ChapterSummaryDto ToSummaryDto(this Chapter c) => new(
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount,
c.Beats.Count, c.Scenes.Count, c.Scenes.Sum(s => s.WordCount),
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())]);
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())]);
}
+4 -4
View File
@@ -12,13 +12,13 @@ public static class ChapterEndpoints
.AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(projectId, ct)))
Results.Ok((await service.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse())))
.WithSummary("List a project's chapters in manuscript order.");
projectScoped.MapPost("/", async (
Guid projectId, CreateChapterRequest request, ChapterService service, CancellationToken ct) =>
{
var created = await service.CreateAsync(projectId, request, ct);
var created = (await service.CreateAsync(projectId, request, ct)).ToResponse();
return Results.Created($"/api/chapters/{created.Id}", created);
})
.WithSummary("Add a chapter.");
@@ -28,12 +28,12 @@ public static class ChapterEndpoints
.AddEndpointFilter<ValidationEndpointFilter>();
chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
(await service.GetAsync(id, ct)).ToApiResult())
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult())
.WithSummary("Read a chapter with all of its scenes.");
chapters.MapPatch("/{id:guid}", async (
Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct)).ToApiResult())
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Update a chapter.");
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
+8 -10
View File
@@ -14,13 +14,13 @@ public class ChapterService(
IModelValidator<CreateChapterRequest> createValidator,
IModelValidator<UpdateChapterRequest> updateValidator)
{
public async Task<IReadOnlyList<ChapterSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default)
public async Task<IReadOnlyList<Chapter>> ListAsync(Guid projectId, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
logger.LogInformation("Listing chapters for project {ProjectId}", projectId);
var chapters = await db.Chapters
return await db.Chapters
.Include(c => c.PovCharacter)
.Include(c => c.Beats)
.Include(c => c.Scenes)
@@ -28,20 +28,18 @@ public class ChapterService(
.Where(c => c.ProjectId == projectId)
.OrderBy(c => c.Number)
.ToListAsync(ct);
return [.. chapters.Select(c => c.ToSummaryDto())];
}
/// <summary>Null when no chapter has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<ChapterDto?> GetAsync(Guid id, CancellationToken ct = default)
public async Task<Chapter?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting chapter {ChapterId}", id);
return (await FindAsync(id, ct))?.ToDto();
return await FindAsync(id, ct);
}
public async Task<ChapterDto> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default)
public async Task<Chapter> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request));
@@ -77,10 +75,10 @@ public class ChapterService(
await db.SaveChangesAsync(ct);
// Just created it — the reload is only to pick up includes, not to check existence.
return (await FindAsync(chapter.Id, ct))!.ToDto();
return (await FindAsync(chapter.Id, ct))!;
}
public async Task<ChapterDto?> UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default)
public async Task<Chapter?> UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
@@ -110,7 +108,7 @@ public class ChapterService(
}
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!.ToDto();
return (await FindAsync(id, ct))!;
}
/// <summary>True if a chapter was deleted; false if no chapter had this id.</summary>