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:
James Wampler
2026-08-17 23:03:09 -07:00
parent 0ab4f568b5
commit 4313c8f206
95 changed files with 3192 additions and 1660 deletions
+32 -32
View File
@@ -11,7 +11,7 @@ namespace Novelly.Api.Beats;
public class BeatService(
INovelDbContext db,
ProjectAccessService access,
NovelAccessService access,
TagService tags,
ILogger<BeatService> logger,
IModelValidator<CreateBeatRequest> createValidator,
@@ -26,7 +26,7 @@ public class BeatService(
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
await RequireChapterAccessAsync(chapterId, ProjectPermission.Read, ct);
await RequireChapterAccessAsync(chapterId, NovelPermission.Read, ct);
return await Query()
.Where(b => b.ChapterId == chapterId)
@@ -46,7 +46,7 @@ public class BeatService(
return null;
}
await RequireBeatAccessAsync(beat, ProjectPermission.Read, ct);
await RequireBeatAccessAsync(beat, NovelPermission.Read, ct);
return beat;
}
@@ -57,14 +57,14 @@ public class BeatService(
logger.LogInformation("Listing beats for character {CharacterId}", characterId);
var characterProjectId = await db.Characters.Where(c => c.Id == characterId).Select(c => (Guid?)c.ProjectId).FirstOrDefaultAsync(ct);
if (characterProjectId is null)
var characterNovelId = await db.Characters.Where(c => c.Id == characterId).Select(c => (Guid?)c.NovelId).FirstOrDefaultAsync(ct);
if (characterNovelId is null)
{
logger.LogWarning("Character {CharacterId} not found", characterId);
return null;
}
await access.RequireAsync(characterProjectId.Value, ProjectPermission.Read, ct);
await access.RequireAsync(characterNovelId.Value, NovelPermission.Read, ct);
var beats = await db.Beats
.Include(b => b.Chapter)
@@ -95,7 +95,7 @@ public class BeatService(
return null;
}
await access.RequireAsync(chapter.ProjectId, ProjectPermission.CreateContent, ct);
await access.RequireAsync(chapter.NovelId, NovelPermission.CreateContent, ct);
var beat = new Beat
{
@@ -108,12 +108,12 @@ public class BeatService(
if (request.CharacterIds is { } characterIds)
{
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct);
beat.Characters = await ResolveCharactersAsync(chapter.NovelId, characterIds, ct);
}
if (request.Tags is { } names)
{
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
beat.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
}
chapter.UpdatedAt = DateTimeOffset.UtcNow;
@@ -145,7 +145,7 @@ public class BeatService(
return null;
}
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct);
await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
beat.SortOrder = request.SortOrder ?? beat.SortOrder;
@@ -156,12 +156,12 @@ public class BeatService(
if (request.CharacterIds is { } characterIds)
{
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct);
beat.Characters = await ResolveCharactersAsync(chapter.NovelId, characterIds, ct);
}
if (request.Tags is { } names)
{
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
beat.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
}
await db.SaveChangesAsync(ct);
@@ -180,7 +180,7 @@ public class BeatService(
return false;
}
await RequireBeatAccessAsync(beat, ProjectPermission.DeleteContent, ct);
await RequireBeatAccessAsync(beat, NovelPermission.DeleteContent, ct);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
if (chapter is not null) chapter.UpdatedAt = DateTimeOffset.UtcNow;
@@ -199,7 +199,7 @@ public class BeatService(
logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId);
await RequireChapterAccessAsync(chapterId, ProjectPermission.Write, ct);
await RequireChapterAccessAsync(chapterId, NovelPermission.Write, ct);
var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct);
@@ -246,15 +246,15 @@ public class BeatService(
return null;
}
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct);
await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
var character = await db.Characters
.FirstOrDefaultAsync(c => c.Id == request.CharacterId && c.ProjectId == chapter.ProjectId, ct);
.FirstOrDefaultAsync(c => c.Id == request.CharacterId && c.NovelId == chapter.NovelId, ct);
if (character is null)
{
logger.LogWarning(
"Rejected character assignment: character {CharacterId} not found in project {ProjectId}",
request.CharacterId, chapter.ProjectId);
"Rejected character assignment: character {CharacterId} not found in novel {NovelId}",
request.CharacterId, chapter.NovelId);
return null;
}
@@ -297,16 +297,16 @@ public class BeatService(
}
var targetChapter = await db.Chapters.FirstOrDefaultAsync(
c => c.Id == request.TargetChapterId && c.ProjectId == chapter.ProjectId, ct);
c => c.Id == request.TargetChapterId && c.NovelId == chapter.NovelId, ct);
if (targetChapter is null)
{
logger.LogWarning(
"Rejected beat move: target chapter {TargetChapterId} not found in project {ProjectId}",
request.TargetChapterId, chapter.ProjectId);
"Rejected beat move: target chapter {TargetChapterId} not found in novel {NovelId}",
request.TargetChapterId, chapter.NovelId);
return null;
}
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct);
await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
var beats = await Query().Where(b => b.ChapterId == chapterId && request.BeatIds.Contains(b.Id)).ToListAsync(ct);
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
@@ -338,9 +338,9 @@ public class BeatService(
return beats;
}
private async Task<List<Character>> ResolveCharactersAsync(Guid projectId, IReadOnlyList<Guid> characterIds, CancellationToken ct)
private async Task<List<Character>> ResolveCharactersAsync(Guid novelId, IReadOnlyList<Guid> characterIds, CancellationToken ct)
{
logger.LogDebug("Resolving {Count} characters for project {ProjectId}", characterIds.Count, projectId);
logger.LogDebug("Resolving {Count} characters for novel {NovelId}", characterIds.Count, novelId);
var distinct = characterIds.Distinct().ToList();
if (distinct.Count == 0)
@@ -349,17 +349,17 @@ public class BeatService(
}
var found = await db.Characters
.Where(c => c.ProjectId == projectId && distinct.Contains(c.Id))
.Where(c => c.NovelId == novelId && distinct.Contains(c.Id))
.ToListAsync(ct);
if (found.Count != distinct.Count)
{
logger.LogWarning("Rejected beat reference: one or more characters do not belong to project {ProjectId}", projectId);
logger.LogWarning("Rejected beat reference: one or more characters do not belong to novel {NovelId}", novelId);
throw new InvalidOperationException(
"A beat's characters must belong to the same project as its chapter.");
"A beat's characters must belong to the same novel as its chapter.");
}
logger.LogDebug("Resolved {Count} characters for project {ProjectId}", found.Count, projectId);
logger.LogDebug("Resolved {Count} characters for novel {NovelId}", found.Count, novelId);
return found;
}
@@ -376,13 +376,13 @@ public class BeatService(
return next;
}
private async Task RequireChapterAccessAsync(Guid chapterId, ProjectPermission permission, CancellationToken ct)
private async Task RequireChapterAccessAsync(Guid chapterId, NovelPermission permission, CancellationToken ct)
{
var projectId = await db.Chapters.Where(c => c.Id == chapterId).Select(c => c.ProjectId).FirstOrDefaultAsync(ct);
await access.RequireAsync(projectId, permission, ct);
var novelId = await db.Chapters.Where(c => c.Id == chapterId).Select(c => c.NovelId).FirstOrDefaultAsync(ct);
await access.RequireAsync(novelId, permission, ct);
}
private Task RequireBeatAccessAsync(Beat beat, ProjectPermission permission, CancellationToken ct) =>
private Task RequireBeatAccessAsync(Beat beat, NovelPermission permission, CancellationToken ct) =>
RequireChapterAccessAsync(beat.ChapterId, permission, ct);
private IQueryable<Beat> Query() =>