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:
James Wampler
2026-08-11 21:05:13 -07:00
parent 1ce526019f
commit 23348327a9
57 changed files with 2600 additions and 1421 deletions
+27 -59
View File
@@ -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)