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.
302 lines
10 KiB
C#
302 lines
10 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Novelly.Api.Chapters;
|
|
using Novelly.Api.Characters;
|
|
using Novelly.Api.Common;
|
|
using Novelly.Api.Common.Validation;
|
|
using Novelly.Api.Data;
|
|
using Novelly.Api.Tags;
|
|
|
|
namespace Novelly.Api.Beats;
|
|
|
|
public class BeatService(
|
|
INovelDbContext db,
|
|
TagService tags,
|
|
ILogger<BeatService> logger,
|
|
IModelValidator<CreateBeatRequest> createValidator,
|
|
IModelValidator<UpdateBeatRequest> updateValidator,
|
|
IModelValidator<ReorderBeatsRequest> reorderValidator,
|
|
IModelValidator<AssignCharacterToBeatsRequest> assignCharacterValidator)
|
|
{
|
|
public async Task<IReadOnlyList<Beat>> ListAsync(Guid chapterId, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(chapterId, nameof(chapterId));
|
|
|
|
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
|
|
|
|
return await Query()
|
|
.Where(b => b.ChapterId == chapterId)
|
|
.OrderBy(b => b.SortOrder)
|
|
.ToListAsync(ct);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<Beat>?> ListForCharacterAsync(
|
|
Guid characterId, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(characterId, nameof(characterId));
|
|
|
|
logger.LogInformation("Listing beats for character {CharacterId}", characterId);
|
|
|
|
if (!await db.Characters.AnyAsync(c => c.Id == characterId, ct))
|
|
{
|
|
logger.LogWarning("Character {CharacterId} not found", characterId);
|
|
return null;
|
|
}
|
|
|
|
var beats = await db.Beats
|
|
.Include(b => b.Chapter)
|
|
.Where(b => b.Characters.Any(c => c.Id == characterId))
|
|
.ToListAsync(ct);
|
|
|
|
return
|
|
[
|
|
.. beats
|
|
.OrderBy(b => b.Chapter?.Number ?? 0)
|
|
.ThenBy(b => b.SortOrder)
|
|
];
|
|
}
|
|
|
|
public async Task<Beat?> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(chapterId, nameof(chapterId));
|
|
Guard.Null(request, nameof(request));
|
|
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.LogWarning("Rejected beat creation: chapter {ChapterId} not found", chapterId);
|
|
return null;
|
|
}
|
|
|
|
var beat = new Beat
|
|
{
|
|
ChapterId = chapterId,
|
|
Title = request.Title,
|
|
SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct),
|
|
WhatHappened = request.WhatHappened,
|
|
WhatsNext = request.WhatsNext
|
|
};
|
|
|
|
if (request.CharacterIds is { } characterIds)
|
|
{
|
|
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct);
|
|
}
|
|
|
|
if (request.Tags is { } names)
|
|
{
|
|
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
|
}
|
|
|
|
db.Beats.Add(beat);
|
|
await db.SaveChangesAsync(ct);
|
|
|
|
return (await FindAsync(beat.Id, ct))!;
|
|
}
|
|
|
|
public async Task<Beat?> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(id, nameof(id));
|
|
Guard.Null(request, nameof(request));
|
|
updateValidator.Validate(request).ThrowIfInvalid(logger);
|
|
|
|
logger.LogInformation("Updating beat {BeatId}", id);
|
|
|
|
var beat = await FindAsync(id, ct);
|
|
if (beat is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
|
|
if (chapter is null)
|
|
{
|
|
logger.LogError("Beat {BeatId} references chapter {ChapterId} which does not exist", id, beat.ChapterId);
|
|
return null;
|
|
}
|
|
|
|
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
|
|
beat.SortOrder = request.SortOrder ?? beat.SortOrder;
|
|
beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened);
|
|
beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext);
|
|
beat.UpdatedAt = DateTimeOffset.UtcNow;
|
|
|
|
if (request.CharacterIds is { } characterIds)
|
|
{
|
|
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct);
|
|
}
|
|
|
|
if (request.Tags is { } names)
|
|
{
|
|
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
|
}
|
|
|
|
await db.SaveChangesAsync(ct);
|
|
return (await FindAsync(id, ct))!;
|
|
}
|
|
|
|
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(id, nameof(id));
|
|
|
|
logger.LogInformation("Deleting beat {BeatId}", id);
|
|
|
|
var beat = await FindAsync(id, ct);
|
|
if (beat is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
db.Beats.Remove(beat);
|
|
await db.SaveChangesAsync(ct);
|
|
return true;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<Beat>?> ReorderAsync(
|
|
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(chapterId, nameof(chapterId));
|
|
Guard.Null(request, nameof(request));
|
|
reorderValidator.Validate(request).ThrowIfInvalid(logger);
|
|
|
|
logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId);
|
|
|
|
var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct);
|
|
|
|
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
|
|
if (missing.Count > 0)
|
|
{
|
|
logger.LogWarning("Reorder for chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]);
|
|
return null;
|
|
}
|
|
|
|
var order = 1;
|
|
foreach (var id in request.BeatIds)
|
|
{
|
|
beats.Single(b => b.Id == id).SortOrder = order++;
|
|
}
|
|
|
|
foreach (var beat in beats.Where(b => !request.BeatIds.Contains(b.Id)).OrderBy(b => b.SortOrder))
|
|
{
|
|
beat.SortOrder = order++;
|
|
}
|
|
|
|
await db.SaveChangesAsync(ct);
|
|
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)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var found = await db.Characters
|
|
.Where(c => c.ProjectId == projectId && distinct.Contains(c.Id))
|
|
.ToListAsync(ct);
|
|
|
|
if (found.Count != distinct.Count)
|
|
{
|
|
logger.LogWarning("Rejected beat reference: one or more characters do not belong to project {ProjectId}", projectId);
|
|
throw new InvalidOperationException(
|
|
"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;
|
|
}
|
|
|
|
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
|
|
{
|
|
logger.LogDebug("Computing next sort order for chapter {ChapterId}", chapterId);
|
|
|
|
var max = await db.Beats
|
|
.Where(b => b.ChapterId == chapterId)
|
|
.MaxAsync(b => (int?)b.SortOrder, ct);
|
|
|
|
var next = (max ?? 0) + 1;
|
|
logger.LogDebug("Next sort order for chapter {ChapterId} is {SortOrder}", chapterId, next);
|
|
return next;
|
|
}
|
|
|
|
private IQueryable<Beat> Query() =>
|
|
db.Beats
|
|
.Include(b => b.Characters)
|
|
.Include(b => b.Tags);
|
|
|
|
private async Task<Beat?> FindAsync(Guid id, CancellationToken ct)
|
|
{
|
|
logger.LogDebug("Finding beat {BeatId}", id);
|
|
|
|
var beat = await Query().FirstOrDefaultAsync(b => b.Id == id, ct);
|
|
if (beat is null)
|
|
{
|
|
logger.LogWarning("Beat {BeatId} not found", id);
|
|
return beat;
|
|
}
|
|
|
|
logger.LogDebug("Found beat {BeatId}", id);
|
|
return beat;
|
|
}
|
|
}
|