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 logger, IModelValidator createValidator, IModelValidator updateValidator, IModelValidator reorderValidator, IModelValidator assignCharacterValidator, IModelValidator moveValidator) { public async Task> 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 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?> 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 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 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 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?> 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?> 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?> 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> ResolveCharactersAsync(Guid novelId, IReadOnlyList 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 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 Query() => db.Beats .Include(b => b.Characters) .Include(b => b.Tags); private async Task 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; } }