Files
novelly/src/Novelly.Api/Beats/BeatService.cs
T
James Wampler 4313c8f206 Rename Project concept to Novel across the stack
Renames the domain concept from Project to Novel throughout the backend
(entities, DTOs, services, endpoints, ProjectAccessService/Permission,
ProjectId foreign keys), MCP server (tool names and routes), and the
React/Vite frontend (types, hooks, routes, components). Adds a new EF
Core migration (RenameProjectToNovel) using RenameTable/RenameColumn to
preserve existing data instead of dropping/recreating tables. Updates
CLAUDE.md's structure section to reference Novels/ instead of Projects/.
2026-08-17 23:03:09 -07:00

408 lines
14 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;
using Novelly.Api.Users;
namespace Novelly.Api.Beats;
public class BeatService(
INovelDbContext db,
NovelAccessService access,
TagService tags,
ILogger<BeatService> logger,
IModelValidator<CreateBeatRequest> createValidator,
IModelValidator<UpdateBeatRequest> updateValidator,
IModelValidator<ReorderBeatsRequest> reorderValidator,
IModelValidator<AssignCharacterToBeatsRequest> assignCharacterValidator,
IModelValidator<MoveBeatsRequest> moveValidator)
{
public async Task<IReadOnlyList<Beat>> ListAsync(Guid chapterId, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
await RequireChapterAccessAsync(chapterId, NovelPermission.Read, ct);
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);
var beat = await FindAsync(id, ct);
if (beat is null)
{
return null;
}
await RequireBeatAccessAsync(beat, NovelPermission.Read, ct);
return beat;
}
public async Task<IReadOnlyList<Beat>?> ListForCharacterAsync(
Guid characterId, CancellationToken ct = default)
{
Guard.Default(characterId, nameof(characterId));
logger.LogInformation("Listing beats for character {CharacterId}", characterId);
var characterNovelId = await db.Characters.Where(c => c.Id == characterId).Select(c => (Guid?)c.NovelId).FirstOrDefaultAsync(ct);
if (characterNovelId is null)
{
logger.LogWarning("Character {CharacterId} not found", characterId);
return null;
}
await access.RequireAsync(characterNovelId.Value, NovelPermission.Read, ct);
var beats = await db.Beats
.Include(b => b.Chapter)
.Include(b => b.ArcStages)
.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;
}
await access.RequireAsync(chapter.NovelId, NovelPermission.CreateContent, ct);
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.NovelId, characterIds, ct);
}
if (request.Tags is { } names)
{
beat.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
}
chapter.UpdatedAt = DateTimeOffset.UtcNow;
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;
}
await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
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;
chapter.UpdatedAt = beat.UpdatedAt;
if (request.CharacterIds is { } characterIds)
{
beat.Characters = await ResolveCharactersAsync(chapter.NovelId, characterIds, ct);
}
if (request.Tags is { } names)
{
beat.Tags = await tags.ResolveAsync(chapter.NovelId, 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;
}
await RequireBeatAccessAsync(beat, NovelPermission.DeleteContent, ct);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
if (chapter is not null) chapter.UpdatedAt = DateTimeOffset.UtcNow;
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);
await RequireChapterAccessAsync(chapterId, NovelPermission.Write, ct);
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++;
}
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
if (chapter is not null) chapter.UpdatedAt = DateTimeOffset.UtcNow;
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;
}
await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
var character = await db.Characters
.FirstOrDefaultAsync(c => c.Id == request.CharacterId && c.NovelId == chapter.NovelId, ct);
if (character is null)
{
logger.LogWarning(
"Rejected character assignment: character {CharacterId} not found in novel {NovelId}",
request.CharacterId, chapter.NovelId);
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;
chapter.UpdatedAt = beat.UpdatedAt;
}
await db.SaveChangesAsync(ct);
return await ListAsync(chapterId, ct);
}
public async Task<IReadOnlyList<Beat>?> MoveAsync(
Guid chapterId, MoveBeatsRequest request, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));
Guard.Null(request, nameof(request));
moveValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation(
"Moving {Count} beats from chapter {ChapterId} to chapter {TargetChapterId}",
request.BeatIds.Count, chapterId, request.TargetChapterId);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
if (chapter is null)
{
logger.LogWarning("Rejected beat move: chapter {ChapterId} not found", chapterId);
return null;
}
var targetChapter = await db.Chapters.FirstOrDefaultAsync(
c => c.Id == request.TargetChapterId && c.NovelId == chapter.NovelId, ct);
if (targetChapter is null)
{
logger.LogWarning(
"Rejected beat move: target chapter {TargetChapterId} not found in novel {NovelId}",
request.TargetChapterId, chapter.NovelId);
return null;
}
await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
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 beat move: chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]);
return null;
}
if (chapterId == request.TargetChapterId)
{
return beats;
}
var nextSortOrder = await NextSortOrderAsync(request.TargetChapterId, ct);
var now = DateTimeOffset.UtcNow;
foreach (var beatId in request.BeatIds)
{
var beat = beats.Single(b => b.Id == beatId);
beat.ChapterId = request.TargetChapterId;
beat.SortOrder = nextSortOrder++;
beat.UpdatedAt = now;
}
chapter.UpdatedAt = now;
targetChapter.UpdatedAt = now;
await db.SaveChangesAsync(ct);
return beats;
}
private async Task<List<Character>> ResolveCharactersAsync(Guid novelId, IReadOnlyList<Guid> characterIds, CancellationToken ct)
{
logger.LogDebug("Resolving {Count} characters for novel {NovelId}", characterIds.Count, novelId);
var distinct = characterIds.Distinct().ToList();
if (distinct.Count == 0)
{
return [];
}
var found = await db.Characters
.Where(c => c.NovelId == novelId && distinct.Contains(c.Id))
.ToListAsync(ct);
if (found.Count != distinct.Count)
{
logger.LogWarning("Rejected beat reference: one or more characters do not belong to novel {NovelId}", novelId);
throw new InvalidOperationException(
"A beat's characters must belong to the same novel as its chapter.");
}
logger.LogDebug("Resolved {Count} characters for novel {NovelId}", found.Count, novelId);
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 async Task RequireChapterAccessAsync(Guid chapterId, NovelPermission permission, CancellationToken ct)
{
var novelId = await db.Chapters.Where(c => c.Id == chapterId).Select(c => c.NovelId).FirstOrDefaultAsync(ct);
await access.RequireAsync(novelId, permission, ct);
}
private Task RequireBeatAccessAsync(Beat beat, NovelPermission permission, CancellationToken ct) =>
RequireChapterAccessAsync(beat.ChapterId, permission, ct);
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;
}
}