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/.
134 lines
4.2 KiB
C#
134 lines
4.2 KiB
C#
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<NovelService> logger,
|
|
IModelValidator<CreateNovelRequest> createValidator,
|
|
IModelValidator<UpdateNovelRequest> updateValidator)
|
|
{
|
|
public async Task<IReadOnlyList<NovelSummaryResponse>> 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<Novel?> 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<Novel> 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<Novel?> 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<bool> 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<Novel?> 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;
|
|
}
|
|
}
|