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 logger, IModelValidator createValidator, IModelValidator updateValidator, IModelValidator reorderValidator, IModelValidator assignCharacterValidator) { public async Task> 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 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?> 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 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 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 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?> 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?> 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> ResolveCharactersAsync(Guid projectId, IReadOnlyList 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 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 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; } }