Add Serilog console logging across the API

Information at endpoint and service-method boundaries, Debug in deeper
helpers, Warning before expected/recoverable failures (not-found,
validation, agent tool errors), Error on caught exceptions. Serilog
wraps the exception handler so request-completion logs report the
resolved status code rather than the raw exception. Never logs prose
bodies or the Anthropic API key.
This commit is contained in:
James Wampler
2026-08-06 12:11:20 -07:00
parent 4f396bb5f9
commit 04917fa09e
34 changed files with 790 additions and 147 deletions
+4 -2
View File
@@ -1,10 +1,12 @@
using Novelly.Api.Common;
namespace Novelly.Api.Agent;
public static class AgentEndpoints
{
public static IEndpointRouteBuilder MapAgentEndpoints(this IEndpointRouteBuilder app)
{
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/agent").WithTags("Agent");
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/agent").WithTags("Agent").AddEndpointFilter<RequestLoggingEndpointFilter>();
projectScoped.MapGet("/conversations", async (
Guid projectId, NovelAgentService agent, CancellationToken ct) =>
@@ -19,7 +21,7 @@ public static class AgentEndpoints
Results.Ok(await agent.SendMessageAsync(projectId, request, ct)))
.WithSummary("Send a message to the writing agent and run it to completion.");
var conversations = app.MapGroup("/api/conversations").WithTags("Agent");
var conversations = app.MapGroup("/api/conversations").WithTags("Agent").AddEndpointFilter<RequestLoggingEndpointFilter>();
conversations.MapGet("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) =>
Results.Ok(await agent.GetConversationAsync(id, ct)))
@@ -11,7 +11,7 @@ namespace Novelly.Api.Agent;
/// model-agnostic block types and the SDK's request/response shapes; the tool-use loop
/// itself lives in <see cref="NovelAgentService"/>.
/// </summary>
public class AnthropicAgentModelClient(IOptions<AgentOptions> options) : IAgentModelClient
public class AnthropicAgentModelClient(IOptions<AgentOptions> options, ILogger<AnthropicAgentModelClient> logger) : IAgentModelClient
{
private readonly AgentOptions _options = options.Value;
private AnthropicClient? _client;
@@ -36,6 +36,10 @@ public class AnthropicAgentModelClient(IOptions<AgentOptions> options) : IAgentM
IReadOnlyList<AgentToolDefinition> tools,
CancellationToken ct = default)
{
logger.LogInformation(
"Calling model {Model} with {MessageCount} messages, {ToolCount} tools, effort {Effort}",
_options.Model, messages.Count, tools.Count, _options.Effort);
var parameters = new MessageCreateParams
{
Model = _options.Model,
@@ -53,6 +57,10 @@ public class AnthropicAgentModelClient(IOptions<AgentOptions> options) : IAgentM
var response = await Client.Messages.Create(parameters, cancellationToken: ct);
logger.LogInformation(
"Model {Model} responded with stop reason {StopReason}, input tokens {InputTokens}, output tokens {OutputTokens}",
_options.Model, response.StopReason, response.Usage?.InputTokens, response.Usage?.OutputTokens);
return new AgentModelResponse(
[.. response.Content.Select(FromSdkBlock).OfType<AgentContentBlock>()],
response.StopReason?.ToString());
+44 -8
View File
@@ -28,15 +28,21 @@ public class NovelAgentService(
private readonly AgentOptions _options = options.Value;
public async Task<IReadOnlyList<ConversationSummaryDto>> ListConversationsAsync(
Guid projectId, CancellationToken ct = default) =>
await db.Conversations
Guid projectId, CancellationToken ct = default)
{
logger.LogInformation("Listing agent conversations for project {ProjectId}", projectId);
return await db.Conversations
.Where(c => c.ProjectId == projectId)
.OrderByDescending(c => c.UpdatedAt)
.Select(c => new ConversationSummaryDto(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt))
.ToListAsync(ct);
}
public async Task<ConversationDto> GetConversationAsync(Guid conversationId, CancellationToken ct = default)
{
logger.LogInformation("Getting agent conversation {ConversationId}", conversationId);
var conversation = await LoadConversationAsync(conversationId, ct);
return new ConversationDto(
@@ -49,6 +55,8 @@ public class NovelAgentService(
public async Task DeleteConversationAsync(Guid conversationId, CancellationToken ct = default)
{
logger.LogInformation("Deleting agent conversation {ConversationId}", conversationId);
var conversation = await LoadConversationAsync(conversationId, ct);
db.Conversations.Remove(conversation);
await db.SaveChangesAsync(ct);
@@ -61,6 +69,10 @@ public class NovelAgentService(
public async Task<AgentTurnDto> SendMessageAsync(
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
{
logger.LogInformation(
"Sending agent message for project {ProjectId}, conversation {ConversationId}, message length {MessageLength}",
projectId, request.ConversationId, request.Message.Length);
var conversation = request.ConversationId is { } id
? await LoadConversationAsync(id, ct)
: await StartConversationAsync(projectId, request.Message, ct);
@@ -77,6 +89,8 @@ public class NovelAgentService(
for (var iteration = 0; iteration < _options.MaxIterations; iteration++)
{
logger.LogDebug("Agent iteration {Iteration} for project {ProjectId}", iteration, projectId);
var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct);
foreach (var block in response.Content.OfType<AgentTextBlock>())
@@ -142,6 +156,8 @@ public class NovelAgentService(
private async Task<AgentMessage> AppendMessageAsync(
AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct)
{
logger.LogDebug("Appending {Role} message to conversation {ConversationId}, content length {ContentLength}", role, conversation.Id, content.Length);
var message = new AgentMessage
{
ConversationId = conversation.Id,
@@ -170,8 +186,11 @@ public class NovelAgentService(
private async Task<AgentConversation> StartConversationAsync(
Guid projectId, string firstMessage, CancellationToken ct)
{
logger.LogDebug("Starting new agent conversation for project {ProjectId}", projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
logger.LogWarning("Project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId);
}
@@ -185,11 +204,22 @@ public class NovelAgentService(
return conversation;
}
private async Task<AgentConversation> LoadConversationAsync(Guid conversationId, CancellationToken ct) =>
await db.Conversations
private async Task<AgentConversation> LoadConversationAsync(Guid conversationId, CancellationToken ct)
{
logger.LogDebug("Loading agent conversation {ConversationId}", conversationId);
var conversation = await db.Conversations
.Include(c => c.Messages)
.FirstOrDefaultAsync(c => c.Id == conversationId, ct)
?? throw new NotFoundException(nameof(AgentConversation), conversationId);
.FirstOrDefaultAsync(c => c.Id == conversationId, ct);
if (conversation is null)
{
logger.LogWarning("AgentConversation {ConversationId} not found", conversationId);
throw new NotFoundException(nameof(AgentConversation), conversationId);
}
return conversation;
}
/// <summary>
/// Replays the stored conversation as plain text turns. Tool calls are not replayed —
@@ -208,8 +238,14 @@ public class NovelAgentService(
private async Task<string> BuildSystemPromptAsync(Guid projectId, CancellationToken ct)
{
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct)
?? throw new NotFoundException(nameof(Project), projectId);
logger.LogDebug("Building system prompt for project {ProjectId}", projectId);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
if (project is null)
{
logger.LogWarning("Project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId);
}
var brief = new StringBuilder();
brief.AppendLine($"Title: {project.Title}");
+9 -1
View File
@@ -33,7 +33,8 @@ public class NovelAgentToolset(
BeatService beats,
SceneService scenes,
TagService tags,
OpenQuestionService questions)
OpenQuestionService questions,
ILogger<NovelAgentToolset> logger)
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
@@ -57,24 +58,31 @@ public class NovelAgentToolset(
{
if (!ByName.TryGetValue(name, out var tool))
{
logger.LogWarning("Agent requested unknown tool {Tool}", name);
return new AgentToolResult($"No such tool: '{name}'.", true);
}
logger.LogDebug("Running tool {Tool} for project {ProjectId}", name, projectId);
try
{
var result = await tool.Handler(projectId, input, ct);
logger.LogDebug("Tool {Tool} for project {ProjectId} succeeded", name, projectId);
return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false);
}
catch (NotFoundException ex)
{
logger.LogWarning(ex, "Tool {Tool} for project {ProjectId} failed: not found", name, projectId);
return new AgentToolResult(ex.Message, true);
}
catch (ArgumentException ex)
{
logger.LogWarning(ex, "Tool {Tool} for project {ProjectId} failed: invalid argument", name, projectId);
return new AgentToolResult(ex.Message, true);
}
catch (InvalidOperationException ex)
{
logger.LogWarning(ex, "Tool {Tool} for project {ProjectId} failed: invalid operation", name, projectId);
return new AgentToolResult(ex.Message, true);
}
}
+4 -2
View File
@@ -1,10 +1,12 @@
using Novelly.Api.Common;
namespace Novelly.Api.Beats;
public static class BeatEndpoints
{
public static IEndpointRouteBuilder MapBeatEndpoints(this IEndpointRouteBuilder app)
{
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/beats").WithTags("Beats");
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/beats").WithTags("Beats").AddEndpointFilter<RequestLoggingEndpointFilter>();
chapterScoped.MapGet("/", async (Guid chapterId, BeatService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(chapterId, ct)))
@@ -29,7 +31,7 @@ public static class BeatEndpoints
.WithTags("Beats")
.WithSummary("Every beat this character appears in, in manuscript order.");
var beats = app.MapGroup("/api/beats").WithTags("Beats");
var beats = app.MapGroup("/api/beats").WithTags("Beats").AddEndpointFilter<RequestLoggingEndpointFilter>();
beats.MapGet("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct)))
+52 -10
View File
@@ -11,10 +11,12 @@ namespace Novelly.Api.Beats;
/// 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)
public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatService> logger)
{
public async Task<IReadOnlyList<BeatDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
{
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
var beats = await Query()
.Where(b => b.ChapterId == chapterId)
.OrderBy(b => b.SortOrder)
@@ -23,8 +25,11 @@ public class BeatService(INovelDbContext db, TagService tags)
return [.. beats.Select(b => b.ToDto())];
}
public async Task<BeatDto> GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
public async Task<BeatDto> GetAsync(Guid id, CancellationToken ct = default)
{
logger.LogInformation("Getting beat {BeatId}", id);
return (await FindAsync(id, ct)).ToDto();
}
/// <summary>
/// Every beat this character appears in, in manuscript order. This is the character
@@ -34,8 +39,11 @@ public class BeatService(INovelDbContext db, TagService tags)
public async Task<IReadOnlyList<CharacterBeatDto>> ListForCharacterAsync(
Guid characterId, CancellationToken ct = default)
{
logger.LogInformation("Listing beats for character {CharacterId}", characterId);
if (!await db.Characters.AnyAsync(c => c.Id == characterId, ct))
{
logger.LogWarning("Character {CharacterId} not found", characterId);
throw new NotFoundException(nameof(Character), characterId);
}
@@ -66,8 +74,14 @@ public class BeatService(INovelDbContext db, TagService tags)
public async Task<BeatDto> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
{
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct)
?? throw new NotFoundException(nameof(Chapter), chapterId);
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("Chapter {ChapterId} not found", chapterId);
throw new NotFoundException(nameof(Chapter), chapterId);
}
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
@@ -94,9 +108,15 @@ public class BeatService(INovelDbContext db, TagService tags)
public async Task<BeatDto> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default)
{
logger.LogInformation("Updating beat {BeatId}", id);
var beat = await FindAsync(id, ct);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct)
?? throw new NotFoundException(nameof(Chapter), beat.ChapterId);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
if (chapter is null)
{
logger.LogWarning("Chapter {ChapterId} not found", beat.ChapterId);
throw new NotFoundException(nameof(Chapter), beat.ChapterId);
}
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
@@ -119,6 +139,8 @@ public class BeatService(INovelDbContext db, TagService tags)
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
logger.LogInformation("Deleting beat {BeatId}", id);
var beat = await FindAsync(id, ct);
db.Beats.Remove(beat);
await db.SaveChangesAsync(ct);
@@ -131,11 +153,14 @@ public class BeatService(INovelDbContext db, TagService tags)
public async Task<IReadOnlyList<BeatDto>> ReorderAsync(
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
{
logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId);
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]);
throw new NotFoundException(nameof(Beat), missing[0]);
}
@@ -159,6 +184,8 @@ public class BeatService(INovelDbContext db, TagService tags)
private async Task ValidateReferencesAsync(
Chapter chapter, Guid? characterId, Guid? sceneId, CancellationToken ct)
{
logger.LogDebug("Validating beat references for chapter {ChapterId}: character {CharacterId}, scene {SceneId}", chapter.Id, characterId, sceneId);
if (characterId is { } cid)
{
var belongs = await db.Characters
@@ -166,6 +193,7 @@ public class BeatService(INovelDbContext db, TagService tags)
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.");
}
@@ -177,6 +205,7 @@ public class BeatService(INovelDbContext db, TagService tags)
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.");
}
@@ -185,6 +214,8 @@ public class BeatService(INovelDbContext db, TagService tags)
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);
@@ -198,7 +229,18 @@ public class BeatService(INovelDbContext db, TagService tags)
.Include(b => b.Scene)
.Include(b => b.Tags);
private async Task<Beat> FindAsync(Guid id, CancellationToken ct) =>
await Query().FirstOrDefaultAsync(b => b.Id == id, ct)
?? throw new NotFoundException(nameof(Beat), id);
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);
throw new NotFoundException(nameof(Beat), id);
}
logger.LogDebug("Found beat {BeatId}", id);
return beat;
}
}
+4 -2
View File
@@ -1,10 +1,12 @@
using Novelly.Api.Common;
namespace Novelly.Api.Chapters;
public static class ChapterEndpoints
{
public static IEndpointRouteBuilder MapChapterEndpoints(this IEndpointRouteBuilder app)
{
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/chapters").WithTags("Chapters");
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/chapters").WithTags("Chapters").AddEndpointFilter<RequestLoggingEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(projectId, ct)))
@@ -18,7 +20,7 @@ public static class ChapterEndpoints
})
.WithSummary("Add a chapter.");
var chapters = app.MapGroup("/api/chapters").WithTags("Chapters");
var chapters = app.MapGroup("/api/chapters").WithTags("Chapters").AddEndpointFilter<RequestLoggingEndpointFilter>();
chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct)))
+36 -8
View File
@@ -6,10 +6,12 @@ using Novelly.Api.Tags;
namespace Novelly.Api.Chapters;
public class ChapterService(INovelDbContext db, TagService tags)
public class ChapterService(INovelDbContext db, TagService tags, ILogger<ChapterService> logger)
{
public async Task<IReadOnlyList<ChapterSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default)
{
logger.LogInformation("Listing chapters for project {ProjectId}", projectId);
var chapters = await db.Chapters
.Include(c => c.PovCharacter)
.Include(c => c.Beats)
@@ -22,13 +24,19 @@ public class ChapterService(INovelDbContext db, TagService tags)
return [.. chapters.Select(c => c.ToSummaryDto())];
}
public async Task<ChapterDto> GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
public async Task<ChapterDto> GetAsync(Guid id, CancellationToken ct = default)
{
logger.LogInformation("Getting chapter {ChapterId}", id);
return (await FindAsync(id, ct)).ToDto();
}
public async Task<ChapterDto> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default)
{
logger.LogInformation("Creating chapter {Title} for project {ProjectId}", request.Title, projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
logger.LogWarning("Project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId);
}
@@ -57,6 +65,8 @@ public class ChapterService(INovelDbContext db, TagService tags)
public async Task<ChapterDto> UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default)
{
logger.LogInformation("Updating chapter {ChapterId}", id);
var chapter = await FindAsync(id, ct);
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
@@ -80,6 +90,8 @@ public class ChapterService(INovelDbContext db, TagService tags)
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
logger.LogInformation("Deleting chapter {ChapterId}", id);
var chapter = await FindAsync(id, ct);
db.Chapters.Remove(chapter);
await db.SaveChangesAsync(ct);
@@ -87,21 +99,37 @@ public class ChapterService(INovelDbContext db, TagService tags)
private async Task<int> NextChapterNumberAsync(Guid projectId, CancellationToken ct)
{
logger.LogDebug("Computing next chapter number for project {ProjectId}", projectId);
var max = await db.Chapters
.Where(c => c.ProjectId == projectId)
.MaxAsync(c => (int?)c.Number, ct);
return (max ?? 0) + 1;
var next = (max ?? 0) + 1;
logger.LogDebug("Next chapter number for project {ProjectId} is {Number}", projectId, next);
return next;
}
private async Task<Chapter> FindAsync(Guid id, CancellationToken ct) =>
await db.Chapters
private async Task<Chapter> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding chapter {ChapterId}", id);
var chapter = await db.Chapters
.Include(c => c.PovCharacter)
.Include(c => c.Beats).ThenInclude(b => b.Character)
.Include(c => c.Beats).ThenInclude(b => b.Scene)
.Include(c => c.Beats).ThenInclude(b => b.Tags)
.Include(c => c.Scenes).ThenInclude(s => s.PovCharacter)
.Include(c => c.Tags)
.FirstOrDefaultAsync(c => c.Id == id, ct)
?? throw new NotFoundException(nameof(Chapter), id);
.FirstOrDefaultAsync(c => c.Id == id, ct);
if (chapter is null)
{
logger.LogWarning("Chapter {ChapterId} not found", id);
throw new NotFoundException(nameof(Chapter), id);
}
logger.LogDebug("Found chapter {ChapterId}", id);
return chapter;
}
}
@@ -13,10 +13,12 @@ namespace Novelly.Api.Characters;
/// on a supporting character. Demoting someone should not delete work, and a character
/// who turns out to matter gets promoted after the arc is already sketched.
/// </remarks>
public class CharacterArcService(INovelDbContext db)
public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService> logger)
{
public async Task<IReadOnlyList<ArcStageDto>> ListAsync(Guid characterId, CancellationToken ct = default)
{
logger.LogInformation("Listing arc stages for character {CharacterId}", characterId);
var stages = await Query()
.Where(s => s.CharacterId == characterId)
.OrderBy(s => s.SortOrder)
@@ -25,14 +27,23 @@ public class CharacterArcService(INovelDbContext db)
return [.. stages.Select(s => s.ToDto())];
}
public async Task<ArcStageDto> GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
public async Task<ArcStageDto> GetAsync(Guid id, CancellationToken ct = default)
{
logger.LogInformation("Getting arc stage {ArcStageId}", id);
return (await FindAsync(id, ct)).ToDto();
}
public async Task<ArcStageDto> CreateAsync(
Guid characterId, CreateArcStageRequest request, CancellationToken ct = default)
{
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct)
?? throw new NotFoundException(nameof(Character), characterId);
logger.LogInformation("Creating arc stage {Title} for character {CharacterId}", request.Title, characterId);
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct);
if (character is null)
{
logger.LogWarning("Character {CharacterId} not found", characterId);
throw new NotFoundException(nameof(Character), characterId);
}
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
@@ -53,10 +64,16 @@ public class CharacterArcService(INovelDbContext db)
public async Task<ArcStageDto> UpdateAsync(
Guid id, UpdateArcStageRequest request, CancellationToken ct = default)
{
logger.LogInformation("Updating arc stage {ArcStageId}", id);
var stage = await FindAsync(id, ct);
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct)
?? throw new NotFoundException(nameof(Character), stage.CharacterId);
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct);
if (character is null)
{
logger.LogWarning("Character {CharacterId} not found", stage.CharacterId);
throw new NotFoundException(nameof(Character), stage.CharacterId);
}
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
@@ -72,6 +89,8 @@ public class CharacterArcService(INovelDbContext db)
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
logger.LogInformation("Deleting arc stage {ArcStageId}", id);
var stage = await FindAsync(id, ct);
db.CharacterArcStages.Remove(stage);
await db.SaveChangesAsync(ct);
@@ -84,6 +103,8 @@ public class CharacterArcService(INovelDbContext db)
public async Task<IReadOnlyList<ArcStageDto>> ReorderAsync(
Guid characterId, ReorderArcStagesRequest request, CancellationToken ct = default)
{
logger.LogInformation("Reordering {Count} arc stages for character {CharacterId}", request.StageIds.Count, characterId);
var stages = await db.CharacterArcStages
.Where(s => s.CharacterId == characterId)
.ToListAsync(ct);
@@ -91,6 +112,7 @@ public class CharacterArcService(INovelDbContext db)
var missing = request.StageIds.Where(id => stages.All(s => s.Id != id)).ToList();
if (missing.Count > 0)
{
logger.LogWarning("Reorder for character {CharacterId} referenced missing arc stage {ArcStageId}", characterId, missing[0]);
throw new NotFoundException(nameof(CharacterArcStage), missing[0]);
}
@@ -117,10 +139,13 @@ public class CharacterArcService(INovelDbContext db)
return;
}
logger.LogDebug("Checking chapter {ChapterId} belongs to project {ProjectId}", id, character.ProjectId);
var belongs = await db.Chapters.AnyAsync(c => c.Id == id && c.ProjectId == character.ProjectId, ct);
if (!belongs)
{
logger.LogWarning("Rejected arc stage: chapter {ChapterId} does not belong to project {ProjectId}", id, character.ProjectId);
throw new InvalidOperationException(
"An arc stage can only point at a chapter in the same project as its character.");
}
@@ -128,6 +153,8 @@ public class CharacterArcService(INovelDbContext db)
private async Task<int> NextSortOrderAsync(Guid characterId, CancellationToken ct)
{
logger.LogDebug("Computing next sort order for character {CharacterId}", characterId);
var max = await db.CharacterArcStages
.Where(s => s.CharacterId == characterId)
.MaxAsync(s => (int?)s.SortOrder, ct);
@@ -137,7 +164,18 @@ public class CharacterArcService(INovelDbContext db)
private IQueryable<CharacterArcStage> Query() => db.CharacterArcStages.Include(s => s.Chapter);
private async Task<CharacterArcStage> FindAsync(Guid id, CancellationToken ct) =>
await Query().FirstOrDefaultAsync(s => s.Id == id, ct)
?? throw new NotFoundException(nameof(CharacterArcStage), id);
private async Task<CharacterArcStage> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding arc stage {ArcStageId}", id);
var stage = await Query().FirstOrDefaultAsync(s => s.Id == id, ct);
if (stage is null)
{
logger.LogWarning("CharacterArcStage {ArcStageId} not found", id);
throw new NotFoundException(nameof(CharacterArcStage), id);
}
logger.LogDebug("Found arc stage {ArcStageId}", id);
return stage;
}
}
@@ -1,10 +1,12 @@
using Novelly.Api.Common;
namespace Novelly.Api.Characters;
public static class CharacterEndpoints
{
public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app)
{
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/characters").WithTags("Characters");
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/characters").WithTags("Characters").AddEndpointFilter<RequestLoggingEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, CharacterService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(projectId, ct)))
@@ -18,7 +20,7 @@ public static class CharacterEndpoints
})
.WithSummary("Add a character dossier.");
var characters = app.MapGroup("/api/characters").WithTags("Characters");
var characters = app.MapGroup("/api/characters").WithTags("Characters").AddEndpointFilter<RequestLoggingEndpointFilter>();
characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct)))
@@ -67,7 +69,7 @@ public static class CharacterEndpoints
Results.Ok(await service.ReorderAsync(id, request, ct)))
.WithSummary("Renumber a character's arc to match the order given.");
var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters");
var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters").AddEndpointFilter<RequestLoggingEndpointFilter>();
arcStages.MapGet("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct)))
+48 -12
View File
@@ -6,7 +6,7 @@ using Novelly.Api.Tags;
namespace Novelly.Api.Characters;
public class CharacterService(INovelDbContext db, TagService tags)
public class CharacterService(INovelDbContext db, TagService tags, ILogger<CharacterService> logger)
{
/// <summary>
/// Main characters first, then by the part they play, then by name.
@@ -20,6 +20,8 @@ public class CharacterService(INovelDbContext db, TagService tags)
/// </remarks>
public async Task<IReadOnlyList<CharacterDto>> ListAsync(Guid projectId, CancellationToken ct = default)
{
logger.LogInformation("Listing characters for project {ProjectId}", projectId);
var characters = await Query()
.Where(c => c.ProjectId == projectId)
.ToListAsync(ct);
@@ -34,11 +36,16 @@ public class CharacterService(INovelDbContext db, TagService tags)
];
}
public async Task<CharacterDto> GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
public async Task<CharacterDto> GetAsync(Guid id, CancellationToken ct = default)
{
logger.LogInformation("Getting character {CharacterId}", id);
return (await FindAsync(id, ct)).ToDto();
}
public async Task<CharacterDto> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default)
{
logger.LogInformation("Creating character {Name} for project {ProjectId}, role {Role}, importance {Importance}", request.Name, projectId, request.Role, request.Importance);
await EnsureProjectExists(projectId, ct);
var character = new Character
@@ -74,6 +81,8 @@ public class CharacterService(INovelDbContext db, TagService tags)
public async Task<CharacterDto> UpdateAsync(Guid id, UpdateCharacterRequest request, CancellationToken ct = default)
{
logger.LogInformation("Updating character {CharacterId}", id);
var character = await FindAsync(id, ct);
character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name;
@@ -105,6 +114,8 @@ public class CharacterService(INovelDbContext db, TagService tags)
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
logger.LogInformation("Deleting character {CharacterId}", id);
var character = await FindAsync(id, ct);
db.Characters.Remove(character);
await db.SaveChangesAsync(ct);
@@ -113,14 +124,20 @@ public class CharacterService(INovelDbContext db, TagService tags)
public async Task<CharacterDto> AddRelationshipAsync(
Guid characterId, CreateRelationshipRequest request, CancellationToken ct = default)
{
logger.LogInformation("Adding relationship {RelationshipType} from character {CharacterId} to {RelatedCharacterId}", request.RelationshipType, characterId, request.RelatedCharacterId);
var character = await FindAsync(characterId, ct);
var related = await db.Characters
.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct)
?? throw new NotFoundException(nameof(Character), request.RelatedCharacterId);
var related = await db.Characters.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct);
if (related is null)
{
logger.LogWarning("Character {RelatedCharacterId} not found", request.RelatedCharacterId);
throw new NotFoundException(nameof(Character), request.RelatedCharacterId);
}
if (related.ProjectId != character.ProjectId)
{
logger.LogWarning("Rejected relationship: character {CharacterId} and {RelatedCharacterId} belong to different projects", characterId, request.RelatedCharacterId);
throw new InvalidOperationException("Characters must belong to the same project to be related.");
}
@@ -138,9 +155,14 @@ public class CharacterService(INovelDbContext db, TagService tags)
public async Task RemoveRelationshipAsync(Guid relationshipId, CancellationToken ct = default)
{
var relationship = await db.CharacterRelationships
.FirstOrDefaultAsync(r => r.Id == relationshipId, ct)
?? throw new NotFoundException(nameof(CharacterRelationship), relationshipId);
logger.LogInformation("Removing relationship {RelationshipId}", relationshipId);
var relationship = await db.CharacterRelationships.FirstOrDefaultAsync(r => r.Id == relationshipId, ct);
if (relationship is null)
{
logger.LogWarning("CharacterRelationship {RelationshipId} not found", relationshipId);
throw new NotFoundException(nameof(CharacterRelationship), relationshipId);
}
db.CharacterRelationships.Remove(relationship);
await db.SaveChangesAsync(ct);
@@ -154,14 +176,28 @@ public class CharacterService(INovelDbContext db, TagService tags)
.Include(c => c.ArcStages)
.ThenInclude(s => s.Chapter);
private async Task<Character> FindAsync(Guid id, CancellationToken ct) =>
await Query().FirstOrDefaultAsync(c => c.Id == id, ct)
?? throw new NotFoundException(nameof(Character), id);
private async Task<Character> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding character {CharacterId}", id);
var character = await Query().FirstOrDefaultAsync(c => c.Id == id, ct);
if (character is null)
{
logger.LogWarning("Character {CharacterId} not found", id);
throw new NotFoundException(nameof(Character), id);
}
logger.LogDebug("Found character {CharacterId}", id);
return character;
}
private async Task EnsureProjectExists(Guid projectId, CancellationToken ct)
{
logger.LogDebug("Checking project {ProjectId} exists", projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
logger.LogWarning("Project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId);
}
}
@@ -0,0 +1,24 @@
namespace Novelly.Api.Common;
/// <summary>
/// Logs every request an endpoint group handles: Information on entry with the route's
/// name and values, Debug on exit with the resulting status. Applied per <c>MapGroup</c>
/// rather than inside each handler, so no endpoint lambda needs to know about logging.
/// </summary>
public class RequestLoggingEndpointFilter(ILogger<RequestLoggingEndpointFilter> logger) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var routeName = context.HttpContext.GetEndpoint()?.DisplayName ?? context.HttpContext.Request.Path;
logger.LogInformation(
"HTTP {Method} {Route} invoked with {@RouteValues}",
context.HttpContext.Request.Method, routeName, context.HttpContext.Request.RouteValues);
var result = await next(context);
logger.LogDebug("HTTP {Method} {Route} completed with {ResultType}", context.HttpContext.Request.Method, routeName, result?.GetType().Name);
return result;
}
}
+2
View File
@@ -13,6 +13,8 @@
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.1" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
</ItemGroup>
+17
View File
@@ -11,9 +11,17 @@ using Novelly.Api.Projects;
using Novelly.Api.Questions;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
// AddSerilog (not UseSerilog) so it becomes an additional logging provider rather than
// replacing the one AddServiceDefaults wires up for the Aspire dashboard.
builder.Services.AddSerilog((services, config) => config
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"));
builder.AddServiceDefaults();
builder.Services.AddNovelly(builder.Configuration);
builder.Services.AddOpenApi();
@@ -41,6 +49,11 @@ using (var scope = app.Services.CreateScope())
await scope.ServiceProvider.GetRequiredService<NovelDbContext>().Database.MigrateAsync();
}
// Serilog's request logging wraps the exception handler (registered first = outermost)
// so it reads the status code the handler already resolved, rather than seeing the raw
// exception fly past and misreporting a handled 404 as a 500.
app.UseSerilogRequestLogging();
app.UseExceptionHandler(handler => handler.Run(async context =>
{
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
@@ -57,6 +70,10 @@ app.UseExceptionHandler(handler => handler.Run(async context =>
{
app.Logger.LogError(exception, "Unhandled exception on {Path}", context.Request.Path);
}
else
{
app.Logger.LogWarning(exception, "Handled {StatusCode} on {Path}: {Title}", status, context.Request.Path, title);
}
await Results
.Problem(title: title, detail: exception?.Message, statusCode: status)
+3 -1
View File
@@ -1,10 +1,12 @@
using Novelly.Api.Common;
namespace Novelly.Api.Projects;
public static class ProjectEndpoints
{
public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/projects").WithTags("Projects");
var group = app.MapGroup("/api/projects").WithTags("Projects").AddEndpointFilter<RequestLoggingEndpointFilter>();
group.MapGet("/", async (ProjectService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(ct)))
+32 -8
View File
@@ -4,10 +4,13 @@ using Novelly.Api.Data;
namespace Novelly.Api.Projects;
public class ProjectService(INovelDbContext db)
public class ProjectService(INovelDbContext db, ILogger<ProjectService> logger)
{
public async Task<IReadOnlyList<ProjectSummaryDto>> ListAsync(CancellationToken ct = default) =>
await db.Projects
public async Task<IReadOnlyList<ProjectSummaryDto>> ListAsync(CancellationToken ct = default)
{
logger.LogInformation("Listing projects");
return await db.Projects
.OrderByDescending(p => p.UpdatedAt)
.Select(p => new ProjectSummaryDto(
p.Id,
@@ -21,12 +24,18 @@ public class ProjectService(INovelDbContext db)
p.Chapters.SelectMany(c => c.Scenes).Sum(s => (int?)s.WordCount) ?? 0,
p.UpdatedAt))
.ToListAsync(ct);
}
public async Task<ProjectDto> GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
public async Task<ProjectDto> GetAsync(Guid id, CancellationToken ct = default)
{
logger.LogInformation("Getting project {ProjectId}", id);
return (await FindAsync(id, ct)).ToDto();
}
public async Task<ProjectDto> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
{
logger.LogInformation("Creating project {Title}", request.Title);
var project = new Project
{
Title = request.Title,
@@ -45,6 +54,8 @@ public class ProjectService(INovelDbContext db)
public async Task<ProjectDto> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
{
logger.LogInformation("Updating project {ProjectId}", id);
var project = await FindAsync(id, ct);
project.Title = Patch.Apply(project.Title, request.Title) ?? project.Title;
@@ -62,12 +73,25 @@ public class ProjectService(INovelDbContext db)
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
logger.LogInformation("Deleting project {ProjectId}", id);
var project = await FindAsync(id, ct);
db.Projects.Remove(project);
await db.SaveChangesAsync(ct);
}
private async Task<Project> FindAsync(Guid id, CancellationToken ct) =>
await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct)
?? throw new NotFoundException(nameof(Project), id);
private async Task<Project> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding project {ProjectId}", id);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct);
if (project is null)
{
logger.LogWarning("Project {ProjectId} not found", id);
throw new NotFoundException(nameof(Project), id);
}
logger.LogDebug("Found project {ProjectId}", id);
return project;
}
}
@@ -1,10 +1,12 @@
using Novelly.Api.Common;
namespace Novelly.Api.Questions;
public static class OpenQuestionEndpoints
{
public static IEndpointRouteBuilder MapOpenQuestionEndpoints(this IEndpointRouteBuilder app)
{
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/questions").WithTags("Questions");
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/questions").WithTags("Questions").AddEndpointFilter<RequestLoggingEndpointFilter>();
projectScoped.MapGet("/", async (
Guid projectId,
@@ -24,7 +26,7 @@ public static class OpenQuestionEndpoints
})
.WithSummary("Raise an open question, optionally against a chapter outline and/or a character.");
var questions = app.MapGroup("/api/questions").WithTags("Questions");
var questions = app.MapGroup("/api/questions").WithTags("Questions").AddEndpointFilter<RequestLoggingEndpointFilter>();
questions.MapGet("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct)))
@@ -11,7 +11,7 @@ namespace Novelly.Api.Questions;
/// The project's open questions — the decisions still outstanding. A question can be
/// attached to a chapter outline, a character, both, or neither.
/// </summary>
public class OpenQuestionService(INovelDbContext db)
public class OpenQuestionService(INovelDbContext db, ILogger<OpenQuestionService> logger)
{
/// <summary>
/// Lists a project's questions, open ones first and newest first within each group.
@@ -25,6 +25,10 @@ public class OpenQuestionService(INovelDbContext db)
bool includeResolved = false,
CancellationToken ct = default)
{
logger.LogInformation(
"Listing open questions for project {ProjectId}, chapter {ChapterId}, character {CharacterId}, includeResolved {IncludeResolved}",
projectId, chapterId, characterId, includeResolved);
var query = Query().Where(q => q.ProjectId == projectId);
if (chapterId is { } cid)
@@ -53,19 +57,26 @@ public class OpenQuestionService(INovelDbContext db)
];
}
public async Task<OpenQuestionDto> GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
public async Task<OpenQuestionDto> GetAsync(Guid id, CancellationToken ct = default)
{
logger.LogInformation("Getting open question {QuestionId}", id);
return (await FindAsync(id, ct)).ToDto();
}
public async Task<OpenQuestionDto> CreateAsync(
Guid projectId, CreateOpenQuestionRequest request, CancellationToken ct = default)
{
logger.LogInformation("Creating open question for project {ProjectId}", projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
logger.LogWarning("Project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId);
}
if (string.IsNullOrWhiteSpace(request.Question))
{
logger.LogWarning("Rejected open question creation for project {ProjectId}: question text was blank", projectId);
throw new ArgumentException("A question needs to say something.");
}
@@ -88,6 +99,8 @@ public class OpenQuestionService(INovelDbContext db)
public async Task<OpenQuestionDto> UpdateAsync(
Guid id, UpdateOpenQuestionRequest request, CancellationToken ct = default)
{
logger.LogInformation("Updating open question {QuestionId}", id);
var question = await FindAsync(id, ct);
await ValidateAssociationsAsync(question.ProjectId, request.ChapterId, request.CharacterId, ct);
@@ -110,10 +123,13 @@ public class OpenQuestionService(INovelDbContext db)
public async Task<OpenQuestionDto> ResolveAsync(
Guid id, ResolveOpenQuestionRequest request, CancellationToken ct = default)
{
logger.LogInformation("Resolving open question {QuestionId}, appendToNotes {AppendToNotes}", id, request.AppendToNotes);
var question = await FindAsync(id, ct);
if (string.IsNullOrWhiteSpace(request.Resolution))
{
logger.LogWarning("Rejected resolution for open question {QuestionId}: resolution text was blank", id);
throw new ArgumentException("A resolution needs to say what was decided.");
}
@@ -127,6 +143,8 @@ public class OpenQuestionService(INovelDbContext db)
if (question.ChapterId is { } chapterId)
{
logger.LogDebug("Appending resolution note to chapter {ChapterId}", chapterId);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
if (chapter is not null)
{
@@ -137,6 +155,8 @@ public class OpenQuestionService(INovelDbContext db)
if (question.CharacterId is { } characterId)
{
logger.LogDebug("Appending resolution note to character {CharacterId}", characterId);
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct);
if (character is not null)
{
@@ -153,6 +173,8 @@ public class OpenQuestionService(INovelDbContext db)
/// <summary>Puts a question back on the list. The resolution goes; anything already appended to notes stays.</summary>
public async Task<OpenQuestionDto> ReopenAsync(Guid id, CancellationToken ct = default)
{
logger.LogInformation("Reopening open question {QuestionId}", id);
var question = await FindAsync(id, ct);
question.Resolution = null;
@@ -165,6 +187,8 @@ public class OpenQuestionService(INovelDbContext db)
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
logger.LogInformation("Deleting open question {QuestionId}", id);
var question = await FindAsync(id, ct);
db.OpenQuestions.Remove(question);
await db.SaveChangesAsync(ct);
@@ -177,9 +201,12 @@ public class OpenQuestionService(INovelDbContext db)
private async Task ValidateAssociationsAsync(
Guid projectId, Guid? chapterId, Guid? characterId, CancellationToken ct)
{
logger.LogDebug("Validating associations for project {ProjectId}: chapter {ChapterId}, character {CharacterId}", projectId, chapterId, characterId);
if (chapterId is { } cid
&& !await db.Chapters.AnyAsync(c => c.Id == cid && c.ProjectId == projectId, ct))
{
logger.LogWarning("Rejected question association: chapter {ChapterId} does not belong to project {ProjectId}", cid, projectId);
throw new InvalidOperationException(
"A question can only be attached to a chapter in the same project.");
}
@@ -187,6 +214,7 @@ public class OpenQuestionService(INovelDbContext db)
if (characterId is { } chid
&& !await db.Characters.AnyAsync(c => c.Id == chid && c.ProjectId == projectId, ct))
{
logger.LogWarning("Rejected question association: character {CharacterId} does not belong to project {ProjectId}", chid, projectId);
throw new InvalidOperationException(
"A question can only be attached to a character in the same project.");
}
@@ -195,7 +223,18 @@ public class OpenQuestionService(INovelDbContext db)
private IQueryable<OpenQuestion> Query() =>
db.OpenQuestions.Include(q => q.Chapter).Include(q => q.Character);
private async Task<OpenQuestion> FindAsync(Guid id, CancellationToken ct) =>
await Query().FirstOrDefaultAsync(q => q.Id == id, ct)
?? throw new NotFoundException(nameof(OpenQuestion), id);
private async Task<OpenQuestion> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding open question {QuestionId}", id);
var question = await Query().FirstOrDefaultAsync(q => q.Id == id, ct);
if (question is null)
{
logger.LogWarning("OpenQuestion {QuestionId} not found", id);
throw new NotFoundException(nameof(OpenQuestion), id);
}
logger.LogDebug("Found open question {QuestionId}", id);
return question;
}
}
+4 -2
View File
@@ -1,10 +1,12 @@
using Novelly.Api.Common;
namespace Novelly.Api.Scenes;
public static class SceneEndpoints
{
public static IEndpointRouteBuilder MapSceneEndpoints(this IEndpointRouteBuilder app)
{
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/scenes").WithTags("Scenes");
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/scenes").WithTags("Scenes").AddEndpointFilter<RequestLoggingEndpointFilter>();
chapterScoped.MapGet("/", async (Guid chapterId, SceneService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(chapterId, ct)))
@@ -18,7 +20,7 @@ public static class SceneEndpoints
})
.WithSummary("Add a scene to a chapter.");
var scenes = app.MapGroup("/api/scenes").WithTags("Scenes");
var scenes = app.MapGroup("/api/scenes").WithTags("Scenes").AddEndpointFilter<RequestLoggingEndpointFilter>();
scenes.MapGet("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct)))
+31 -6
View File
@@ -5,10 +5,12 @@ using Novelly.Api.Data;
namespace Novelly.Api.Scenes;
public class SceneService(INovelDbContext db)
public class SceneService(INovelDbContext db, ILogger<SceneService> logger)
{
public async Task<IReadOnlyList<SceneDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
{
logger.LogInformation("Listing scenes for chapter {ChapterId}", chapterId);
var scenes = await Query()
.Where(s => s.ChapterId == chapterId)
.OrderBy(s => s.SortOrder)
@@ -17,13 +19,19 @@ public class SceneService(INovelDbContext db)
return [.. scenes.Select(s => s.ToDto())];
}
public async Task<SceneDto> GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
public async Task<SceneDto> GetAsync(Guid id, CancellationToken ct = default)
{
logger.LogInformation("Getting scene {SceneId}", id);
return (await FindAsync(id, ct)).ToDto();
}
public async Task<SceneDto> CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default)
{
logger.LogInformation("Creating scene {Title} for chapter {ChapterId}, prose length {ProseLength}", request.Title, chapterId, request.Prose?.Length ?? 0);
if (!await db.Chapters.AnyAsync(c => c.Id == chapterId, ct))
{
logger.LogWarning("Chapter {ChapterId} not found", chapterId);
throw new NotFoundException(nameof(Chapter), chapterId);
}
@@ -50,6 +58,8 @@ public class SceneService(INovelDbContext db)
public async Task<SceneDto> UpdateAsync(Guid id, UpdateSceneRequest request, CancellationToken ct = default)
{
logger.LogInformation("Updating scene {SceneId}, prose length {ProseLength}", id, request.Prose?.Length ?? 0);
var scene = await FindAsync(id, ct);
scene.Title = Patch.Apply(scene.Title, request.Title) ?? scene.Title;
@@ -76,6 +86,8 @@ public class SceneService(INovelDbContext db)
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
logger.LogInformation("Deleting scene {SceneId}", id);
var scene = await FindAsync(id, ct);
db.Scenes.Remove(scene);
await db.SaveChangesAsync(ct);
@@ -83,6 +95,8 @@ public class SceneService(INovelDbContext db)
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
{
logger.LogDebug("Computing next sort order for chapter {ChapterId}", chapterId);
var max = await db.Scenes
.Where(s => s.ChapterId == chapterId)
.MaxAsync(s => (int?)s.SortOrder, ct);
@@ -92,7 +106,18 @@ public class SceneService(INovelDbContext db)
private IQueryable<Scene> Query() => db.Scenes.Include(s => s.PovCharacter);
private async Task<Scene> FindAsync(Guid id, CancellationToken ct) =>
await Query().FirstOrDefaultAsync(s => s.Id == id, ct)
?? throw new NotFoundException(nameof(Scene), id);
private async Task<Scene> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding scene {SceneId}", id);
var scene = await Query().FirstOrDefaultAsync(s => s.Id == id, ct);
if (scene is null)
{
logger.LogWarning("Scene {SceneId} not found", id);
throw new NotFoundException(nameof(Scene), id);
}
logger.LogDebug("Found scene {SceneId}", id);
return scene;
}
}
+4 -2
View File
@@ -1,10 +1,12 @@
using Novelly.Api.Common;
namespace Novelly.Api.Tags;
public static class TagEndpoints
{
public static IEndpointRouteBuilder MapTagEndpoints(this IEndpointRouteBuilder app)
{
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/tags").WithTags("Tags");
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/tags").WithTags("Tags").AddEndpointFilter<RequestLoggingEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, TagService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(projectId, ct)))
@@ -18,7 +20,7 @@ public static class TagEndpoints
})
.WithSummary("Create a tag. Tags are also created on demand when applied by name.");
var tags = app.MapGroup("/api/tags").WithTags("Tags");
var tags = app.MapGroup("/api/tags").WithTags("Tags").AddEndpointFilter<RequestLoggingEndpointFilter>();
tags.MapGet("/{id:guid}/references", async (Guid id, TagService service, CancellationToken ct) =>
Results.Ok(await service.GetReferencesAsync(id, ct)))
+43 -9
View File
@@ -5,27 +5,38 @@ using Novelly.Api.Projects;
namespace Novelly.Api.Tags;
public class TagService(INovelDbContext db)
public class TagService(INovelDbContext db, ILogger<TagService> logger)
{
public async Task<IReadOnlyList<TagSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default) =>
await db.Tags
public async Task<IReadOnlyList<TagSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default)
{
logger.LogInformation("Listing tags for project {ProjectId}", projectId);
return await db.Tags
.Where(t => t.ProjectId == projectId)
.OrderBy(t => t.Name)
.Select(t => new TagSummaryDto(
t.Id, t.Name, t.Color,
t.Characters.Count, t.Chapters.Count, t.Beats.Count))
.ToListAsync(ct);
}
/// <summary>Everything in the project carrying this tag.</summary>
public async Task<TagReferencesDto> GetReferencesAsync(Guid tagId, CancellationToken ct = default)
{
logger.LogInformation("Getting references for tag {TagId}", tagId);
var tag = await db.Tags
.Include(t => t.Characters)
.Include(t => t.Chapters)
.Include(t => t.Beats).ThenInclude(b => b.Character)
.Include(t => t.Beats).ThenInclude(b => b.Chapter)
.FirstOrDefaultAsync(t => t.Id == tagId, ct)
?? throw new NotFoundException(nameof(Tag), tagId);
.FirstOrDefaultAsync(t => t.Id == tagId, ct);
if (tag is null)
{
logger.LogWarning("Tag {TagId} not found", tagId);
throw new NotFoundException(nameof(Tag), tagId);
}
return new TagReferencesDto(
tag.ToDto(),
@@ -51,20 +62,25 @@ public class TagService(INovelDbContext db)
public async Task<TagDto> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default)
{
logger.LogInformation("Creating tag {Name} for project {ProjectId}", request.Name, projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
logger.LogWarning("Project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId);
}
var name = TagMapping.Normalise(request.Name);
if (string.IsNullOrWhiteSpace(name))
{
logger.LogWarning("Rejected tag creation for project {ProjectId}: name was blank", projectId);
throw new ArgumentException("A tag needs a name.");
}
var existing = await FindByNameAsync(projectId, name, ct);
if (existing is not null)
{
logger.LogWarning("Rejected tag creation for project {ProjectId}: '{Name}' already exists", projectId, existing.Name);
throw new InvalidOperationException($"The project already has a tag called '{existing.Name}'.");
}
@@ -76,20 +92,28 @@ public class TagService(INovelDbContext db)
public async Task<TagDto> UpdateAsync(Guid tagId, UpdateTagRequest request, CancellationToken ct = default)
{
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct)
?? throw new NotFoundException(nameof(Tag), tagId);
logger.LogInformation("Updating tag {TagId}", tagId);
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct);
if (tag is null)
{
logger.LogWarning("Tag {TagId} not found", tagId);
throw new NotFoundException(nameof(Tag), tagId);
}
if (request.Name is not null)
{
var name = TagMapping.Normalise(request.Name);
if (string.IsNullOrWhiteSpace(name))
{
logger.LogWarning("Rejected update for tag {TagId}: name was blank", tagId);
throw new ArgumentException("A tag needs a name.");
}
var clash = await FindByNameAsync(tag.ProjectId, name, ct);
if (clash is not null && clash.Id != tag.Id)
{
logger.LogWarning("Rejected update for tag {TagId}: '{Name}' already exists as {ClashTagId}", tagId, clash.Name, clash.Id);
throw new InvalidOperationException($"The project already has a tag called '{clash.Name}'.");
}
@@ -104,8 +128,14 @@ public class TagService(INovelDbContext db)
/// <summary>Deletes a tag. Whatever carried it keeps existing — only the label goes.</summary>
public async Task DeleteAsync(Guid tagId, CancellationToken ct = default)
{
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct)
?? throw new NotFoundException(nameof(Tag), tagId);
logger.LogInformation("Deleting tag {TagId}", tagId);
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct);
if (tag is null)
{
logger.LogWarning("Tag {TagId} not found", tagId);
throw new NotFoundException(nameof(Tag), tagId);
}
db.Tags.Remove(tag);
await db.SaveChangesAsync(ct);
@@ -119,6 +149,8 @@ public class TagService(INovelDbContext db)
internal async Task<List<Tag>> ResolveAsync(
Guid projectId, IReadOnlyList<string> names, CancellationToken ct)
{
logger.LogDebug("Resolving {Count} tag names for project {ProjectId}", names.Count, projectId);
var wanted = names
.Select(TagMapping.Normalise)
.Where(n => !string.IsNullOrWhiteSpace(n))
@@ -127,6 +159,7 @@ public class TagService(INovelDbContext db)
if (wanted.Count == 0)
{
logger.LogDebug("No usable tag names for project {ProjectId}", projectId);
return [];
}
@@ -150,6 +183,7 @@ public class TagService(INovelDbContext db)
resolved.Add(match);
}
logger.LogDebug("Resolved {Count} tags for project {ProjectId}", resolved.Count, projectId);
return resolved;
}
@@ -5,5 +5,15 @@
"Novelly": "Debug",
"Microsoft.AspNetCore": "Warning"
}
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Novelly": "Debug",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "Fatal"
}
}
}
}
+11
View File
@@ -6,6 +6,17 @@
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Novelly": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "Fatal",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Novel": "Data Source=novel.db"