using Microsoft.EntityFrameworkCore; using Novelly.Api.Common; using Novelly.Api.Common.Validation; using Novelly.Api.Data; using Novelly.Api.Tags; using Novelly.Api.Users; namespace Novelly.Api.Chapters; public class ChapterService( INovelDbContext db, NovelAccessService access, TagService tags, 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) .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), Summary = request.Summary, Setting = request.Setting, 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); } db.Chapters.Add(chapter); 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.Summary = Patch.Apply(chapter.Summary, request.Summary); chapter.Setting = Patch.Apply(chapter.Setting, request.Setting); chapter.Notes = Patch.Apply(chapter.Notes, request.Notes); chapter.Status = request.Status ?? chapter.Status; chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount; 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); } 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); await db.SaveChangesAsync(ct); return true; } 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) .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; } }