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:
@@ -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() =>
|
||||
|
||||
Reference in New Issue
Block a user