using Microsoft.EntityFrameworkCore; using Novelly.Api.Common; using Novelly.Api.Common.Validation; using Novelly.Api.Data; using Novelly.Api.Users; namespace Novelly.Api.Novels; public class NovelService( INovelDbContext db, NovelAccessService access, INovelUserContext userContext, ILogger logger, IModelValidator createValidator, IModelValidator updateValidator) { public async Task> ListAsync(CancellationToken ct = default) { logger.LogInformation("Listing novels"); return await access.VisibleNovels() .OrderByDescending(p => p.UpdatedAt) .Select(p => new NovelSummaryResponse( p.Id, p.Title, p.Author, p.Genre, p.Logline, p.TargetWordCount, p.Phase, p.Characters.Count, p.Chapters.Count, p.Chapters.Sum(c => (int?)c.WordCount) ?? 0, p.UpdatedAt)) .ToListAsync(ct); } public async Task GetAsync(Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); logger.LogInformation("Getting novel {NovelId}", id); var novel = await FindAsync(id, ct); if (novel is null) return null; await access.RequireAsync(id, NovelPermission.Read, ct); return novel; } public async Task CreateAsync(CreateNovelRequest request, CancellationToken ct = default) { Guard.Null(request, nameof(request)); createValidator.Validate(request).ThrowIfInvalid(logger); access.RequireCanCreateNovel(); logger.LogInformation("Creating novel {Title}", request.Title); var novel = new Novel { Title = request.Title, Author = request.Author, Genre = request.Genre, Logline = request.Logline, Synopsis = request.Synopsis, Notes = request.Notes, TargetWordCount = request.TargetWordCount, OwnerId = userContext.UserId }; db.Novels.Add(novel); await db.SaveChangesAsync(ct); return novel; } public async Task UpdateAsync(Guid id, UpdateNovelRequest request, CancellationToken ct = default) { Guard.Default(id, nameof(id)); Guard.Null(request, nameof(request)); updateValidator.Validate(request).ThrowIfInvalid(logger); logger.LogInformation("Updating novel {NovelId}", id); var novel = await FindAsync(id, ct); if (novel is null) return null; await access.RequireAsync(id, NovelPermission.Write, ct); novel.Title = Patch.Apply(novel.Title, request.Title) ?? novel.Title; novel.Author = Patch.Apply(novel.Author, request.Author); novel.Genre = Patch.Apply(novel.Genre, request.Genre); novel.Logline = Patch.Apply(novel.Logline, request.Logline); novel.Synopsis = Patch.Apply(novel.Synopsis, request.Synopsis); novel.Notes = Patch.Apply(novel.Notes, request.Notes); novel.TargetWordCount = request.TargetWordCount ?? novel.TargetWordCount; novel.Phase = request.Phase ?? novel.Phase; novel.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(ct); return novel; } public async Task DeleteAsync(Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); logger.LogInformation("Deleting novel {NovelId}", id); var novel = await FindAsync(id, ct); if (novel is null) return false; await access.RequireAsync(id, NovelPermission.DeleteContent, ct); db.Novels.Remove(novel); await db.SaveChangesAsync(ct); return true; } private async Task FindAsync(Guid id, CancellationToken ct) { logger.LogDebug("Finding novel {NovelId}", id); var novel = await db.Novels.FirstOrDefaultAsync(p => p.Id == id, ct); if (novel is null) { logger.LogWarning("Novel {NovelId} not found", id); return novel; } logger.LogDebug("Found novel {NovelId}", id); return novel; } }