using Microsoft.EntityFrameworkCore; using Novelly.Api.Activity; using Novelly.Api.Common; using Novelly.Api.Common.Validation; using Novelly.Api.Data; using Novelly.Api.Locations; using Novelly.Api.Tags; using Novelly.Api.Users; namespace Novelly.Api.Chapters; public class ChapterService( INovelDbContext db, NovelAccessService access, TagService tags, LocationService locations, ActivityLog activity, ILogger logger, IModelValidator createValidator, IModelValidator updateValidator) { public async Task> ListAsync(Guid novelId, CancellationToken ct = default) { Guard.Default(novelId, nameof(novelId)); logger.LogInformation("Listing chapters for novel {NovelId}", novelId); await access.RequireAsync(novelId, NovelPermission.Read, ct); return await db.Chapters .Include(c => c.Beats) .Include(c => c.Tags) .Include(c => c.Locations) .Where(c => c.NovelId == novelId) .OrderBy(c => c.Number) .ToListAsync(ct); } public async Task GetAsync(Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); logger.LogInformation("Getting chapter {ChapterId}", id); var chapter = await FindAsync(id, ct); if (chapter is null) { return null; } await access.RequireAsync(chapter.NovelId, NovelPermission.Read, ct); return chapter; } public async Task CreateAsync(Guid novelId, CreateChapterRequest request, CancellationToken ct = default) { Guard.Default(novelId, nameof(novelId)); Guard.Null(request, nameof(request)); createValidator.Validate(request).ThrowIfInvalid(logger); logger.LogInformation("Creating chapter {Title} for novel {NovelId}", request.Title, novelId); if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct)) { logger.LogWarning("Rejected chapter creation: novel {NovelId} not found", novelId); return null; } await access.RequireAsync(novelId, NovelPermission.CreateContent, ct); var chapter = new Chapter { NovelId = novelId, Title = request.Title, Number = request.Number ?? await NextChapterNumberAsync(novelId, ct), Kind = request.Kind, Summary = request.Summary, Notes = request.Notes, Status = request.Status, TargetWordCount = request.TargetWordCount, Prose = request.Prose, WordCount = ChapterMapping.CountWords(request.Prose) }; if (request.Tags is { } names) { chapter.Tags = await tags.ResolveAsync(novelId, names, ct); } if (request.Locations is { } locationNames) { chapter.Locations = await locations.ResolveAsync(novelId, locationNames, ct); } db.Chapters.Add(chapter); activity.Record(novelId, ActivityEntityKind.Chapter, ActivityAction.Created, chapter.Id, chapter.WordCount); await db.SaveChangesAsync(ct); return (await FindAsync(chapter.Id, ct))!; } public async Task UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default) { Guard.Default(id, nameof(id)); Guard.Null(request, nameof(request)); updateValidator.Validate(request).ThrowIfInvalid(logger); logger.LogInformation("Updating chapter {ChapterId}", id); var chapter = await FindAsync(id, ct); if (chapter is null) { return null; } await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct); chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title; chapter.Number = request.Number ?? chapter.Number; chapter.Kind = request.Kind ?? chapter.Kind; chapter.Summary = Patch.Apply(chapter.Summary, request.Summary); chapter.Notes = Patch.Apply(chapter.Notes, request.Notes); chapter.Status = request.Status ?? chapter.Status; chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount; var wordCountBeforeEdit = chapter.WordCount; if (request.Prose is not null) { chapter.Prose = Patch.Apply(chapter.Prose, request.Prose); chapter.WordCount = ChapterMapping.CountWords(chapter.Prose); } chapter.UpdatedAt = DateTimeOffset.UtcNow; if (request.Tags is { } names) { chapter.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct); } if (request.Locations is { } locationNames) { chapter.Locations = await locations.ResolveAsync(chapter.NovelId, locationNames, ct); } activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Updated, chapter.Id, chapter.WordCount - wordCountBeforeEdit); 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 chapter {ChapterId}", id); var chapter = await FindAsync(id, ct); if (chapter is null) { return false; } await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct); db.Chapters.Remove(chapter); activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Deleted, chapter.Id, -chapter.WordCount); await db.SaveChangesAsync(ct); return true; } public async Task DisplayNumberAsync(Chapter chapter, CancellationToken ct = default) { if (chapter.Kind != ChapterKind.Body) return null; return await db.Chapters.CountAsync( c => c.NovelId == chapter.NovelId && c.Kind == ChapterKind.Body && c.Number <= chapter.Number, ct); } private async Task NextChapterNumberAsync(Guid novelId, CancellationToken ct) { logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId); var max = await db.Chapters .Where(c => c.NovelId == novelId) .MaxAsync(c => (int?)c.Number, ct); var next = (max ?? 0) + 1; logger.LogDebug("Next chapter number for novel {NovelId} is {Number}", novelId, next); return next; } private async Task FindAsync(Guid id, CancellationToken ct) { logger.LogDebug("Finding chapter {ChapterId}", id); var chapter = await db.Chapters .Include(c => c.Beats).ThenInclude(b => b.Characters) .Include(c => c.Beats).ThenInclude(b => b.Tags) .Include(c => c.Tags) .Include(c => c.Locations) .FirstOrDefaultAsync(c => c.Id == id, ct); if (chapter is null) { logger.LogWarning("Chapter {ChapterId} not found", id); return chapter; } logger.LogDebug("Found chapter {ChapterId}", id); return chapter; } }