Remove Scenes, group beats by multiple characters; strip comments repo-wide
Drop the Scene entity/grouping in favor of chapters carrying prose directly and beats belonging to many characters. Add markdown editor + character multi-select components to the web client. Remove all XML doc and inline comments across the touched C#/TS/CSS files in favor of self-documenting names, and record that convention in CLAUDE.md. Add .mcp.json (local MCP server config, no secrets) and ignore .idea/.
This commit is contained in:
@@ -8,10 +8,6 @@ using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Beats;
|
||||
|
||||
/// <summary>
|
||||
/// Beats are a chapter's outline: a flat, ordered table rather than a tree. Everything
|
||||
/// here is scoped to one chapter.
|
||||
/// </summary>
|
||||
public class BeatService(
|
||||
INovelDbContext db,
|
||||
TagService tags,
|
||||
@@ -32,7 +28,6 @@ public class BeatService(
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Null when no beat has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Beat?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
@@ -41,12 +36,6 @@ public class BeatService(
|
||||
return await FindAsync(id, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every beat this character appears in, in manuscript order. This is the character
|
||||
/// page's view onto the outlines: each row carries its chapter so the UI can link
|
||||
/// straight to the beat in that chapter's outline. Null when no character has this id;
|
||||
/// an empty list means the character exists but has no beats yet.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<Beat>?> ListForCharacterAsync(
|
||||
Guid characterId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -62,8 +51,7 @@ public class BeatService(
|
||||
|
||||
var beats = await db.Beats
|
||||
.Include(b => b.Chapter)
|
||||
.Include(b => b.Scene)
|
||||
.Where(b => b.CharacterId == characterId)
|
||||
.Where(b => b.Characters.Any(c => c.Id == characterId))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return
|
||||
@@ -74,7 +62,6 @@ public class BeatService(
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>Null when no chapter has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Beat?> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(chapterId, nameof(chapterId));
|
||||
@@ -90,19 +77,20 @@ public class BeatService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
|
||||
|
||||
var beat = new Beat
|
||||
{
|
||||
ChapterId = chapterId,
|
||||
Title = request.Title,
|
||||
SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct),
|
||||
CharacterId = request.CharacterId,
|
||||
WhatHappened = request.WhatHappened,
|
||||
WhatsNext = request.WhatsNext,
|
||||
SceneId = request.SceneId
|
||||
WhatsNext = request.WhatsNext
|
||||
};
|
||||
|
||||
if (request.CharacterIds is { } characterIds)
|
||||
{
|
||||
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct);
|
||||
}
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
||||
@@ -111,7 +99,6 @@ public class BeatService(
|
||||
db.Beats.Add(beat);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// Just created it — the reload is only to pick up includes, not to check existence.
|
||||
return (await FindAsync(beat.Id, ct))!;
|
||||
}
|
||||
|
||||
@@ -132,22 +119,21 @@ public class BeatService(
|
||||
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
|
||||
if (chapter is null)
|
||||
{
|
||||
// The beat's own chapter should always exist via the FK — an invariant
|
||||
// failing, not a caller mistake, but still not found so still just null.
|
||||
logger.LogError("Beat {BeatId} references chapter {ChapterId} which does not exist", id, beat.ChapterId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
|
||||
|
||||
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
|
||||
beat.SortOrder = request.SortOrder ?? beat.SortOrder;
|
||||
beat.CharacterId = request.ClearCharacter ? null : request.CharacterId ?? beat.CharacterId;
|
||||
beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened);
|
||||
beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext);
|
||||
beat.SceneId = request.ClearScene ? null : request.SceneId ?? beat.SceneId;
|
||||
beat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (request.CharacterIds is { } characterIds)
|
||||
{
|
||||
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct);
|
||||
}
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
||||
@@ -157,7 +143,6 @@ public class BeatService(
|
||||
return (await FindAsync(id, ct))!;
|
||||
}
|
||||
|
||||
/// <summary>True if a beat was deleted; false if no beat had this id.</summary>
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
@@ -175,11 +160,6 @@ public class BeatService(
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renumbers a chapter's beats to match the order given. Sending the whole list beats
|
||||
/// patching sort orders one at a time, which is fiddly to get right from a drag handle.
|
||||
/// </summary>
|
||||
/// <summary>Null when the chapter carries a beat id it does not own — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<IReadOnlyList<Beat>?> ReorderAsync(
|
||||
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
@@ -198,8 +178,6 @@ public class BeatService(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Listed beats take the order given; anything omitted keeps its relative position
|
||||
// after them rather than silently jumping to the front.
|
||||
var order = 1;
|
||||
foreach (var id in request.BeatIds)
|
||||
{
|
||||
@@ -215,35 +193,26 @@ public class BeatService(
|
||||
return await ListAsync(chapterId, ct);
|
||||
}
|
||||
|
||||
private async Task ValidateReferencesAsync(
|
||||
Chapter chapter, Guid? characterId, Guid? sceneId, CancellationToken ct)
|
||||
private async Task<List<Character>> ResolveCharactersAsync(Guid projectId, IReadOnlyList<Guid> characterIds, CancellationToken ct)
|
||||
{
|
||||
logger.LogDebug("Validating beat references for chapter {ChapterId}: character {CharacterId}, scene {SceneId}", chapter.Id, characterId, sceneId);
|
||||
|
||||
if (characterId is { } cid)
|
||||
var distinct = characterIds.Distinct().ToList();
|
||||
if (distinct.Count == 0)
|
||||
{
|
||||
var belongs = await db.Characters
|
||||
.AnyAsync(c => c.Id == cid && c.ProjectId == chapter.ProjectId, ct);
|
||||
|
||||
if (!belongs)
|
||||
{
|
||||
logger.LogWarning("Rejected beat reference: character {CharacterId} does not belong to project {ProjectId}", cid, chapter.ProjectId);
|
||||
throw new InvalidOperationException(
|
||||
"A beat's character must belong to the same project as its chapter.");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (sceneId is { } sid)
|
||||
{
|
||||
var belongs = await db.Scenes.AnyAsync(s => s.Id == sid && s.ChapterId == chapter.Id, ct);
|
||||
var found = await db.Characters
|
||||
.Where(c => c.ProjectId == projectId && distinct.Contains(c.Id))
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (!belongs)
|
||||
{
|
||||
logger.LogWarning("Rejected beat reference: scene {SceneId} does not belong to chapter {ChapterId}", sid, chapter.Id);
|
||||
throw new InvalidOperationException(
|
||||
"A beat can only be grouped under a scene in the same chapter.");
|
||||
}
|
||||
if (found.Count != distinct.Count)
|
||||
{
|
||||
logger.LogWarning("Rejected beat reference: one or more characters do not belong to project {ProjectId}", projectId);
|
||||
throw new InvalidOperationException(
|
||||
"A beat's characters must belong to the same project as its chapter.");
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
|
||||
@@ -259,8 +228,7 @@ public class BeatService(
|
||||
|
||||
private IQueryable<Beat> Query() =>
|
||||
db.Beats
|
||||
.Include(b => b.Character)
|
||||
.Include(b => b.Scene)
|
||||
.Include(b => b.Characters)
|
||||
.Include(b => b.Tags);
|
||||
|
||||
private async Task<Beat?> FindAsync(Guid id, CancellationToken ct)
|
||||
|
||||
Reference in New Issue
Block a user