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/.
This commit is contained in:
@@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Chapters;
|
||||
@@ -10,8 +10,8 @@ namespace Novelly.Api.Chapters;
|
||||
public class Chapter
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
public Guid NovelId { get; set; }
|
||||
public Novel? Novel { get; set; }
|
||||
|
||||
public int Number { get; set; }
|
||||
|
||||
@@ -43,6 +43,6 @@ public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter>
|
||||
{
|
||||
entity.Property(c => c.Title).IsRequired().HasMaxLength(300);
|
||||
entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32);
|
||||
entity.HasIndex(c => new { c.ProjectId, c.Number });
|
||||
entity.HasIndex(c => new { c.NovelId, c.Number });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace Novelly.Api.Chapters;
|
||||
|
||||
public record ChapterSummaryResponse(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
Guid NovelId,
|
||||
int Number,
|
||||
string Title,
|
||||
string? Summary,
|
||||
@@ -21,7 +21,7 @@ public record ChapterSummaryResponse(
|
||||
|
||||
public record ChapterResponse(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
Guid NovelId,
|
||||
int Number,
|
||||
string Title,
|
||||
string? Summary,
|
||||
@@ -115,7 +115,7 @@ file static class ChapterValidation
|
||||
public static class ChapterMapping
|
||||
{
|
||||
public static ChapterResponse ToResponse(this Chapter c) => new(
|
||||
c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
|
||||
c.Id, c.NovelId, c.Number, c.Title, c.Summary,
|
||||
c.Setting, c.Notes,
|
||||
c.Status, c.TargetWordCount,
|
||||
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
|
||||
@@ -124,7 +124,7 @@ public static class ChapterMapping
|
||||
c.UpdatedAt);
|
||||
|
||||
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
|
||||
c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
|
||||
c.Id, c.NovelId, c.Number, c.Title, c.Summary,
|
||||
c.Setting, c.Status, c.TargetWordCount,
|
||||
c.Beats.Count, c.WordCount,
|
||||
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
||||
|
||||
@@ -7,18 +7,18 @@ public static class ChapterEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapChapterEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/chapters").WithTags("Chapters")
|
||||
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/chapters").WithTags("Chapters")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) =>
|
||||
Results.Ok((await service.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse())))
|
||||
.WithSummary("List a project's chapters in manuscript order.");
|
||||
novelScoped.MapGet("/", async (Guid novelId, ChapterService service, CancellationToken ct) =>
|
||||
Results.Ok((await service.ListAsync(novelId, ct)).Select(c => c.ToSummaryResponse())))
|
||||
.WithSummary("List a novel's chapters in manuscript order.");
|
||||
|
||||
projectScoped.MapPost("/", async (
|
||||
Guid projectId, CreateChapterRequest request, ChapterService service, CancellationToken ct) =>
|
||||
novelScoped.MapPost("/", async (
|
||||
Guid novelId, CreateChapterRequest request, ChapterService service, CancellationToken ct) =>
|
||||
{
|
||||
var chapter = await service.CreateAsync(projectId, request, ct);
|
||||
var chapter = await service.CreateAsync(novelId, request, ct);
|
||||
if (chapter is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
|
||||
@@ -9,24 +9,24 @@ namespace Novelly.Api.Chapters;
|
||||
|
||||
public class ChapterService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
NovelAccessService access,
|
||||
TagService tags,
|
||||
ILogger<ChapterService> logger,
|
||||
IModelValidator<CreateChapterRequest> createValidator,
|
||||
IModelValidator<UpdateChapterRequest> updateValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<Chapter>> ListAsync(Guid projectId, CancellationToken ct = default)
|
||||
public async Task<IReadOnlyList<Chapter>> ListAsync(Guid novelId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Default(novelId, nameof(novelId));
|
||||
|
||||
logger.LogInformation("Listing chapters for project {ProjectId}", projectId);
|
||||
logger.LogInformation("Listing chapters for novel {NovelId}", novelId);
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(novelId, NovelPermission.Read, ct);
|
||||
|
||||
return await db.Chapters
|
||||
.Include(c => c.Beats)
|
||||
.Include(c => c.Tags)
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.Where(c => c.NovelId == novelId)
|
||||
.OrderBy(c => c.Number)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
@@ -43,31 +43,31 @@ public class ChapterService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(chapter.NovelId, NovelPermission.Read, ct);
|
||||
return chapter;
|
||||
}
|
||||
|
||||
public async Task<Chapter?> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default)
|
||||
public async Task<Chapter?> CreateAsync(Guid novelId, CreateChapterRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Default(novelId, nameof(novelId));
|
||||
Guard.Null(request, nameof(request));
|
||||
createValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
|
||||
logger.LogInformation("Creating chapter {Title} for project {ProjectId}", request.Title, projectId);
|
||||
logger.LogInformation("Creating chapter {Title} for novel {NovelId}", request.Title, novelId);
|
||||
|
||||
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
|
||||
if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
|
||||
{
|
||||
logger.LogWarning("Rejected chapter creation: project {ProjectId} not found", projectId);
|
||||
logger.LogWarning("Rejected chapter creation: novel {NovelId} not found", novelId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct);
|
||||
await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
|
||||
|
||||
var chapter = new Chapter
|
||||
{
|
||||
ProjectId = projectId,
|
||||
NovelId = novelId,
|
||||
Title = request.Title,
|
||||
Number = request.Number ?? await NextChapterNumberAsync(projectId, ct),
|
||||
Number = request.Number ?? await NextChapterNumberAsync(novelId, ct),
|
||||
Summary = request.Summary,
|
||||
Setting = request.Setting,
|
||||
Notes = request.Notes,
|
||||
@@ -79,7 +79,7 @@ public class ChapterService(
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
chapter.Tags = await tags.ResolveAsync(projectId, names, ct);
|
||||
chapter.Tags = await tags.ResolveAsync(novelId, names, ct);
|
||||
}
|
||||
|
||||
db.Chapters.Add(chapter);
|
||||
@@ -102,7 +102,7 @@ public class ChapterService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
|
||||
chapter.Number = request.Number ?? chapter.Number;
|
||||
@@ -122,7 +122,7 @@ public class ChapterService(
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
chapter.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
||||
chapter.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -141,23 +141,23 @@ public class ChapterService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(chapter.ProjectId, ProjectPermission.DeleteContent, ct);
|
||||
await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct);
|
||||
|
||||
db.Chapters.Remove(chapter);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<int> NextChapterNumberAsync(Guid projectId, CancellationToken ct)
|
||||
private async Task<int> NextChapterNumberAsync(Guid novelId, CancellationToken ct)
|
||||
{
|
||||
logger.LogDebug("Computing next chapter number for project {ProjectId}", projectId);
|
||||
logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId);
|
||||
|
||||
var max = await db.Chapters
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.Where(c => c.NovelId == novelId)
|
||||
.MaxAsync(c => (int?)c.Number, ct);
|
||||
|
||||
var next = (max ?? 0) + 1;
|
||||
logger.LogDebug("Next chapter number for project {ProjectId} is {Number}", projectId, next);
|
||||
logger.LogDebug("Next chapter number for novel {NovelId} is {Number}", novelId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user