Files
novelly/src/Novelly.Api/Chapters/ChapterService.cs
T
James Wampler 4313c8f206 Rename Project concept to Novel across the stack
Renames the domain concept from Project to Novel throughout the backend
(entities, DTOs, services, endpoints, ProjectAccessService/Permission,
ProjectId foreign keys), MCP server (tool names and routes), and the
React/Vite frontend (types, hooks, routes, components). Adds a new EF
Core migration (RenameProjectToNovel) using RenameTable/RenameColumn to
preserve existing data instead of dropping/recreating tables. Updates
CLAUDE.md's structure section to reference Novels/ instead of Projects/.
2026-08-17 23:03:09 -07:00

184 lines
5.9 KiB
C#

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<ChapterService> logger,
IModelValidator<CreateChapterRequest> createValidator,
IModelValidator<UpdateChapterRequest> updateValidator)
{
public async Task<IReadOnlyList<Chapter>> 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<Chapter?> 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<Chapter?> 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<Chapter?> 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<bool> 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<int> 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<Chapter?> 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;
}
}