Add batch character assignment for chapter outline beats
Lets a writer select several beats and add one character to all of them at once, without disturbing each beat's existing characters. Surfaced through the REST API, the embedded agent, and the MCP server per the project's rule that all three share the same service methods.
This commit is contained in:
@@ -14,7 +14,8 @@ public class BeatService(
|
||||
ILogger<BeatService> logger,
|
||||
IModelValidator<CreateBeatRequest> createValidator,
|
||||
IModelValidator<UpdateBeatRequest> updateValidator,
|
||||
IModelValidator<ReorderBeatsRequest> reorderValidator)
|
||||
IModelValidator<ReorderBeatsRequest> reorderValidator,
|
||||
IModelValidator<AssignCharacterToBeatsRequest> assignCharacterValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<Beat>> ListAsync(Guid chapterId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -45,7 +46,7 @@ public class BeatService(
|
||||
|
||||
if (!await db.Characters.AnyAsync(c => c.Id == characterId, ct))
|
||||
{
|
||||
logger.LogInformation("Character {CharacterId} not found", characterId);
|
||||
logger.LogWarning("Character {CharacterId} not found", characterId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -66,14 +67,14 @@ public class BeatService(
|
||||
{
|
||||
Guard.Default(chapterId, nameof(chapterId));
|
||||
Guard.Null(request, nameof(request));
|
||||
createValidator.Validate(request).ThrowIfInvalid();
|
||||
createValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
|
||||
logger.LogInformation("Creating beat {Title} for chapter {ChapterId}", request.Title, chapterId);
|
||||
|
||||
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
|
||||
if (chapter is null)
|
||||
{
|
||||
logger.LogInformation("Rejected beat creation: chapter {ChapterId} not found", chapterId);
|
||||
logger.LogWarning("Rejected beat creation: chapter {ChapterId} not found", chapterId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -106,7 +107,7 @@ public class BeatService(
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
Guard.Null(request, nameof(request));
|
||||
updateValidator.Validate(request).ThrowIfInvalid();
|
||||
updateValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
|
||||
logger.LogInformation("Updating beat {BeatId}", id);
|
||||
|
||||
@@ -165,7 +166,7 @@ public class BeatService(
|
||||
{
|
||||
Guard.Default(chapterId, nameof(chapterId));
|
||||
Guard.Null(request, nameof(request));
|
||||
reorderValidator.Validate(request).ThrowIfInvalid();
|
||||
reorderValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
|
||||
logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId);
|
||||
|
||||
@@ -174,7 +175,7 @@ public class BeatService(
|
||||
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
logger.LogInformation("Reorder for chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]);
|
||||
logger.LogWarning("Reorder for chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -193,8 +194,57 @@ public class BeatService(
|
||||
return await ListAsync(chapterId, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Beat>?> AssignCharacterAsync(
|
||||
Guid chapterId, AssignCharacterToBeatsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(chapterId, nameof(chapterId));
|
||||
Guard.Null(request, nameof(request));
|
||||
assignCharacterValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
|
||||
logger.LogInformation(
|
||||
"Assigning character {CharacterId} to {Count} beats in chapter {ChapterId}",
|
||||
request.CharacterId, request.BeatIds.Count, chapterId);
|
||||
|
||||
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
|
||||
if (chapter is null)
|
||||
{
|
||||
logger.LogWarning("Rejected character assignment: chapter {ChapterId} not found", chapterId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var character = await db.Characters
|
||||
.FirstOrDefaultAsync(c => c.Id == request.CharacterId && c.ProjectId == chapter.ProjectId, ct);
|
||||
if (character is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rejected character assignment: character {CharacterId} not found in project {ProjectId}",
|
||||
request.CharacterId, chapter.ProjectId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var beats = await Query().Where(b => b.ChapterId == chapterId && request.BeatIds.Contains(b.Id)).ToListAsync(ct);
|
||||
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rejected character assignment: chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]);
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var beat in beats.Where(b => b.Characters.All(c => c.Id != character.Id)))
|
||||
{
|
||||
beat.Characters.Add(character);
|
||||
beat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return await ListAsync(chapterId, ct);
|
||||
}
|
||||
|
||||
private async Task<List<Character>> ResolveCharactersAsync(Guid projectId, IReadOnlyList<Guid> characterIds, CancellationToken ct)
|
||||
{
|
||||
logger.LogDebug("Resolving {Count} characters for project {ProjectId}", characterIds.Count, projectId);
|
||||
|
||||
var distinct = characterIds.Distinct().ToList();
|
||||
if (distinct.Count == 0)
|
||||
{
|
||||
@@ -212,6 +262,7 @@ public class BeatService(
|
||||
"A beat's characters must belong to the same project as its chapter.");
|
||||
}
|
||||
|
||||
logger.LogDebug("Resolved {Count} characters for project {ProjectId}", found.Count, projectId);
|
||||
return found;
|
||||
}
|
||||
|
||||
@@ -223,7 +274,9 @@ public class BeatService(
|
||||
.Where(b => b.ChapterId == chapterId)
|
||||
.MaxAsync(b => (int?)b.SortOrder, ct);
|
||||
|
||||
return (max ?? 0) + 1;
|
||||
var next = (max ?? 0) + 1;
|
||||
logger.LogDebug("Next sort order for chapter {ChapterId} is {SortOrder}", chapterId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
private IQueryable<Beat> Query() =>
|
||||
@@ -238,13 +291,11 @@ public class BeatService(
|
||||
var beat = await Query().FirstOrDefaultAsync(b => b.Id == id, ct);
|
||||
if (beat is null)
|
||||
{
|
||||
logger.LogInformation("Beat {BeatId} not found", id);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug("Found beat {BeatId}", id);
|
||||
logger.LogWarning("Beat {BeatId} not found", id);
|
||||
return beat;
|
||||
}
|
||||
|
||||
logger.LogDebug("Found beat {BeatId}", id);
|
||||
return beat;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user