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/.
408 lines
14 KiB
C#
408 lines
14 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Novelly.Api.Chapters;
|
|
using Novelly.Api.Characters;
|
|
using Novelly.Api.Common;
|
|
using Novelly.Api.Common.Validation;
|
|
using Novelly.Api.Data;
|
|
using Novelly.Api.Tags;
|
|
using Novelly.Api.Users;
|
|
|
|
namespace Novelly.Api.Beats;
|
|
|
|
public class BeatService(
|
|
INovelDbContext db,
|
|
NovelAccessService access,
|
|
TagService tags,
|
|
ILogger<BeatService> logger,
|
|
IModelValidator<CreateBeatRequest> createValidator,
|
|
IModelValidator<UpdateBeatRequest> updateValidator,
|
|
IModelValidator<ReorderBeatsRequest> reorderValidator,
|
|
IModelValidator<AssignCharacterToBeatsRequest> assignCharacterValidator,
|
|
IModelValidator<MoveBeatsRequest> moveValidator)
|
|
{
|
|
public async Task<IReadOnlyList<Beat>> ListAsync(Guid chapterId, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(chapterId, nameof(chapterId));
|
|
|
|
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
|
|
|
|
await RequireChapterAccessAsync(chapterId, NovelPermission.Read, ct);
|
|
|
|
return await Query()
|
|
.Where(b => b.ChapterId == chapterId)
|
|
.OrderBy(b => b.SortOrder)
|
|
.ToListAsync(ct);
|
|
}
|
|
|
|
public async Task<Beat?> GetAsync(Guid id, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(id, nameof(id));
|
|
|
|
logger.LogInformation("Getting beat {BeatId}", id);
|
|
|
|
var beat = await FindAsync(id, ct);
|
|
if (beat is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
await RequireBeatAccessAsync(beat, NovelPermission.Read, ct);
|
|
return beat;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<Beat>?> ListForCharacterAsync(
|
|
Guid characterId, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(characterId, nameof(characterId));
|
|
|
|
logger.LogInformation("Listing beats for character {CharacterId}", characterId);
|
|
|
|
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(characterNovelId.Value, NovelPermission.Read, ct);
|
|
|
|
var beats = await db.Beats
|
|
.Include(b => b.Chapter)
|
|
.Include(b => b.ArcStages)
|
|
.Where(b => b.Characters.Any(c => c.Id == characterId))
|
|
.ToListAsync(ct);
|
|
|
|
return
|
|
[
|
|
.. beats
|
|
.OrderBy(b => b.Chapter?.Number ?? 0)
|
|
.ThenBy(b => b.SortOrder)
|
|
];
|
|
}
|
|
|
|
public async Task<Beat?> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(chapterId, nameof(chapterId));
|
|
Guard.Null(request, nameof(request));
|
|
createValidator.Validate(request).ThrowIfInvalid(logger);
|
|
|
|
logger.LogInformation("Creating beat {Title} for chapter {ChapterId}", request.Title, chapterId);
|
|
|
|
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
|
|
if (chapter is null)
|
|
{
|
|
logger.LogWarning("Rejected beat creation: chapter {ChapterId} not found", chapterId);
|
|
return null;
|
|
}
|
|
|
|
await access.RequireAsync(chapter.NovelId, NovelPermission.CreateContent, ct);
|
|
|
|
var beat = new Beat
|
|
{
|
|
ChapterId = chapterId,
|
|
Title = request.Title,
|
|
SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct),
|
|
WhatHappened = request.WhatHappened,
|
|
WhatsNext = request.WhatsNext
|
|
};
|
|
|
|
if (request.CharacterIds is { } characterIds)
|
|
{
|
|
beat.Characters = await ResolveCharactersAsync(chapter.NovelId, characterIds, ct);
|
|
}
|
|
|
|
if (request.Tags is { } names)
|
|
{
|
|
beat.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
|
|
}
|
|
|
|
chapter.UpdatedAt = DateTimeOffset.UtcNow;
|
|
|
|
db.Beats.Add(beat);
|
|
await db.SaveChangesAsync(ct);
|
|
|
|
return (await FindAsync(beat.Id, ct))!;
|
|
}
|
|
|
|
public async Task<Beat?> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(id, nameof(id));
|
|
Guard.Null(request, nameof(request));
|
|
updateValidator.Validate(request).ThrowIfInvalid(logger);
|
|
|
|
logger.LogInformation("Updating beat {BeatId}", id);
|
|
|
|
var beat = await FindAsync(id, ct);
|
|
if (beat is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
|
|
if (chapter is null)
|
|
{
|
|
logger.LogError("Beat {BeatId} references chapter {ChapterId} which does not exist", id, beat.ChapterId);
|
|
return null;
|
|
}
|
|
|
|
await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
|
|
|
|
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
|
|
beat.SortOrder = request.SortOrder ?? beat.SortOrder;
|
|
beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened);
|
|
beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext);
|
|
beat.UpdatedAt = DateTimeOffset.UtcNow;
|
|
chapter.UpdatedAt = beat.UpdatedAt;
|
|
|
|
if (request.CharacterIds is { } characterIds)
|
|
{
|
|
beat.Characters = await ResolveCharactersAsync(chapter.NovelId, characterIds, ct);
|
|
}
|
|
|
|
if (request.Tags is { } names)
|
|
{
|
|
beat.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 beat {BeatId}", id);
|
|
|
|
var beat = await FindAsync(id, ct);
|
|
if (beat is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
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;
|
|
|
|
db.Beats.Remove(beat);
|
|
await db.SaveChangesAsync(ct);
|
|
return true;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<Beat>?> ReorderAsync(
|
|
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(chapterId, nameof(chapterId));
|
|
Guard.Null(request, nameof(request));
|
|
reorderValidator.Validate(request).ThrowIfInvalid(logger);
|
|
|
|
logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId);
|
|
|
|
await RequireChapterAccessAsync(chapterId, NovelPermission.Write, ct);
|
|
|
|
var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct);
|
|
|
|
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
|
|
if (missing.Count > 0)
|
|
{
|
|
logger.LogWarning("Reorder for chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]);
|
|
return null;
|
|
}
|
|
|
|
var order = 1;
|
|
foreach (var id in request.BeatIds)
|
|
{
|
|
beats.Single(b => b.Id == id).SortOrder = order++;
|
|
}
|
|
|
|
foreach (var beat in beats.Where(b => !request.BeatIds.Contains(b.Id)).OrderBy(b => b.SortOrder))
|
|
{
|
|
beat.SortOrder = order++;
|
|
}
|
|
|
|
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
|
|
if (chapter is not null) chapter.UpdatedAt = DateTimeOffset.UtcNow;
|
|
|
|
await db.SaveChangesAsync(ct);
|
|
return await ListAsync(chapterId, ct);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<Beat>?> AssignCharacterAsync(
|
|
Guid chapterId, AssignCharacterToBeatsRequest request, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(chapterId, nameof(chapterId));
|
|
Guard.Null(request, nameof(request));
|
|
assignCharacterValidator.Validate(request).ThrowIfInvalid(logger);
|
|
|
|
logger.LogInformation(
|
|
"Assigning character {CharacterId} to {Count} beats in chapter {ChapterId}",
|
|
request.CharacterId, request.BeatIds.Count, chapterId);
|
|
|
|
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
|
|
if (chapter is null)
|
|
{
|
|
logger.LogWarning("Rejected character assignment: chapter {ChapterId} not found", chapterId);
|
|
return null;
|
|
}
|
|
|
|
await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
|
|
|
|
var character = await db.Characters
|
|
.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 novel {NovelId}",
|
|
request.CharacterId, chapter.NovelId);
|
|
return null;
|
|
}
|
|
|
|
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();
|
|
if (missing.Count > 0)
|
|
{
|
|
logger.LogWarning(
|
|
"Rejected character assignment: chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]);
|
|
return null;
|
|
}
|
|
|
|
foreach (var beat in beats.Where(b => b.Characters.All(c => c.Id != character.Id)))
|
|
{
|
|
beat.Characters.Add(character);
|
|
beat.UpdatedAt = DateTimeOffset.UtcNow;
|
|
chapter.UpdatedAt = beat.UpdatedAt;
|
|
}
|
|
|
|
await db.SaveChangesAsync(ct);
|
|
return await ListAsync(chapterId, ct);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<Beat>?> MoveAsync(
|
|
Guid chapterId, MoveBeatsRequest request, CancellationToken ct = default)
|
|
{
|
|
Guard.Default(chapterId, nameof(chapterId));
|
|
Guard.Null(request, nameof(request));
|
|
moveValidator.Validate(request).ThrowIfInvalid(logger);
|
|
|
|
logger.LogInformation(
|
|
"Moving {Count} beats from chapter {ChapterId} to chapter {TargetChapterId}",
|
|
request.BeatIds.Count, chapterId, request.TargetChapterId);
|
|
|
|
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
|
|
if (chapter is null)
|
|
{
|
|
logger.LogWarning("Rejected beat move: chapter {ChapterId} not found", chapterId);
|
|
return null;
|
|
}
|
|
|
|
var targetChapter = await db.Chapters.FirstOrDefaultAsync(
|
|
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 novel {NovelId}",
|
|
request.TargetChapterId, chapter.NovelId);
|
|
return null;
|
|
}
|
|
|
|
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();
|
|
if (missing.Count > 0)
|
|
{
|
|
logger.LogWarning("Rejected beat move: chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]);
|
|
return null;
|
|
}
|
|
|
|
if (chapterId == request.TargetChapterId)
|
|
{
|
|
return beats;
|
|
}
|
|
|
|
var nextSortOrder = await NextSortOrderAsync(request.TargetChapterId, ct);
|
|
var now = DateTimeOffset.UtcNow;
|
|
foreach (var beatId in request.BeatIds)
|
|
{
|
|
var beat = beats.Single(b => b.Id == beatId);
|
|
beat.ChapterId = request.TargetChapterId;
|
|
beat.SortOrder = nextSortOrder++;
|
|
beat.UpdatedAt = now;
|
|
}
|
|
|
|
chapter.UpdatedAt = now;
|
|
targetChapter.UpdatedAt = now;
|
|
|
|
await db.SaveChangesAsync(ct);
|
|
return beats;
|
|
}
|
|
|
|
private async Task<List<Character>> ResolveCharactersAsync(Guid novelId, IReadOnlyList<Guid> characterIds, CancellationToken ct)
|
|
{
|
|
logger.LogDebug("Resolving {Count} characters for novel {NovelId}", characterIds.Count, novelId);
|
|
|
|
var distinct = characterIds.Distinct().ToList();
|
|
if (distinct.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var found = await db.Characters
|
|
.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 novel {NovelId}", novelId);
|
|
throw new InvalidOperationException(
|
|
"A beat's characters must belong to the same novel as its chapter.");
|
|
}
|
|
|
|
logger.LogDebug("Resolved {Count} characters for novel {NovelId}", found.Count, novelId);
|
|
return found;
|
|
}
|
|
|
|
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
|
|
{
|
|
logger.LogDebug("Computing next sort order for chapter {ChapterId}", chapterId);
|
|
|
|
var max = await db.Beats
|
|
.Where(b => b.ChapterId == chapterId)
|
|
.MaxAsync(b => (int?)b.SortOrder, ct);
|
|
|
|
var next = (max ?? 0) + 1;
|
|
logger.LogDebug("Next sort order for chapter {ChapterId} is {SortOrder}", chapterId, next);
|
|
return next;
|
|
}
|
|
|
|
private async Task RequireChapterAccessAsync(Guid chapterId, NovelPermission permission, CancellationToken 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, NovelPermission permission, CancellationToken ct) =>
|
|
RequireChapterAccessAsync(beat.ChapterId, permission, ct);
|
|
|
|
private IQueryable<Beat> Query() =>
|
|
db.Beats
|
|
.Include(b => b.Characters)
|
|
.Include(b => b.Tags);
|
|
|
|
private async Task<Beat?> FindAsync(Guid id, CancellationToken ct)
|
|
{
|
|
logger.LogDebug("Finding beat {BeatId}", id);
|
|
|
|
var beat = await Query().FirstOrDefaultAsync(b => b.Id == id, ct);
|
|
if (beat is null)
|
|
{
|
|
logger.LogWarning("Beat {BeatId} not found", id);
|
|
return beat;
|
|
}
|
|
|
|
logger.LogDebug("Found beat {BeatId}", id);
|
|
return beat;
|
|
}
|
|
}
|