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
+12 -25
View File
@@ -20,27 +20,25 @@ public class BeatService(
IModelValidator<UpdateBeatRequest> updateValidator,
IModelValidator<ReorderBeatsRequest> reorderValidator)
{
public async Task<IReadOnlyList<BeatDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
public async Task<IReadOnlyList<Beat>> ListAsync(Guid chapterId, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
var beats = await Query()
return await Query()
.Where(b => b.ChapterId == chapterId)
.OrderBy(b => b.SortOrder)
.ToListAsync(ct);
return [.. beats.Select(b => b.ToDto())];
}
/// <summary>Null when no beat has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<BeatDto?> GetAsync(Guid id, CancellationToken ct = default)
public async Task<Beat?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting beat {BeatId}", id);
return (await FindAsync(id, ct))?.ToDto();
return await FindAsync(id, ct);
}
/// <summary>
@@ -49,7 +47,7 @@ public class BeatService(
/// straight to the beat in that chapter's outline. Null when no character has this id;
/// an empty list means the character exists but has no beats yet.
/// </summary>
public async Task<IReadOnlyList<CharacterBeatDto>?> ListForCharacterAsync(
public async Task<IReadOnlyList<Beat>?> ListForCharacterAsync(
Guid characterId, CancellationToken ct = default)
{
Guard.Default(characterId, nameof(characterId));
@@ -73,21 +71,10 @@ public class BeatService(
.. beats
.OrderBy(b => b.Chapter?.Number ?? 0)
.ThenBy(b => b.SortOrder)
.Select(b => new CharacterBeatDto(
b.Id,
b.ChapterId,
b.Chapter?.Number ?? 0,
b.Chapter?.Title ?? "(unknown chapter)",
b.SortOrder,
b.Title,
b.WhatHappened,
b.WhatsNext,
b.SceneId,
b.Scene?.Title))
];
}
public async Task<BeatDto> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
public async Task<Beat> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));
Guard.Null(request, nameof(request));
@@ -124,10 +111,10 @@ public class BeatService(
await db.SaveChangesAsync(ct);
// Just created it — the reload is only to pick up includes, not to check existence.
return (await FindAsync(beat.Id, ct))!.ToDto();
return (await FindAsync(beat.Id, ct))!;
}
public async Task<BeatDto?> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default)
public async Task<Beat?> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
@@ -154,10 +141,10 @@ public class BeatService(
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
beat.SortOrder = request.SortOrder ?? beat.SortOrder;
beat.CharacterId = request.CharacterId ?? beat.CharacterId;
beat.CharacterId = request.ClearCharacter ? null : request.CharacterId ?? beat.CharacterId;
beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened);
beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext);
beat.SceneId = request.SceneId ?? beat.SceneId;
beat.SceneId = request.ClearScene ? null : request.SceneId ?? beat.SceneId;
beat.UpdatedAt = DateTimeOffset.UtcNow;
if (request.Tags is { } names)
@@ -166,7 +153,7 @@ public class BeatService(
}
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!.ToDto();
return (await FindAsync(id, ct))!;
}
/// <summary>True if a beat was deleted; false if no beat had this id.</summary>
@@ -191,7 +178,7 @@ public class BeatService(
/// Renumbers a chapter's beats to match the order given. Sending the whole list beats
/// patching sort orders one at a time, which is fiddly to get right from a drag handle.
/// </summary>
public async Task<IReadOnlyList<BeatDto>> ReorderAsync(
public async Task<IReadOnlyList<Beat>> ReorderAsync(
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));