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