Remove NotFoundException; services return null on lookup miss

A missing record isn't exceptional — services now return null (logged
at Info) instead of throwing, and endpoints map null to 404. Agent and
import toolsets route not-found through their existing OrNotFound
result pattern rather than a caught exception.
This commit is contained in:
James Wampler
2026-08-06 21:22:40 -07:00
parent 189ebf3237
commit 2ccebb31eb
22 changed files with 305 additions and 212 deletions
+3 -1
View File
@@ -23,7 +23,9 @@ public static class AgentEndpoints
CancellationToken ct) => CancellationToken ct) =>
{ {
var reply = await agent.SendMessageAsync(projectId, request, ct); var reply = await agent.SendMessageAsync(projectId, request, ct);
return Results.Ok(new AgentTurnResponse(reply.ConversationId, reply.ToResponse())); return reply is null
? Results.NotFound()
: Results.Ok(new AgentTurnResponse(reply.ConversationId, reply.ToResponse()));
}) })
.WithSummary("Send a message to the writing agent and run it to completion."); .WithSummary("Send a message to the writing agent and run it to completion.");
+29 -25
View File
@@ -71,9 +71,11 @@ public class NovelAgentService(
/// <summary> /// <summary>
/// Sends a message to the agent and runs it to completion, executing any tools it /// Sends a message to the agent and runs it to completion, executing any tools it
/// calls along the way. Returns the assistant's final turn. /// calls along the way. Returns the assistant's final turn. Null when no project has
/// this id, or the request names a conversation that does not exist — a lookup miss
/// is expected, not exceptional.
/// </summary> /// </summary>
public async Task<AgentMessage> SendMessageAsync( public async Task<AgentMessage?> SendMessageAsync(
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default) Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(projectId, nameof(projectId));
@@ -84,19 +86,37 @@ public class NovelAgentService(
"Sending agent message for project {ProjectId}, conversation {ConversationId}, message length {MessageLength}", "Sending agent message for project {ProjectId}, conversation {ConversationId}, message length {MessageLength}",
projectId, request.ConversationId, request.Message.Length); projectId, request.ConversationId, request.Message.Length);
var conversation = request.ConversationId is { } id var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
? await FindConversationAsync(id, ct) if (project is null)
{
logger.LogInformation("Project {ProjectId} not found", projectId);
return null;
}
AgentConversation conversation;
if (request.ConversationId is { } id)
{
// The id came from the request body, not the route — an unknown id here // The id came from the request body, not the route — an unknown id here
// is bad input to this call, not a direct "fetch conversation" lookup. // is bad input to this call, not a direct "fetch conversation" lookup.
?? throw new NotFoundException(nameof(AgentConversation), id) var found = await FindConversationAsync(id, ct);
: await StartConversationAsync(projectId, request.Message, ct); if (found is null)
{
return null;
}
conversation = found;
}
else
{
conversation = StartConversation(projectId, request.Message);
}
// Persist the user's turn before running the loop. The tools save through the // Persist the user's turn before running the loop. The tools save through the
// same DbContext, so leaving this pending would entangle it with their writes — // same DbContext, so leaving this pending would entangle it with their writes —
// and recording the question even if the model call fails is the behaviour we want. // and recording the question even if the model call fails is the behaviour we want.
await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct); await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct);
var systemPrompt = await BuildSystemPromptAsync(projectId, ct); var systemPrompt = BuildSystemPrompt(project);
var transcript = BuildTranscript(conversation); var transcript = BuildTranscript(conversation);
var toolCalls = new List<ToolCallResponse>(); var toolCalls = new List<ToolCallResponse>();
var text = new StringBuilder(); var text = new StringBuilder();
@@ -197,17 +217,10 @@ public class NovelAgentService(
return message; return message;
} }
private async Task<AgentConversation> StartConversationAsync( private AgentConversation StartConversation(Guid projectId, string firstMessage)
Guid projectId, string firstMessage, CancellationToken ct)
{ {
logger.LogDebug("Starting new agent conversation for project {ProjectId}", projectId); 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);
}
var conversation = new AgentConversation var conversation = new AgentConversation
{ {
ProjectId = projectId, ProjectId = projectId,
@@ -249,17 +262,8 @@ public class NovelAgentService(
[new AgentTextBlock(m.Content)])) [new AgentTextBlock(m.Content)]))
]; ];
private async Task<string> BuildSystemPromptAsync(Guid projectId, CancellationToken ct) private static string BuildSystemPrompt(Project project)
{ {
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(); var brief = new StringBuilder();
brief.AppendLine($"Title: {project.Title}"); brief.AppendLine($"Title: {project.Title}");
if (!string.IsNullOrWhiteSpace(project.Genre)) brief.AppendLine($"Genre: {project.Genre}"); if (!string.IsNullOrWhiteSpace(project.Genre)) brief.AppendLine($"Genre: {project.Genre}");
+41 -26
View File
@@ -83,11 +83,6 @@ public class NovelAgentToolset(
logger.LogDebug("Tool {Tool} for project {ProjectId} succeeded", name, projectId); logger.LogDebug("Tool {Tool} for project {ProjectId} succeeded", name, projectId);
return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false); 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) catch (ArgumentException ex)
{ {
logger.LogWarning(ex, "Tool {Tool} for project {ProjectId} failed: invalid argument", name, projectId); logger.LogWarning(ex, "Tool {Tool} for project {ProjectId} failed: invalid argument", name, projectId);
@@ -157,7 +152,7 @@ public class NovelAgentToolset(
"Add a character dossier. Name is the only requirement — leave fields blank when " "Add a character dossier. Name is the only requirement — leave fields blank when "
+ "the writer has not decided them yet rather than inventing detail.", + "the writer has not decided them yet rather than inventing detail.",
CharacterSchema(includeName: true, nameRequired: true).Build(), CharacterSchema(includeName: true, nameRequired: true).Build(),
async (projectId, input, ct) => (await characters.CreateAsync(projectId, new CreateCharacterRequest( async (projectId, input, ct) => await OrNotFound(characters.CreateAsync(projectId, new CreateCharacterRequest(
JsonInput.RequiredString(input, "name"), JsonInput.RequiredString(input, "name"),
JsonInput.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting, JsonInput.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting,
JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting, JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
@@ -174,7 +169,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "arc_summary"), JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"), JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct)).ToResponse()); JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Project", projectId));
yield return new AgentTool( yield return new AgentTool(
"update_character", "update_character",
@@ -224,8 +219,11 @@ public class NovelAgentToolset(
.Str("chapter_id", "Id of the chapter the beat belongs to.", required: true) .Str("chapter_id", "Id of the chapter the beat belongs to.", required: true)
.Str("title", "Three to five words naming the beat.", required: true) .Str("title", "Three to five words naming the beat.", required: true)
.Build(), .Build(),
async (_, input, ct) => (await beats.CreateAsync( async (_, input, ct) =>
JsonInput.RequiredGuid(input, "chapter_id"), {
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return await OrNotFound(beats.CreateAsync(
chapterId,
new CreateBeatRequest( new CreateBeatRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "sort_order"), JsonInput.Int(input, "sort_order"),
@@ -233,7 +231,8 @@ public class NovelAgentToolset(
JsonInput.String(input, "what_happened"), JsonInput.String(input, "what_happened"),
JsonInput.String(input, "whats_next"), JsonInput.String(input, "whats_next"),
JsonInput.Guid(input, "scene_id"), JsonInput.Guid(input, "scene_id"),
JsonInput.Strings(input, "tags")), ct)).ToResponse()); JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Chapter", chapterId);
});
yield return new AgentTool( yield return new AgentTool(
"update_beat", "update_beat",
@@ -278,12 +277,16 @@ public class NovelAgentToolset(
.Str("chapter_id", "Id of the chapter whose beats to reorder.", required: true) .Str("chapter_id", "Id of the chapter whose beats to reorder.", required: true)
.StringArray("beat_ids", "Beat ids in their new order.", required: true) .StringArray("beat_ids", "Beat ids in their new order.", required: true)
.Build(), .Build(),
async (_, input, ct) => (await beats.ReorderAsync( async (_, input, ct) =>
JsonInput.RequiredGuid(input, "chapter_id"), {
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return await OrNotFound(beats.ReorderAsync(
chapterId,
new ReorderBeatsRequest( new ReorderBeatsRequest(
[.. (JsonInput.Strings(input, "beat_ids") ?? []) [.. (JsonInput.Strings(input, "beat_ids") ?? [])
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty) .Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
.Where(g => g != Guid.Empty)]), ct)).Select(b => b.ToResponse())); .Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId);
});
yield return new AgentTool( yield return new AgentTool(
"list_tags", "list_tags",
@@ -337,7 +340,7 @@ public class NovelAgentToolset(
.Int("target_word_count", "Target length in words.") .Int("target_word_count", "Target length in words.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.") .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(), .Build(),
async (projectId, input, ct) => (await chapters.CreateAsync(projectId, new CreateChapterRequest( async (projectId, input, ct) => await OrNotFound(chapters.CreateAsync(projectId, new CreateChapterRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"), JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"), JsonInput.String(input, "summary"),
@@ -346,7 +349,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned, JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
JsonInput.Int(input, "target_word_count"), JsonInput.Int(input, "target_word_count"),
JsonInput.Strings(input, "tags")), ct)).ToResponse()); JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Project", projectId));
yield return new AgentTool( yield return new AgentTool(
"update_chapter", "update_chapter",
@@ -388,8 +391,11 @@ public class NovelAgentToolset(
.Str("chapter_id", "Id of the chapter the scene belongs to.", required: true) .Str("chapter_id", "Id of the chapter the scene belongs to.", required: true)
.Str("title", "Scene title.", required: true) .Str("title", "Scene title.", required: true)
.Build(), .Build(),
async (_, input, ct) => (await scenes.CreateAsync( async (_, input, ct) =>
JsonInput.RequiredGuid(input, "chapter_id"), {
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return await OrNotFound(scenes.CreateAsync(
chapterId,
new CreateSceneRequest( new CreateSceneRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "sort_order"), JsonInput.Int(input, "sort_order"),
@@ -400,7 +406,8 @@ public class NovelAgentToolset(
JsonInput.Guid(input, "pov_character_id"), JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "location"), JsonInput.String(input, "location"),
JsonInput.String(input, "prose"), JsonInput.String(input, "prose"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned), ct)).ToResponse()); JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned), ct), s => s.ToResponse(), "Chapter", chapterId);
});
yield return new AgentTool( yield return new AgentTool(
"update_scene", "update_scene",
@@ -464,13 +471,17 @@ public class NovelAgentToolset(
.Str("character_id", "Id of the character whose arc to add to.", required: true) .Str("character_id", "Id of the character whose arc to add to.", required: true)
.Str("title", "A short handle for the change, three to five words.", required: true) .Str("title", "A short handle for the change, three to five words.", required: true)
.Build(), .Build(),
async (_, input, ct) => (await arcs.CreateAsync( async (_, input, ct) =>
JsonInput.RequiredGuid(input, "character_id"), {
var characterId = JsonInput.RequiredGuid(input, "character_id");
return await OrNotFound(arcs.CreateAsync(
characterId,
new CreateArcStageRequest( new CreateArcStageRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "sort_order"), JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "description"), JsonInput.String(input, "description"),
JsonInput.Guid(input, "chapter_id")), ct)).ToResponse()); JsonInput.Guid(input, "chapter_id")), ct), s => s.ToResponse(), "Character", characterId);
});
yield return new AgentTool( yield return new AgentTool(
"update_arc_stage", "update_arc_stage",
@@ -511,10 +522,14 @@ public class NovelAgentToolset(
.Str("character_id", "Id of the character whose arc to reorder.", required: true) .Str("character_id", "Id of the character whose arc to reorder.", required: true)
.StringArray("stage_ids", "Arc stage ids in the order wanted.", required: true) .StringArray("stage_ids", "Arc stage ids in the order wanted.", required: true)
.Build(), .Build(),
async (_, input, ct) => (await arcs.ReorderAsync( async (_, input, ct) =>
JsonInput.RequiredGuid(input, "character_id"), {
var characterId = JsonInput.RequiredGuid(input, "character_id");
return await OrNotFound(arcs.ReorderAsync(
characterId,
new ReorderArcStagesRequest( new ReorderArcStagesRequest(
[.. JsonInput.Strings(input, "stage_ids")?.Select(Guid.Parse) ?? []]), ct)).Select(s => s.ToResponse())); [.. JsonInput.Strings(input, "stage_ids")?.Select(Guid.Parse) ?? []]), ct), list => list.Select(s => s.ToResponse()), "Character", characterId);
});
yield return new AgentTool( yield return new AgentTool(
"list_open_questions", "list_open_questions",
@@ -543,13 +558,13 @@ public class NovelAgentToolset(
.Str("chapter_id", "The chapter outline this is about, if any.") .Str("chapter_id", "The chapter outline this is about, if any.")
.Str("character_id", "The character this is about, if any.") .Str("character_id", "The character this is about, if any.")
.Build(), .Build(),
async (projectId, input, ct) => (await questions.CreateAsync( async (projectId, input, ct) => await OrNotFound(questions.CreateAsync(
projectId, projectId,
new CreateOpenQuestionRequest( new CreateOpenQuestionRequest(
JsonInput.RequiredString(input, "question"), JsonInput.RequiredString(input, "question"),
JsonInput.String(input, "detail"), JsonInput.String(input, "detail"),
JsonInput.Guid(input, "chapter_id"), JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id")), ct)).ToResponse()); JsonInput.Guid(input, "character_id")), ct), q => q.ToResponse(), "Project", projectId));
yield return new AgentTool( yield return new AgentTool(
"resolve_open_question", "resolve_open_question",
+8 -2
View File
@@ -18,14 +18,20 @@ public static class BeatEndpoints
chapterScoped.MapPost("/", async ( chapterScoped.MapPost("/", async (
Guid chapterId, CreateBeatRequest request, BeatService service, CancellationToken ct) => Guid chapterId, CreateBeatRequest request, BeatService service, CancellationToken ct) =>
{ {
var created = (await service.CreateAsync(chapterId, request, ct)).ToResponse(); var beat = await service.CreateAsync(chapterId, request, ct);
if (beat is null)
{
return Results.NotFound();
}
var created = beat.ToResponse();
return Results.Created($"/api/beats/{created.Id}", created); return Results.Created($"/api/beats/{created.Id}", created);
}) })
.WithSummary("Add a beat to a chapter's outline."); .WithSummary("Add a beat to a chapter's outline.");
chapterScoped.MapPost("/reorder", async ( chapterScoped.MapPost("/reorder", async (
Guid chapterId, ReorderBeatsRequest request, BeatService service, CancellationToken ct) => Guid chapterId, ReorderBeatsRequest request, BeatService service, CancellationToken ct) =>
Results.Ok((await service.ReorderAsync(chapterId, request, ct)).Select(b => b.ToResponse()))) (await service.ReorderAsync(chapterId, request, ct))?.Select(b => b.ToResponse()).ToList().ToApiResult())
.WithSummary("Renumber a chapter's beats to match the order given."); .WithSummary("Renumber a chapter's beats to match the order given.");
app.MapGet("/api/characters/{characterId:guid}/beats", async ( app.MapGet("/api/characters/{characterId:guid}/beats", async (
+11 -9
View File
@@ -74,7 +74,8 @@ public class BeatService(
]; ];
} }
public async Task<Beat> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default) /// <summary>Null when no chapter has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<Beat?> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
{ {
Guard.Default(chapterId, nameof(chapterId)); Guard.Default(chapterId, nameof(chapterId));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
@@ -85,8 +86,8 @@ public class BeatService(
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct); var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
if (chapter is null) if (chapter is null)
{ {
logger.LogWarning("Rejected beat creation: chapter {ChapterId} not found", chapterId); logger.LogInformation("Rejected beat creation: chapter {ChapterId} not found", chapterId);
throw new NotFoundException(nameof(Chapter), chapterId); return null;
} }
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct); await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
@@ -131,10 +132,10 @@ public class BeatService(
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct); var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
if (chapter is null) if (chapter is null)
{ {
// The beat's own chapter should always exist via the FK — this is an // The beat's own chapter should always exist via the FK — an invariant
// invariant failing, not a caller mistake, so it stays exceptional. // failing, not a caller mistake, but still not found so still just null.
logger.LogError("Beat {BeatId} references chapter {ChapterId} which does not exist", id, beat.ChapterId); logger.LogError("Beat {BeatId} references chapter {ChapterId} which does not exist", id, beat.ChapterId);
throw new NotFoundException(nameof(Chapter), beat.ChapterId); return null;
} }
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct); await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
@@ -178,7 +179,8 @@ public class BeatService(
/// Renumbers a chapter's beats to match the order given. Sending the whole list beats /// Renumbers a chapter's beats to match the order given. Sending the whole list beats
/// patching sort orders one at a time, which is fiddly to get right from a drag handle. /// patching sort orders one at a time, which is fiddly to get right from a drag handle.
/// </summary> /// </summary>
public async Task<IReadOnlyList<Beat>> ReorderAsync( /// <summary>Null when the chapter carries a beat id it does not own — a lookup miss is expected, not exceptional.</summary>
public async Task<IReadOnlyList<Beat>?> ReorderAsync(
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default) Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
{ {
Guard.Default(chapterId, nameof(chapterId)); Guard.Default(chapterId, nameof(chapterId));
@@ -192,8 +194,8 @@ public class BeatService(
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList(); var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
if (missing.Count > 0) if (missing.Count > 0)
{ {
logger.LogWarning("Reorder for chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]); logger.LogInformation("Reorder for chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]);
throw new NotFoundException(nameof(Beat), missing[0]); return null;
} }
// Listed beats take the order given; anything omitted keeps its relative position // Listed beats take the order given; anything omitted keeps its relative position
+7 -1
View File
@@ -18,7 +18,13 @@ public static class ChapterEndpoints
projectScoped.MapPost("/", async ( projectScoped.MapPost("/", async (
Guid projectId, CreateChapterRequest request, ChapterService service, CancellationToken ct) => Guid projectId, CreateChapterRequest request, ChapterService service, CancellationToken ct) =>
{ {
var created = (await service.CreateAsync(projectId, request, ct)).ToResponse(); var chapter = await service.CreateAsync(projectId, request, ct);
if (chapter is null)
{
return Results.NotFound();
}
var created = chapter.ToResponse();
return Results.Created($"/api/chapters/{created.Id}", created); return Results.Created($"/api/chapters/{created.Id}", created);
}) })
.WithSummary("Add a chapter."); .WithSummary("Add a chapter.");
+4 -4
View File
@@ -2,7 +2,6 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Projects;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Chapters; namespace Novelly.Api.Chapters;
@@ -39,7 +38,8 @@ public class ChapterService(
return await FindAsync(id, ct); return await FindAsync(id, ct);
} }
public async Task<Chapter> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default) /// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<Chapter?> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
@@ -49,8 +49,8 @@ public class ChapterService(
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{ {
logger.LogWarning("Rejected chapter creation: project {ProjectId} not found", projectId); logger.LogInformation("Rejected chapter creation: project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId); return null;
} }
var chapter = new Chapter var chapter = new Chapter
@@ -44,7 +44,8 @@ public class CharacterArcService(
return await FindAsync(id, ct); return await FindAsync(id, ct);
} }
public async Task<CharacterArcStage> CreateAsync( /// <summary>Null when no character has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<CharacterArcStage?> CreateAsync(
Guid characterId, CreateArcStageRequest request, CancellationToken ct = default) Guid characterId, CreateArcStageRequest request, CancellationToken ct = default)
{ {
Guard.Default(characterId, nameof(characterId)); Guard.Default(characterId, nameof(characterId));
@@ -56,8 +57,8 @@ public class CharacterArcService(
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct); var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct);
if (character is null) if (character is null)
{ {
logger.LogWarning("Rejected arc stage creation: character {CharacterId} not found", characterId); logger.LogInformation("Rejected arc stage creation: character {CharacterId} not found", characterId);
throw new NotFoundException(nameof(Character), characterId); return null;
} }
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct); await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
@@ -96,10 +97,10 @@ public class CharacterArcService(
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct); var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct);
if (character is null) if (character is null)
{ {
// The stage's own character should always exist via the FK — this is an // The stage's own character should always exist via the FK — an invariant
// invariant failing, not a caller mistake, so it stays exceptional. // failing, not a caller mistake, but still not found so still just null.
logger.LogError("Arc stage {ArcStageId} references character {CharacterId} which does not exist", id, stage.CharacterId); logger.LogError("Arc stage {ArcStageId} references character {CharacterId} which does not exist", id, stage.CharacterId);
throw new NotFoundException(nameof(Character), stage.CharacterId); return null;
} }
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct); await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
@@ -136,7 +137,8 @@ public class CharacterArcService(
/// Renumbers a character's arc to match the order given. Stages left out keep their /// Renumbers a character's arc to match the order given. Stages left out keep their
/// relative position after the ones listed, exactly as beat reordering works. /// relative position after the ones listed, exactly as beat reordering works.
/// </summary> /// </summary>
public async Task<IReadOnlyList<CharacterArcStage>> ReorderAsync( /// <summary>Null when the character carries a stage id it does not own — a lookup miss is expected, not exceptional.</summary>
public async Task<IReadOnlyList<CharacterArcStage>?> ReorderAsync(
Guid characterId, ReorderArcStagesRequest request, CancellationToken ct = default) Guid characterId, ReorderArcStagesRequest request, CancellationToken ct = default)
{ {
Guard.Default(characterId, nameof(characterId)); Guard.Default(characterId, nameof(characterId));
@@ -152,8 +154,8 @@ public class CharacterArcService(
var missing = request.StageIds.Where(id => stages.All(s => s.Id != id)).ToList(); var missing = request.StageIds.Where(id => stages.All(s => s.Id != id)).ToList();
if (missing.Count > 0) if (missing.Count > 0)
{ {
logger.LogWarning("Reorder for character {CharacterId} referenced missing arc stage {ArcStageId}", characterId, missing[0]); logger.LogInformation("Reorder for character {CharacterId} referenced missing arc stage {ArcStageId}", characterId, missing[0]);
throw new NotFoundException(nameof(CharacterArcStage), missing[0]); return null;
} }
var order = 1; var order = 1;
@@ -18,7 +18,13 @@ public static class CharacterEndpoints
projectScoped.MapPost("/", async ( projectScoped.MapPost("/", async (
Guid projectId, CreateCharacterRequest request, CharacterService service, CancellationToken ct) => Guid projectId, CreateCharacterRequest request, CharacterService service, CancellationToken ct) =>
{ {
var created = (await service.CreateAsync(projectId, request, ct)).ToResponse(); var character = await service.CreateAsync(projectId, request, ct);
if (character is null)
{
return Results.NotFound();
}
var created = character.ToResponse();
return Results.Created($"/api/characters/{created.Id}", created); return Results.Created($"/api/characters/{created.Id}", created);
}) })
.WithSummary("Add a character dossier."); .WithSummary("Add a character dossier.");
@@ -58,14 +64,20 @@ public static class CharacterEndpoints
characters.MapPost("/{id:guid}/arc", async ( characters.MapPost("/{id:guid}/arc", async (
Guid id, CreateArcStageRequest request, CharacterArcService service, CancellationToken ct) => Guid id, CreateArcStageRequest request, CharacterArcService service, CancellationToken ct) =>
{ {
var created = (await service.CreateAsync(id, request, ct)).ToResponse(); var stage = await service.CreateAsync(id, request, ct);
if (stage is null)
{
return Results.NotFound();
}
var created = stage.ToResponse();
return Results.Created($"/api/arc-stages/{created.Id}", created); return Results.Created($"/api/arc-stages/{created.Id}", created);
}) })
.WithSummary("Add a stage to a character's arc."); .WithSummary("Add a stage to a character's arc.");
characters.MapPost("/{id:guid}/arc/reorder", async ( characters.MapPost("/{id:guid}/arc/reorder", async (
Guid id, ReorderArcStagesRequest request, CharacterArcService service, CancellationToken ct) => Guid id, ReorderArcStagesRequest request, CharacterArcService service, CancellationToken ct) =>
Results.Ok((await service.ReorderAsync(id, request, ct)).Select(s => s.ToResponse()))) (await service.ReorderAsync(id, request, ct))?.Select(s => s.ToResponse()).ToList().ToApiResult())
.WithSummary("Renumber a character's arc to match the order given."); .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")
+9 -16
View File
@@ -2,7 +2,6 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Projects;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Characters; namespace Novelly.Api.Characters;
@@ -53,7 +52,8 @@ public class CharacterService(
return await FindAsync(id, ct); return await FindAsync(id, ct);
} }
public async Task<Character> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default) /// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<Character?> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
@@ -61,7 +61,11 @@ public class CharacterService(
logger.LogInformation("Creating character {Name} for project {ProjectId}, role {Role}, importance {Importance}", request.Name, projectId, request.Role, request.Importance); logger.LogInformation("Creating character {Name} for project {ProjectId}, role {Role}, importance {Importance}", request.Name, projectId, request.Role, request.Importance);
await EnsureProjectExists(projectId, ct); if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
logger.LogInformation("Rejected character creation: project {ProjectId} not found", projectId);
return null;
}
var character = new Character var character = new Character
{ {
@@ -174,8 +178,8 @@ public class CharacterService(
var related = await db.Characters.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct); var related = await db.Characters.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct);
if (related is null) if (related is null)
{ {
logger.LogWarning("Rejected relationship: related character {RelatedCharacterId} not found", request.RelatedCharacterId); logger.LogInformation("Rejected relationship: related character {RelatedCharacterId} not found", request.RelatedCharacterId);
throw new NotFoundException(nameof(Character), request.RelatedCharacterId); return null;
} }
if (related.ProjectId != character.ProjectId) if (related.ProjectId != character.ProjectId)
@@ -239,15 +243,4 @@ public class CharacterService(
return character; 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("Rejected character creation: project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId);
}
}
} }
@@ -1,11 +0,0 @@
namespace Novelly.Api.Common;
/// <summary>
/// Thrown when a service is asked for an entity that does not exist. The API translates
/// this into a 404 so services never have to know about HTTP.
/// </summary>
public class NotFoundException(string entity, Guid id) : Exception($"{entity} '{id}' was not found.")
{
public string Entity { get; } = entity;
public Guid Id { get; } = id;
}
+78 -24
View File
@@ -8,6 +8,13 @@ using Novelly.Api.Projects;
namespace Novelly.Api.Imports; namespace Novelly.Api.Imports;
/// <summary>
/// A lookup a tool performed came back empty. Not an exception — the underlying service
/// already said so by returning null — just a value <see cref="ImportAgentToolset.ExecuteAsync"/>
/// recognises and turns into the same error-result shape a caught exception would produce.
/// </summary>
internal record ImportToolNotFound(string Message);
/// <summary>A tool the import agent can call, bound to a handler that runs against this run's state.</summary> /// <summary>A tool the import agent can call, bound to a handler that runs against this run's state.</summary>
internal record ImportAgentTool( internal record ImportAgentTool(
string Name, string Name,
@@ -71,14 +78,16 @@ public class ImportAgentToolset(
try try
{ {
var result = await tool.Handler(input, ct); var result = await tool.Handler(input, ct);
if (result is ImportToolNotFound notFound)
{
logger.LogInformation("Import tool {Tool} found nothing: {Message}", name, notFound.Message);
return new AgentToolResult(notFound.Message, true);
}
logger.LogDebug("Import tool {Tool} succeeded", name); logger.LogDebug("Import tool {Tool} succeeded", name);
return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false); return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false);
} }
catch (NotFoundException ex)
{
logger.LogWarning(ex, "Import tool {Tool} failed: not found", name);
return new AgentToolResult(ex.Message, true);
}
catch (ArgumentException ex) catch (ArgumentException ex)
{ {
logger.LogWarning(ex, "Import tool {Tool} failed: invalid argument", name); logger.LogWarning(ex, "Import tool {Tool} failed: invalid argument", name);
@@ -223,25 +232,40 @@ public class ImportAgentToolset(
.Str("genre", "Genre or category.") .Str("genre", "Genre or category.")
.Str("notes", "Free-form notes — the blurb, if not already set.") .Str("notes", "Free-form notes — the blurb, if not already set.")
.Build(), .Build(),
async (input, ct) => (await projects.UpdateAsync(RequireProjectId(), new UpdateProjectRequest( async (input, ct) =>
{
var projectId = RequireProjectId();
var updated = await projects.UpdateAsync(projectId, new UpdateProjectRequest(
JsonInput.String(input, "title"), JsonInput.String(input, "title"),
JsonInput.String(input, "author"), JsonInput.String(input, "author"),
JsonInput.String(input, "genre"), JsonInput.String(input, "genre"),
Notes: JsonInput.String(input, "notes")), ct) Notes: JsonInput.String(input, "notes")), ct);
?? throw new NotFoundException(nameof(Project), RequireProjectId())).ToResponse());
return updated is null
? new ImportToolNotFound($"Project '{projectId}' was not found.")
: updated.ToResponse();
});
yield return new ImportAgentTool( yield return new ImportAgentTool(
"create_character", "create_character",
"Add a character dossier, parsed from a characters/*.md file.", "Add a character dossier, parsed from a characters/*.md file.",
CharacterSchema(nameRequired: true).Build(), CharacterSchema(nameRequired: true).Build(),
async (input, ct) => (await characters.CreateAsync(RequireProjectId(), new CreateCharacterRequest( async (input, ct) =>
{
var projectId = RequireProjectId();
var created = await characters.CreateAsync(projectId, new CreateCharacterRequest(
JsonInput.RequiredString(input, "name"), JsonInput.RequiredString(input, "name"),
Importance: JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting, Importance: JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
Occupation: JsonInput.String(input, "occupation"), Occupation: JsonInput.String(input, "occupation"),
Appearance: JsonInput.String(input, "appearance"), Appearance: JsonInput.String(input, "appearance"),
Backstory: JsonInput.String(input, "backstory"), Backstory: JsonInput.String(input, "backstory"),
Want: JsonInput.String(input, "want"), Want: JsonInput.String(input, "want"),
Notes: JsonInput.String(input, "notes")), ct)).ToResponse()); Notes: JsonInput.String(input, "notes")), ct);
return created is null
? new ImportToolNotFound($"Project '{projectId}' was not found.")
: created.ToResponse();
});
yield return new ImportAgentTool( yield return new ImportAgentTool(
"update_character", "update_character",
@@ -252,15 +276,18 @@ public class ImportAgentToolset(
async (input, ct) => async (input, ct) =>
{ {
var characterId = JsonInput.RequiredGuid(input, "character_id"); var characterId = JsonInput.RequiredGuid(input, "character_id");
return (await characters.UpdateAsync(characterId, new UpdateCharacterRequest( var updated = await characters.UpdateAsync(characterId, new UpdateCharacterRequest(
JsonInput.String(input, "name"), JsonInput.String(input, "name"),
Importance: JsonInput.Enum<CharacterImportance>(input, "importance"), Importance: JsonInput.Enum<CharacterImportance>(input, "importance"),
Occupation: JsonInput.String(input, "occupation"), Occupation: JsonInput.String(input, "occupation"),
Appearance: JsonInput.String(input, "appearance"), Appearance: JsonInput.String(input, "appearance"),
Backstory: JsonInput.String(input, "backstory"), Backstory: JsonInput.String(input, "backstory"),
Want: JsonInput.String(input, "want"), Want: JsonInput.String(input, "want"),
Notes: JsonInput.String(input, "notes")), ct) Notes: JsonInput.String(input, "notes")), ct);
?? throw new NotFoundException("Character", characterId)).ToResponse();
return updated is null
? new ImportToolNotFound($"Character '{characterId}' was not found.")
: updated.ToResponse();
}); });
yield return new ImportAgentTool( yield return new ImportAgentTool(
@@ -274,13 +301,21 @@ public class ImportAgentToolset(
.Str("notes", "The chapter file's ## Notes section, if present.") .Str("notes", "The chapter file's ## Notes section, if present.")
.StringArray("tags", "The Part value and the raw Thread text, e.g. ['Part I', 'thread:Logen'].") .StringArray("tags", "The Part value and the raw Thread text, e.g. ['Part I', 'thread:Logen'].")
.Build(), .Build(),
async (input, ct) => (await chapters.CreateAsync(RequireProjectId(), new CreateChapterRequest( async (input, ct) =>
{
var projectId = RequireProjectId();
var created = await chapters.CreateAsync(projectId, new CreateChapterRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"), JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"), JsonInput.String(input, "summary"),
JsonInput.Guid(input, "pov_character_id"), JsonInput.Guid(input, "pov_character_id"),
Notes: JsonInput.String(input, "notes"), Notes: JsonInput.String(input, "notes"),
Tags: JsonInput.Strings(input, "tags")), ct)).ToResponse()); Tags: JsonInput.Strings(input, "tags")), ct);
return created is null
? new ImportToolNotFound($"Project '{projectId}' was not found.")
: created.ToResponse();
});
yield return new ImportAgentTool( yield return new ImportAgentTool(
"update_chapter", "update_chapter",
@@ -294,11 +329,14 @@ public class ImportAgentToolset(
async (input, ct) => async (input, ct) =>
{ {
var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return (await chapters.UpdateAsync(chapterId, new UpdateChapterRequest( var updated = await chapters.UpdateAsync(chapterId, new UpdateChapterRequest(
Summary: JsonInput.String(input, "summary"), Summary: JsonInput.String(input, "summary"),
PovCharacterId: JsonInput.Guid(input, "pov_character_id"), PovCharacterId: JsonInput.Guid(input, "pov_character_id"),
Notes: JsonInput.String(input, "notes")), ct) Notes: JsonInput.String(input, "notes")), ct);
?? throw new NotFoundException("Chapter", chapterId)).ToResponse();
return updated is null
? new ImportToolNotFound($"Chapter '{chapterId}' was not found.")
: updated.ToResponse();
}); });
yield return new ImportAgentTool( yield return new ImportAgentTool(
@@ -311,13 +349,21 @@ public class ImportAgentToolset(
.Str("what_happened", "The What column.") .Str("what_happened", "The What column.")
.Str("whats_next", "The Why column.") .Str("whats_next", "The Why column.")
.Build(), .Build(),
async (input, ct) => (await beats.CreateAsync( async (input, ct) =>
JsonInput.RequiredGuid(input, "chapter_id"), {
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
var created = await beats.CreateAsync(
chapterId,
new CreateBeatRequest( new CreateBeatRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
CharacterId: JsonInput.Guid(input, "character_id"), CharacterId: JsonInput.Guid(input, "character_id"),
WhatHappened: JsonInput.String(input, "what_happened"), WhatHappened: JsonInput.String(input, "what_happened"),
WhatsNext: JsonInput.String(input, "whats_next")), ct)).ToResponse()); WhatsNext: JsonInput.String(input, "whats_next")), ct);
return created is null
? new ImportToolNotFound($"Chapter '{chapterId}' was not found.")
: created.ToResponse();
});
yield return new ImportAgentTool( yield return new ImportAgentTool(
"add_arc_stage", "add_arc_stage",
@@ -328,12 +374,20 @@ public class ImportAgentToolset(
.Str("description", "The bullet's text.") .Str("description", "The bullet's text.")
.Str("chapter_id", "The chapter this stage is pinned to, if the (Ch. N) marker resolves to an imported chapter.") .Str("chapter_id", "The chapter this stage is pinned to, if the (Ch. N) marker resolves to an imported chapter.")
.Build(), .Build(),
async (input, ct) => (await arcs.CreateAsync( async (input, ct) =>
JsonInput.RequiredGuid(input, "character_id"), {
var characterId = JsonInput.RequiredGuid(input, "character_id");
var created = await arcs.CreateAsync(
characterId,
new CreateArcStageRequest( new CreateArcStageRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
Description: JsonInput.String(input, "description"), Description: JsonInput.String(input, "description"),
ChapterId: JsonInput.Guid(input, "chapter_id")), ct)).ToResponse()); ChapterId: JsonInput.Guid(input, "chapter_id")), ct);
return created is null
? new ImportToolNotFound($"Character '{characterId}' was not found.")
: created.ToResponse();
});
} }
private static JsonSchemaBuilder CharacterSchema(bool nameRequired) => private static JsonSchemaBuilder CharacterSchema(bool nameRequired) =>
-1
View File
@@ -61,7 +61,6 @@ app.UseExceptionHandler(handler => handler.Run(async context =>
var (status, title) = exception switch var (status, title) = exception switch
{ {
NotFoundException => (StatusCodes.Status404NotFound, "Not found"),
AgentNotConfiguredException => (StatusCodes.Status503ServiceUnavailable, "Agent unavailable"), AgentNotConfiguredException => (StatusCodes.Status503ServiceUnavailable, "Agent unavailable"),
ArgumentException or InvalidOperationException => (StatusCodes.Status400BadRequest, "Invalid request"), ArgumentException or InvalidOperationException => (StatusCodes.Status400BadRequest, "Invalid request"),
_ => (StatusCodes.Status500InternalServerError, "Unexpected error") _ => (StatusCodes.Status500InternalServerError, "Unexpected error")
@@ -24,7 +24,13 @@ public static class OpenQuestionEndpoints
projectScoped.MapPost("/", async ( projectScoped.MapPost("/", async (
Guid projectId, CreateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) => Guid projectId, CreateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
{ {
var created = (await service.CreateAsync(projectId, request, ct)).ToResponse(); var question = await service.CreateAsync(projectId, request, ct);
if (question is null)
{
return Results.NotFound();
}
var created = question.ToResponse();
return Results.Created($"/api/questions/{created.Id}", created); return Results.Created($"/api/questions/{created.Id}", created);
}) })
.WithSummary("Raise an open question, optionally against a chapter outline and/or a character."); .WithSummary("Raise an open question, optionally against a chapter outline and/or a character.");
@@ -4,7 +4,6 @@ using Novelly.Api.Characters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Projects;
namespace Novelly.Api.Questions; namespace Novelly.Api.Questions;
@@ -73,7 +72,8 @@ public class OpenQuestionService(
return await FindAsync(id, ct); return await FindAsync(id, ct);
} }
public async Task<OpenQuestion> CreateAsync( /// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<OpenQuestion?> CreateAsync(
Guid projectId, CreateOpenQuestionRequest request, CancellationToken ct = default) Guid projectId, CreateOpenQuestionRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(projectId, nameof(projectId));
@@ -84,8 +84,8 @@ public class OpenQuestionService(
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{ {
logger.LogWarning("Rejected open question creation: project {ProjectId} not found", projectId); logger.LogInformation("Rejected open question creation: project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId); return null;
} }
await ValidateAssociationsAsync(projectId, request.ChapterId, request.CharacterId, ct); await ValidateAssociationsAsync(projectId, request.ChapterId, request.CharacterId, ct);
+7 -1
View File
@@ -18,7 +18,13 @@ public static class SceneEndpoints
chapterScoped.MapPost("/", async ( chapterScoped.MapPost("/", async (
Guid chapterId, CreateSceneRequest request, SceneService service, CancellationToken ct) => Guid chapterId, CreateSceneRequest request, SceneService service, CancellationToken ct) =>
{ {
var created = (await service.CreateAsync(chapterId, request, ct)).ToResponse(); var scene = await service.CreateAsync(chapterId, request, ct);
if (scene is null)
{
return Results.NotFound();
}
var created = scene.ToResponse();
return Results.Created($"/api/scenes/{created.Id}", created); return Results.Created($"/api/scenes/{created.Id}", created);
}) })
.WithSummary("Add a scene to a chapter."); .WithSummary("Add a scene to a chapter.");
+4 -4
View File
@@ -1,5 +1,4 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Novelly.Api.Chapters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
@@ -33,7 +32,8 @@ public class SceneService(
return await FindAsync(id, ct); return await FindAsync(id, ct);
} }
public async Task<Scene> CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default) /// <summary>Null when no chapter has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<Scene?> CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default)
{ {
Guard.Default(chapterId, nameof(chapterId)); Guard.Default(chapterId, nameof(chapterId));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
@@ -43,8 +43,8 @@ public class SceneService(
if (!await db.Chapters.AnyAsync(c => c.Id == chapterId, ct)) if (!await db.Chapters.AnyAsync(c => c.Id == chapterId, ct))
{ {
logger.LogWarning("Rejected scene creation: chapter {ChapterId} not found", chapterId); logger.LogInformation("Rejected scene creation: chapter {ChapterId} not found", chapterId);
throw new NotFoundException(nameof(Chapter), chapterId); return null;
} }
var scene = new Scene var scene = new Scene
+7 -1
View File
@@ -18,7 +18,13 @@ public static class TagEndpoints
projectScoped.MapPost("/", async ( projectScoped.MapPost("/", async (
Guid projectId, CreateTagRequest request, TagService service, CancellationToken ct) => Guid projectId, CreateTagRequest request, TagService service, CancellationToken ct) =>
{ {
var created = (await service.CreateAsync(projectId, request, ct)).ToResponse(); var tag = await service.CreateAsync(projectId, request, ct);
if (tag is null)
{
return Results.NotFound();
}
var created = tag.ToResponse();
return Results.Created($"/api/tags/{created.Id}", created); return Results.Created($"/api/tags/{created.Id}", created);
}) })
.WithSummary("Create a tag. Tags are also created on demand when applied by name."); .WithSummary("Create a tag. Tags are also created on demand when applied by name.");
+4 -4
View File
@@ -2,7 +2,6 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Projects;
namespace Novelly.Api.Tags; namespace Novelly.Api.Tags;
@@ -47,7 +46,8 @@ public class TagService(
return tag; return tag;
} }
public async Task<Tag> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default) /// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<Tag?> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
@@ -57,8 +57,8 @@ public class TagService(
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{ {
logger.LogWarning("Rejected tag creation: project {ProjectId} not found", projectId); logger.LogInformation("Rejected tag creation: project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId); return null;
} }
var name = TagMapping.Normalise(request.Name); var name = TagMapping.Normalise(request.Name);
+3 -5
View File
@@ -67,13 +67,11 @@ public class BeatServiceTests : ServiceTestFixture
} }
[Test] [Test]
public void Reordering_with_an_unknown_beat_is_refused() public async Task Reordering_with_an_unknown_beat_returns_null_rather_than_throwing()
{ {
Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")).Wait(); await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
Assert.That( Assert.That(await Beats.ReorderAsync(_chapterId, new ReorderBeatsRequest([Guid.NewGuid()])), Is.Null);
async () => await Beats.ReorderAsync(_chapterId, new ReorderBeatsRequest([Guid.NewGuid()])),
Throws.TypeOf<NotFoundException>());
} }
[Test] [Test]
+3 -4
View File
@@ -183,11 +183,10 @@ public class CharacterArcTests : ServiceTestFixture
} }
[Test] [Test]
public void Reordering_with_an_unknown_stage_is_refused() => public async Task Reordering_with_an_unknown_stage_returns_null_rather_than_throwing() =>
Assert.That( Assert.That(
async () => await Arcs.ReorderAsync( await Arcs.ReorderAsync(_characterId, new ReorderArcStagesRequest([Guid.NewGuid()])),
_characterId, new ReorderArcStagesRequest([Guid.NewGuid()])), Is.Null);
Throws.TypeOf<NotFoundException>());
[Test] [Test]
public async Task The_character_page_sees_every_beat_they_appear_in_across_the_book() public async Task The_character_page_sees_every_beat_they_appear_in_across_the_book()
@@ -46,12 +46,6 @@ public class ExceptionHandlingTests : ServiceTestFixture
Throws.TypeOf<ArgumentException>()); Throws.TypeOf<ArgumentException>());
[Test] [Test]
public async Task An_embedded_reference_to_a_missing_parent_still_throws() public async Task Creating_a_chapter_under_a_missing_project_returns_null_rather_than_throwing() =>
{ Assert.That(await Chapters.CreateAsync(Guid.NewGuid(), new CreateChapterRequest("Landfall")), Is.Null);
// Creating a chapter under a nonexistent project isn't a "look this up" miss — it's
// an invalid precondition for the create, so it stays exceptional.
Assert.That(
async () => await Chapters.CreateAsync(Guid.NewGuid(), new CreateChapterRequest("Landfall")),
Throws.TypeOf<NotFoundException>());
}
} }