Stop throwing for not-found; add Guard and request validation
Not-found lookups return null/false instead of throwing NotFoundException across all services — a missing row is expected control flow, not an exceptional condition. NotFoundException stays for embedded precondition checks inside mutations (missing parent, invalid foreign reference). Guard (copied from mic-check) enforces required arguments at the top of every service method. A ported IModelValidator<T> framework validates every request DTO at the API layer via a new ValidationEndpointFilter, returning a 400 with field-level messages; services re-run the same validator and throw for direct callers that bypass the API. Endpoints translate null/false into 404 via a new ToApiResult() helper. The agent toolset boundary translates the same nullable/bool results into the tool-error text the model already expected.
This commit is contained in:
@@ -5,16 +5,16 @@ namespace Novelly.Api.Agent;
|
||||
/// <summary>A chat thread between the writer and the embedded agent, scoped to one project.</summary>
|
||||
public class AgentConversation
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; init; }
|
||||
public Project? Project { get; init; }
|
||||
|
||||
public string Title { get; set; } = "New conversation";
|
||||
public string Title { get; init; } = "New conversation";
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public List<AgentMessage> Messages { get; set; } = [];
|
||||
public List<AgentMessage> Messages { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -24,26 +24,26 @@ public class AgentConversation
|
||||
/// </summary>
|
||||
public class AgentMessage
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ConversationId { get; set; }
|
||||
public AgentConversation? Conversation { get; set; }
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
public Guid ConversationId { get; init; }
|
||||
public AgentConversation? Conversation { get; init; }
|
||||
|
||||
public AgentRole Role { get; set; }
|
||||
public AgentRole Role { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Position in the conversation, 0-based. Timestamps are not enough to order a
|
||||
/// transcript: a fast turn can produce two messages inside the same tick.
|
||||
/// </summary>
|
||||
public int Sequence { get; set; }
|
||||
public int Sequence { get; init; }
|
||||
|
||||
/// <summary>The visible text of the turn.</summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public string Content { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// JSON array of <c>{ name, input, result }</c> objects describing tool calls made
|
||||
/// during this turn. Null on user turns and on assistant turns that used no tools.
|
||||
/// </summary>
|
||||
public string? ToolCallsJson { get; set; }
|
||||
public string? ToolCallsJson { get; init; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
public record ConversationSummaryDto(
|
||||
@@ -26,4 +28,19 @@ public record ToolCallDto(string Name, string Input, string Result);
|
||||
|
||||
public record SendAgentMessageRequest(string Message, Guid? ConversationId = null);
|
||||
|
||||
public class SendAgentMessageRequestValidator : IModelValidator<SendAgentMessageRequest>
|
||||
{
|
||||
public ValidationResult Validate(SendAgentMessageRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.Message))
|
||||
result.AddError("Message", "'Message' must not be empty.");
|
||||
else if (model.Message.Length > 20000)
|
||||
result.AddError("Message", "'Message' must be 20,000 characters or fewer.");
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public record AgentTurnDto(Guid ConversationId, AgentMessageDto Message);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
@@ -6,7 +7,9 @@ public static class AgentEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAgentEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/agent").WithTags("Agent").AddEndpointFilter<RequestLoggingEndpointFilter>();
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/agent").WithTags("Agent")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/conversations", async (
|
||||
Guid projectId, NovelAgentService agent, CancellationToken ct) =>
|
||||
@@ -21,17 +24,16 @@ 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").AddEndpointFilter<RequestLoggingEndpointFilter>();
|
||||
var conversations = app.MapGroup("/api/conversations").WithTags("Agent")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
conversations.MapGet("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) =>
|
||||
Results.Ok(await agent.GetConversationAsync(id, ct)))
|
||||
(await agent.GetConversationAsync(id, ct)).ToApiResult())
|
||||
.WithSummary("Read a conversation's full transcript.");
|
||||
|
||||
conversations.MapDelete("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) =>
|
||||
{
|
||||
await agent.DeleteConversationAsync(id, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
await agent.DeleteConversationAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithSummary("Delete a conversation.");
|
||||
|
||||
return app;
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
|
||||
@@ -18,7 +19,8 @@ public class NovelAgentService(
|
||||
IAgentModelClient model,
|
||||
NovelAgentToolset toolset,
|
||||
IOptions<AgentOptions> options,
|
||||
ILogger<NovelAgentService> logger)
|
||||
ILogger<NovelAgentService> logger,
|
||||
IModelValidator<SendAgentMessageRequest> sendMessageValidator)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -39,11 +41,18 @@ public class NovelAgentService(
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<ConversationDto> GetConversationAsync(Guid conversationId, CancellationToken ct = default)
|
||||
/// <summary>Null when no conversation has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<ConversationDto?> GetConversationAsync(Guid conversationId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(conversationId, nameof(conversationId));
|
||||
|
||||
logger.LogInformation("Getting agent conversation {ConversationId}", conversationId);
|
||||
|
||||
var conversation = await LoadConversationAsync(conversationId, ct);
|
||||
var conversation = await FindConversationAsync(conversationId, ct);
|
||||
if (conversation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ConversationDto(
|
||||
conversation.Id,
|
||||
@@ -53,13 +62,22 @@ public class NovelAgentService(
|
||||
conversation.UpdatedAt);
|
||||
}
|
||||
|
||||
public async Task DeleteConversationAsync(Guid conversationId, CancellationToken ct = default)
|
||||
/// <summary>True if a conversation was deleted; false if no conversation had this id.</summary>
|
||||
public async Task<bool> DeleteConversationAsync(Guid conversationId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(conversationId, nameof(conversationId));
|
||||
|
||||
logger.LogInformation("Deleting agent conversation {ConversationId}", conversationId);
|
||||
|
||||
var conversation = await LoadConversationAsync(conversationId, ct);
|
||||
var conversation = await FindConversationAsync(conversationId, ct);
|
||||
if (conversation is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
db.Conversations.Remove(conversation);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -69,12 +87,19 @@ public class NovelAgentService(
|
||||
public async Task<AgentTurnDto> SendMessageAsync(
|
||||
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Null(request, nameof(request));
|
||||
sendMessageValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
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 FindConversationAsync(id, ct)
|
||||
// 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.
|
||||
?? throw new NotFoundException(nameof(AgentConversation), id)
|
||||
: await StartConversationAsync(projectId, request.Message, ct);
|
||||
|
||||
// Persist the user's turn before running the loop. The tools save through the
|
||||
@@ -204,9 +229,9 @@ public class NovelAgentService(
|
||||
return conversation;
|
||||
}
|
||||
|
||||
private async Task<AgentConversation> LoadConversationAsync(Guid conversationId, CancellationToken ct)
|
||||
private async Task<AgentConversation?> FindConversationAsync(Guid conversationId, CancellationToken ct)
|
||||
{
|
||||
logger.LogDebug("Loading agent conversation {ConversationId}", conversationId);
|
||||
logger.LogDebug("Finding agent conversation {ConversationId}", conversationId);
|
||||
|
||||
var conversation = await db.Conversations
|
||||
.Include(c => c.Messages)
|
||||
@@ -214,8 +239,7 @@ public class NovelAgentService(
|
||||
|
||||
if (conversation is null)
|
||||
{
|
||||
logger.LogWarning("AgentConversation {ConversationId} not found", conversationId);
|
||||
throw new NotFoundException(nameof(AgentConversation), conversationId);
|
||||
logger.LogInformation("AgentConversation {ConversationId} not found", conversationId);
|
||||
}
|
||||
|
||||
return conversation;
|
||||
|
||||
@@ -13,6 +13,13 @@ namespace Novelly.Api.Agent;
|
||||
/// <summary>The outcome of running a tool: what to hand back to the model, and whether it failed.</summary>
|
||||
public record AgentToolResult(string Content, bool IsError);
|
||||
|
||||
/// <summary>
|
||||
/// A lookup a tool performed came back empty. Not an exception — the underlying service
|
||||
/// already said so by returning null/false — just a value <see cref="NovelAgentToolset.ExecuteAsync"/>
|
||||
/// recognises and turns into the same error-result shape a caught exception would produce.
|
||||
/// </summary>
|
||||
internal record ToolNotFound(string Message);
|
||||
|
||||
/// <summary>A tool the agent can call, bound to a handler that runs against the project's data.</summary>
|
||||
public record AgentTool(
|
||||
string Name,
|
||||
@@ -44,7 +51,7 @@ public class NovelAgentToolset(
|
||||
|
||||
private Dictionary<string, AgentTool>? _byName;
|
||||
|
||||
public IReadOnlyList<AgentTool> Tools => [.. ByName.Values];
|
||||
private IReadOnlyList<AgentTool> Tools => [.. ByName.Values];
|
||||
|
||||
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
||||
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
|
||||
@@ -53,8 +60,7 @@ public class NovelAgentToolset(
|
||||
/// Runs a tool and serialises its result. Failures come back as text rather than
|
||||
/// exceptions so the model can read the message and correct itself.
|
||||
/// </summary>
|
||||
public async Task<AgentToolResult> ExecuteAsync(
|
||||
string name, Guid projectId, JsonElement input, CancellationToken ct = default)
|
||||
public async Task<AgentToolResult> ExecuteAsync(string name, Guid projectId, JsonElement input, CancellationToken ct = default)
|
||||
{
|
||||
if (!ByName.TryGetValue(name, out var tool))
|
||||
{
|
||||
@@ -67,6 +73,13 @@ public class NovelAgentToolset(
|
||||
try
|
||||
{
|
||||
var result = await tool.Handler(projectId, input, ct);
|
||||
|
||||
if (result is ToolNotFound notFound)
|
||||
{
|
||||
logger.LogInformation("Tool {Tool} for project {ProjectId} found nothing: {Message}", name, projectId, notFound.Message);
|
||||
return new AgentToolResult(notFound.Message, true);
|
||||
}
|
||||
|
||||
logger.LogDebug("Tool {Tool} for project {ProjectId} succeeded", name, projectId);
|
||||
return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false);
|
||||
}
|
||||
@@ -87,6 +100,14 @@ public class NovelAgentToolset(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Turns a nullable lookup into either the value or a <see cref="ToolNotFound"/> the model can read.</summary>
|
||||
private static async Task<object> OrNotFound<T>(Task<T?> lookup, string entity, Guid id) where T : class =>
|
||||
await lookup as object ?? new ToolNotFound($"{entity} '{id}' was not found.");
|
||||
|
||||
/// <summary>Turns a delete's success flag into either a confirmation or a <see cref="ToolNotFound"/>.</summary>
|
||||
private static async Task<object> DeletedOrNotFound(Task<bool> delete, string entity, Guid id) =>
|
||||
await delete ? new { deleted = true } : new ToolNotFound($"{entity} '{id}' was not found.");
|
||||
|
||||
private Dictionary<string, AgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name);
|
||||
|
||||
private IEnumerable<AgentTool> Build()
|
||||
@@ -96,7 +117,7 @@ public class NovelAgentToolset(
|
||||
"Read the project's title, logline, synopsis, genre, notes and word-count target. "
|
||||
+ "Call this first in a conversation to ground yourself in what the book is.",
|
||||
new JsonSchemaBuilder().Build(),
|
||||
async (projectId, _, ct) => await projects.GetAsync(projectId, ct));
|
||||
async (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), "Project", projectId));
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_project_brief",
|
||||
@@ -111,14 +132,14 @@ public class NovelAgentToolset(
|
||||
.Str("notes", "Free-form notes on theme, tone, comparable titles.")
|
||||
.Int("target_word_count", "Target manuscript length in words.")
|
||||
.Build(),
|
||||
async (projectId, input, ct) => await projects.UpdateAsync(projectId, new UpdateProjectRequest(
|
||||
async (projectId, input, ct) => await OrNotFound(projects.UpdateAsync(projectId, new UpdateProjectRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.String(input, "author"),
|
||||
JsonInput.String(input, "genre"),
|
||||
JsonInput.String(input, "logline"),
|
||||
JsonInput.String(input, "synopsis"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Int(input, "target_word_count")), ct));
|
||||
JsonInput.Int(input, "target_word_count")), ct), "Project", projectId));
|
||||
|
||||
yield return new AgentTool(
|
||||
"list_characters",
|
||||
@@ -156,26 +177,30 @@ public class NovelAgentToolset(
|
||||
CharacterSchema(includeName: true, nameRequired: false)
|
||||
.Str("character_id", "Id of the character to update.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await characters.UpdateAsync(
|
||||
JsonInput.RequiredGuid(input, "character_id"),
|
||||
new UpdateCharacterRequest(
|
||||
JsonInput.String(input, "name"),
|
||||
JsonInput.Enum<CharacterRole>(input, "role"),
|
||||
JsonInput.Enum<CharacterImportance>(input, "importance"),
|
||||
JsonInput.String(input, "age"),
|
||||
JsonInput.String(input, "pronouns"),
|
||||
JsonInput.String(input, "occupation"),
|
||||
JsonInput.String(input, "appearance"),
|
||||
JsonInput.String(input, "personality"),
|
||||
JsonInput.String(input, "backstory"),
|
||||
JsonInput.String(input, "want"),
|
||||
JsonInput.String(input, "need"),
|
||||
JsonInput.String(input, "internal_conflict"),
|
||||
JsonInput.String(input, "external_conflict"),
|
||||
JsonInput.String(input, "arc_summary"),
|
||||
JsonInput.String(input, "voice"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Strings(input, "tags")), ct));
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
var characterId = JsonInput.RequiredGuid(input, "character_id");
|
||||
return await OrNotFound(characters.UpdateAsync(
|
||||
characterId,
|
||||
new UpdateCharacterRequest(
|
||||
JsonInput.String(input, "name"),
|
||||
JsonInput.Enum<CharacterRole>(input, "role"),
|
||||
JsonInput.Enum<CharacterImportance>(input, "importance"),
|
||||
JsonInput.String(input, "age"),
|
||||
JsonInput.String(input, "pronouns"),
|
||||
JsonInput.String(input, "occupation"),
|
||||
JsonInput.String(input, "appearance"),
|
||||
JsonInput.String(input, "personality"),
|
||||
JsonInput.String(input, "backstory"),
|
||||
JsonInput.String(input, "want"),
|
||||
JsonInput.String(input, "need"),
|
||||
JsonInput.String(input, "internal_conflict"),
|
||||
JsonInput.String(input, "external_conflict"),
|
||||
JsonInput.String(input, "arc_summary"),
|
||||
JsonInput.String(input, "voice"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Strings(input, "tags")), ct), "Character", characterId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"get_chapter_outline",
|
||||
@@ -213,16 +238,20 @@ public class NovelAgentToolset(
|
||||
.Str("beat_id", "Id of the beat to update.", required: true)
|
||||
.Str("title", "Three to five words naming the beat.")
|
||||
.Build(),
|
||||
async (_, input, ct) => await beats.UpdateAsync(
|
||||
JsonInput.RequiredGuid(input, "beat_id"),
|
||||
new UpdateBeatRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.Guid(input, "character_id"),
|
||||
JsonInput.String(input, "what_happened"),
|
||||
JsonInput.String(input, "whats_next"),
|
||||
JsonInput.Guid(input, "scene_id"),
|
||||
JsonInput.Strings(input, "tags")), ct));
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
var beatId = JsonInput.RequiredGuid(input, "beat_id");
|
||||
return await OrNotFound(beats.UpdateAsync(
|
||||
beatId,
|
||||
new UpdateBeatRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.Guid(input, "character_id"),
|
||||
JsonInput.String(input, "what_happened"),
|
||||
JsonInput.String(input, "whats_next"),
|
||||
JsonInput.Guid(input, "scene_id"),
|
||||
JsonInput.Strings(input, "tags")), ct), "Beat", beatId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"delete_beat",
|
||||
@@ -232,8 +261,8 @@ public class NovelAgentToolset(
|
||||
.Build(),
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
await beats.DeleteAsync(JsonInput.RequiredGuid(input, "beat_id"), ct);
|
||||
return new { deleted = true };
|
||||
var beatId = JsonInput.RequiredGuid(input, "beat_id");
|
||||
return await DeletedOrNotFound(beats.DeleteAsync(beatId, ct), "Beat", beatId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
@@ -265,7 +294,11 @@ public class NovelAgentToolset(
|
||||
new JsonSchemaBuilder()
|
||||
.Str("tag_id", "Id of the tag to trace.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await tags.GetReferencesAsync(JsonInput.RequiredGuid(input, "tag_id"), ct));
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
var tagId = JsonInput.RequiredGuid(input, "tag_id");
|
||||
return await OrNotFound(tags.GetReferencesAsync(tagId, ct), "Tag", tagId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"list_chapters",
|
||||
@@ -279,7 +312,11 @@ public class NovelAgentToolset(
|
||||
new JsonSchemaBuilder()
|
||||
.Str("chapter_id", "Id of the chapter to read.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await chapters.GetAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct));
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
||||
return await OrNotFound(chapters.GetAsync(chapterId, ct), "Chapter", chapterId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"create_chapter",
|
||||
@@ -321,18 +358,22 @@ public class NovelAgentToolset(
|
||||
.Int("target_word_count", "Target length in words.")
|
||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
||||
.Build(),
|
||||
async (_, input, ct) => await chapters.UpdateAsync(
|
||||
JsonInput.RequiredGuid(input, "chapter_id"),
|
||||
new UpdateChapterRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "number"),
|
||||
JsonInput.String(input, "summary"),
|
||||
JsonInput.Guid(input, "pov_character_id"),
|
||||
JsonInput.String(input, "setting"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status"),
|
||||
JsonInput.Int(input, "target_word_count"),
|
||||
JsonInput.Strings(input, "tags")), ct));
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
||||
return await OrNotFound(chapters.UpdateAsync(
|
||||
chapterId,
|
||||
new UpdateChapterRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "number"),
|
||||
JsonInput.String(input, "summary"),
|
||||
JsonInput.Guid(input, "pov_character_id"),
|
||||
JsonInput.String(input, "setting"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status"),
|
||||
JsonInput.Int(input, "target_word_count"),
|
||||
JsonInput.Strings(input, "tags")), ct), "Chapter", chapterId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"create_scene",
|
||||
@@ -364,19 +405,23 @@ public class NovelAgentToolset(
|
||||
.Str("scene_id", "Id of the scene to update.", required: true)
|
||||
.Str("title", "New title.")
|
||||
.Build(),
|
||||
async (_, input, ct) => await scenes.UpdateAsync(
|
||||
JsonInput.RequiredGuid(input, "scene_id"),
|
||||
new UpdateSceneRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.String(input, "summary"),
|
||||
JsonInput.String(input, "goal"),
|
||||
JsonInput.String(input, "conflict"),
|
||||
JsonInput.String(input, "outcome"),
|
||||
JsonInput.Guid(input, "pov_character_id"),
|
||||
JsonInput.String(input, "location"),
|
||||
JsonInput.String(input, "prose"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status")), ct));
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
var sceneId = JsonInput.RequiredGuid(input, "scene_id");
|
||||
return await OrNotFound(scenes.UpdateAsync(
|
||||
sceneId,
|
||||
new UpdateSceneRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.String(input, "summary"),
|
||||
JsonInput.String(input, "goal"),
|
||||
JsonInput.String(input, "conflict"),
|
||||
JsonInput.String(input, "outcome"),
|
||||
JsonInput.Guid(input, "pov_character_id"),
|
||||
JsonInput.String(input, "location"),
|
||||
JsonInput.String(input, "prose"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status")), ct), "Scene", sceneId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"get_character_beats",
|
||||
@@ -386,8 +431,11 @@ public class NovelAgentToolset(
|
||||
new JsonSchemaBuilder()
|
||||
.Str("character_id", "Id of the character.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await beats.ListForCharacterAsync(
|
||||
JsonInput.RequiredGuid(input, "character_id"), ct));
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
var characterId = JsonInput.RequiredGuid(input, "character_id");
|
||||
return await OrNotFound(beats.ListForCharacterAsync(characterId, ct), "Character", characterId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"get_character_arc",
|
||||
@@ -422,13 +470,17 @@ public class NovelAgentToolset(
|
||||
.Str("arc_stage_id", "Id of the arc stage to update.", required: true)
|
||||
.Str("title", "New title for the stage.")
|
||||
.Build(),
|
||||
async (_, input, ct) => await arcs.UpdateAsync(
|
||||
JsonInput.RequiredGuid(input, "arc_stage_id"),
|
||||
new UpdateArcStageRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.String(input, "description"),
|
||||
JsonInput.Guid(input, "chapter_id")), ct));
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
var arcStageId = JsonInput.RequiredGuid(input, "arc_stage_id");
|
||||
return await OrNotFound(arcs.UpdateAsync(
|
||||
arcStageId,
|
||||
new UpdateArcStageRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.String(input, "description"),
|
||||
JsonInput.Guid(input, "chapter_id")), ct), "CharacterArcStage", arcStageId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"delete_arc_stage",
|
||||
@@ -438,8 +490,8 @@ public class NovelAgentToolset(
|
||||
.Build(),
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
await arcs.DeleteAsync(JsonInput.RequiredGuid(input, "arc_stage_id"), ct);
|
||||
return new { deleted = true };
|
||||
var arcStageId = JsonInput.RequiredGuid(input, "arc_stage_id");
|
||||
return await DeletedOrNotFound(arcs.DeleteAsync(arcStageId, ct), "CharacterArcStage", arcStageId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
@@ -499,11 +551,15 @@ public class NovelAgentToolset(
|
||||
.Str("resolution", "What was decided.", required: true)
|
||||
.Bool("append_to_notes", "Also append the resolution to the associated notes.")
|
||||
.Build(),
|
||||
async (_, input, ct) => await questions.ResolveAsync(
|
||||
JsonInput.RequiredGuid(input, "question_id"),
|
||||
new ResolveOpenQuestionRequest(
|
||||
JsonInput.RequiredString(input, "resolution"),
|
||||
JsonInput.Bool(input, "append_to_notes") ?? false), ct));
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
var questionId = JsonInput.RequiredGuid(input, "question_id");
|
||||
return await OrNotFound(questions.ResolveAsync(
|
||||
questionId,
|
||||
new ResolveOpenQuestionRequest(
|
||||
JsonInput.RequiredString(input, "resolution"),
|
||||
JsonInput.Bool(input, "append_to_notes") ?? false), ct), "OpenQuestion", questionId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"reopen_question",
|
||||
@@ -511,8 +567,11 @@ public class NovelAgentToolset(
|
||||
new JsonSchemaBuilder()
|
||||
.Str("question_id", "Id of the question to reopen.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await questions.ReopenAsync(
|
||||
JsonInput.RequiredGuid(input, "question_id"), ct));
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
var questionId = JsonInput.RequiredGuid(input, "question_id");
|
||||
return await OrNotFound(questions.ReopenAsync(questionId, ct), "OpenQuestion", questionId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"delete_open_question",
|
||||
@@ -522,8 +581,8 @@ public class NovelAgentToolset(
|
||||
.Build(),
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
await questions.DeleteAsync(JsonInput.RequiredGuid(input, "question_id"), ct);
|
||||
return new { deleted = true };
|
||||
var questionId = JsonInput.RequiredGuid(input, "question_id");
|
||||
return await DeletedOrNotFound(questions.DeleteAsync(questionId, ct), "OpenQuestion", questionId);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Beats;
|
||||
@@ -25,6 +26,23 @@ public record CreateBeatRequest(
|
||||
Guid? SceneId = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
public class CreateBeatRequestValidator : IModelValidator<CreateBeatRequest>
|
||||
{
|
||||
public ValidationResult Validate(CreateBeatRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.Title))
|
||||
result.AddError("Title", "'Title' must not be empty.");
|
||||
else if (model.Title.Length > 200)
|
||||
result.AddError("Title", "'Title' must be 200 characters or fewer.");
|
||||
|
||||
BeatValidation.OptionalFields(model.SortOrder, model.WhatHappened, model.WhatsNext, model.Tags, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a
|
||||
/// <see cref="Tags"/> list replaces the beat's tags outright.
|
||||
@@ -38,6 +56,44 @@ public record UpdateBeatRequest(
|
||||
Guid? SceneId = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
public class UpdateBeatRequestValidator : IModelValidator<UpdateBeatRequest>
|
||||
{
|
||||
public ValidationResult Validate(UpdateBeatRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (model.Title is not null)
|
||||
{
|
||||
if (model.Title.Length == 0)
|
||||
result.AddError("Title", "'Title' can not be cleared — a beat always needs one.");
|
||||
else if (model.Title.Length > 200)
|
||||
result.AddError("Title", "'Title' must be 200 characters or fewer.");
|
||||
}
|
||||
|
||||
BeatValidation.OptionalFields(model.SortOrder, model.WhatHappened, model.WhatsNext, model.Tags, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
file static class BeatValidation
|
||||
{
|
||||
public static void OptionalFields(int? sortOrder, string? whatHappened, string? whatsNext, IReadOnlyList<string>? tags, ValidationResult result)
|
||||
{
|
||||
if (sortOrder is < 0)
|
||||
result.AddError("SortOrder", "'Sort Order' must be zero or greater.");
|
||||
|
||||
if (whatHappened is { Length: > 20000 })
|
||||
result.AddError("WhatHappened", "'What Happened' must be 20,000 characters or fewer.");
|
||||
|
||||
if (whatsNext is { Length: > 20000 })
|
||||
result.AddError("WhatsNext", "'Whats Next' must be 20,000 characters or fewer.");
|
||||
|
||||
if (tags is not null && tags.Any(string.IsNullOrWhiteSpace))
|
||||
result.AddError("Tags", "'Tags' must not contain blank entries.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A beat this character appears in, carrying enough of its chapter to link straight to
|
||||
/// the row in that chapter's outline.
|
||||
@@ -57,6 +113,19 @@ public record CharacterBeatDto(
|
||||
/// <summary>Reorders a chapter's beats in one call, by listing their ids in the order wanted.</summary>
|
||||
public record ReorderBeatsRequest(IReadOnlyList<Guid> BeatIds);
|
||||
|
||||
public class ReorderBeatsRequestValidator : IModelValidator<ReorderBeatsRequest>
|
||||
{
|
||||
public ValidationResult Validate(ReorderBeatsRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (model.BeatIds is null || model.BeatIds.Count == 0)
|
||||
result.AddError("BeatIds", "'Beat Ids' must not be empty.");
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public static class BeatMapping
|
||||
{
|
||||
public static BeatDto ToDto(this Beat b) => new(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Beats;
|
||||
|
||||
@@ -6,7 +7,9 @@ public static class BeatEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapBeatEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/beats").WithTags("Beats").AddEndpointFilter<RequestLoggingEndpointFilter>();
|
||||
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/beats").WithTags("Beats")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
chapterScoped.MapGet("/", async (Guid chapterId, BeatService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(chapterId, ct)))
|
||||
@@ -27,26 +30,25 @@ public static class BeatEndpoints
|
||||
|
||||
app.MapGet("/api/characters/{characterId:guid}/beats", async (
|
||||
Guid characterId, BeatService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListForCharacterAsync(characterId, ct)))
|
||||
(await service.ListForCharacterAsync(characterId, ct)).ToApiResult())
|
||||
.WithTags("Beats")
|
||||
.WithSummary("Every beat this character appears in, in manuscript order.");
|
||||
|
||||
var beats = app.MapGroup("/api/beats").WithTags("Beats").AddEndpointFilter<RequestLoggingEndpointFilter>();
|
||||
var beats = app.MapGroup("/api/beats").WithTags("Beats")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
beats.MapGet("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.GetAsync(id, ct)))
|
||||
(await service.GetAsync(id, ct)).ToApiResult())
|
||||
.WithSummary("Read one beat.");
|
||||
|
||||
beats.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateBeatRequest request, BeatService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.UpdateAsync(id, request, ct)))
|
||||
(await service.UpdateAsync(id, request, ct)).ToApiResult())
|
||||
.WithSummary("Update a beat. Sending a tag list replaces the beat's tags.");
|
||||
|
||||
beats.MapDelete("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) =>
|
||||
{
|
||||
await service.DeleteAsync(id, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithSummary("Delete a beat.");
|
||||
|
||||
return app;
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
@@ -11,10 +12,18 @@ 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, ILogger<BeatService> logger)
|
||||
public class BeatService(
|
||||
INovelDbContext db,
|
||||
TagService tags,
|
||||
ILogger<BeatService> logger,
|
||||
IModelValidator<CreateBeatRequest> createValidator,
|
||||
IModelValidator<UpdateBeatRequest> updateValidator,
|
||||
IModelValidator<ReorderBeatsRequest> reorderValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<BeatDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(chapterId, nameof(chapterId));
|
||||
|
||||
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
|
||||
|
||||
var beats = await Query()
|
||||
@@ -25,26 +34,32 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
|
||||
return [.. beats.Select(b => b.ToDto())];
|
||||
}
|
||||
|
||||
public async Task<BeatDto> GetAsync(Guid id, CancellationToken ct = default)
|
||||
/// <summary>Null when no beat has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<BeatDto?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Getting beat {BeatId}", id);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
return (await FindAsync(id, ct))?.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every beat this character appears in, in manuscript order. This is the character
|
||||
/// page's view onto the outlines: each row carries its chapter so the UI can link
|
||||
/// straight to the beat in that chapter's outline.
|
||||
/// straight to the beat in that chapter's outline. Null when no character has this id;
|
||||
/// an empty list means the character exists but has no beats yet.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<CharacterBeatDto>> ListForCharacterAsync(
|
||||
public async Task<IReadOnlyList<CharacterBeatDto>?> ListForCharacterAsync(
|
||||
Guid characterId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(characterId, nameof(characterId));
|
||||
|
||||
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);
|
||||
logger.LogInformation("Character {CharacterId} not found", characterId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var beats = await db.Beats
|
||||
@@ -74,12 +89,16 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
|
||||
|
||||
public async Task<BeatDto> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(chapterId, nameof(chapterId));
|
||||
Guard.Null(request, nameof(request));
|
||||
createValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
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);
|
||||
logger.LogWarning("Rejected beat creation: chapter {ChapterId} not found", chapterId);
|
||||
throw new NotFoundException(nameof(Chapter), chapterId);
|
||||
}
|
||||
|
||||
@@ -103,18 +122,31 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
|
||||
|
||||
db.Beats.Add(beat);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(beat.Id, ct)).ToDto();
|
||||
|
||||
// Just created it — the reload is only to pick up includes, not to check existence.
|
||||
return (await FindAsync(beat.Id, ct))!.ToDto();
|
||||
}
|
||||
|
||||
public async Task<BeatDto> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default)
|
||||
public async Task<BeatDto?> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
Guard.Null(request, nameof(request));
|
||||
updateValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation("Updating beat {BeatId}", id);
|
||||
|
||||
var beat = await FindAsync(id, ct);
|
||||
if (beat is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
|
||||
if (chapter is null)
|
||||
{
|
||||
logger.LogWarning("Chapter {ChapterId} not found", beat.ChapterId);
|
||||
// The beat's own chapter should always exist via the FK — this is an
|
||||
// invariant failing, not a caller mistake, so it stays exceptional.
|
||||
logger.LogError("Beat {BeatId} references chapter {ChapterId} which does not exist", id, beat.ChapterId);
|
||||
throw new NotFoundException(nameof(Chapter), beat.ChapterId);
|
||||
}
|
||||
|
||||
@@ -134,16 +166,25 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
return (await FindAsync(id, ct))!.ToDto();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
/// <summary>True if a beat was deleted; false if no beat had this id.</summary>
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Deleting beat {BeatId}", id);
|
||||
|
||||
var beat = await FindAsync(id, ct);
|
||||
if (beat is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
db.Beats.Remove(beat);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -153,6 +194,10 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
|
||||
public async Task<IReadOnlyList<BeatDto>> ReorderAsync(
|
||||
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(chapterId, nameof(chapterId));
|
||||
Guard.Null(request, nameof(request));
|
||||
reorderValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId);
|
||||
|
||||
var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct);
|
||||
@@ -229,18 +274,20 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
|
||||
.Include(b => b.Scene)
|
||||
.Include(b => b.Tags);
|
||||
|
||||
private async Task<Beat> FindAsync(Guid id, CancellationToken ct)
|
||||
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.LogInformation("Beat {BeatId} not found", id);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug("Found beat {BeatId}", id);
|
||||
}
|
||||
|
||||
logger.LogDebug("Found beat {BeatId}", id);
|
||||
return beat;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
@@ -53,6 +54,23 @@ public record CreateChapterRequest(
|
||||
int? TargetWordCount = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
public class CreateChapterRequestValidator : IModelValidator<CreateChapterRequest>
|
||||
{
|
||||
public ValidationResult Validate(CreateChapterRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.Title))
|
||||
result.AddError("Title", "'Title' must not be empty.");
|
||||
else if (model.Title.Length > 200)
|
||||
result.AddError("Title", "'Title' must be 200 characters or fewer.");
|
||||
|
||||
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Tags, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a
|
||||
/// <see cref="Tags"/> list replaces the chapter's tags outright.
|
||||
@@ -68,6 +86,51 @@ public record UpdateChapterRequest(
|
||||
int? TargetWordCount = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterRequest>
|
||||
{
|
||||
public ValidationResult Validate(UpdateChapterRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (model.Title is not null)
|
||||
{
|
||||
if (model.Title.Length == 0)
|
||||
result.AddError("Title", "'Title' can not be cleared — a chapter always needs one.");
|
||||
else if (model.Title.Length > 200)
|
||||
result.AddError("Title", "'Title' must be 200 characters or fewer.");
|
||||
}
|
||||
|
||||
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Tags, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
file static class ChapterValidation
|
||||
{
|
||||
public static void OptionalFields(
|
||||
int? number, string? summary, string? setting, string? notes, int? targetWordCount, IReadOnlyList<string>? tags, ValidationResult result)
|
||||
{
|
||||
if (number is <= 0)
|
||||
result.AddError("Number", "'Number' must be greater than zero.");
|
||||
|
||||
if (summary is { Length: > 20000 })
|
||||
result.AddError("Summary", "'Summary' must be 20,000 characters or fewer.");
|
||||
|
||||
if (setting is { Length: > 500 })
|
||||
result.AddError("Setting", "'Setting' must be 500 characters or fewer.");
|
||||
|
||||
if (notes is { Length: > 20000 })
|
||||
result.AddError("Notes", "'Notes' must be 20,000 characters or fewer.");
|
||||
|
||||
if (targetWordCount is < 0)
|
||||
result.AddError("TargetWordCount", "'Target Word Count' must be zero or greater.");
|
||||
|
||||
if (tags is not null && tags.Any(string.IsNullOrWhiteSpace))
|
||||
result.AddError("Tags", "'Tags' must not contain blank entries.");
|
||||
}
|
||||
}
|
||||
|
||||
public static class ChapterMapping
|
||||
{
|
||||
public static ChapterDto ToDto(this Chapter c) => new(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Chapters;
|
||||
|
||||
@@ -6,7 +7,9 @@ public static class ChapterEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapChapterEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/chapters").WithTags("Chapters").AddEndpointFilter<RequestLoggingEndpointFilter>();
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/chapters").WithTags("Chapters")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(projectId, ct)))
|
||||
@@ -20,22 +23,21 @@ public static class ChapterEndpoints
|
||||
})
|
||||
.WithSummary("Add a chapter.");
|
||||
|
||||
var chapters = app.MapGroup("/api/chapters").WithTags("Chapters").AddEndpointFilter<RequestLoggingEndpointFilter>();
|
||||
var chapters = app.MapGroup("/api/chapters").WithTags("Chapters")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.GetAsync(id, ct)))
|
||||
(await service.GetAsync(id, ct)).ToApiResult())
|
||||
.WithSummary("Read a chapter with all of its scenes.");
|
||||
|
||||
chapters.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.UpdateAsync(id, request, ct)))
|
||||
(await service.UpdateAsync(id, request, ct)).ToApiResult())
|
||||
.WithSummary("Update a chapter.");
|
||||
|
||||
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
|
||||
{
|
||||
await service.DeleteAsync(id, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithSummary("Delete a chapter and its scenes.");
|
||||
|
||||
return app;
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Chapters;
|
||||
|
||||
public class ChapterService(INovelDbContext db, TagService tags, ILogger<ChapterService> logger)
|
||||
public class ChapterService(
|
||||
INovelDbContext db,
|
||||
TagService tags,
|
||||
ILogger<ChapterService> logger,
|
||||
IModelValidator<CreateChapterRequest> createValidator,
|
||||
IModelValidator<UpdateChapterRequest> updateValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<ChapterSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
|
||||
logger.LogInformation("Listing chapters for project {ProjectId}", projectId);
|
||||
|
||||
var chapters = await db.Chapters
|
||||
@@ -24,19 +32,26 @@ public class ChapterService(INovelDbContext db, TagService tags, ILogger<Chapter
|
||||
return [.. chapters.Select(c => c.ToSummaryDto())];
|
||||
}
|
||||
|
||||
public async Task<ChapterDto> GetAsync(Guid id, CancellationToken ct = default)
|
||||
/// <summary>Null when no chapter has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<ChapterDto?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Getting chapter {ChapterId}", id);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
return (await FindAsync(id, ct))?.ToDto();
|
||||
}
|
||||
|
||||
public async Task<ChapterDto> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Null(request, nameof(request));
|
||||
createValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
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);
|
||||
logger.LogWarning("Rejected chapter creation: project {ProjectId} not found", projectId);
|
||||
throw new NotFoundException(nameof(Project), projectId);
|
||||
}
|
||||
|
||||
@@ -60,14 +75,24 @@ public class ChapterService(INovelDbContext db, TagService tags, ILogger<Chapter
|
||||
|
||||
db.Chapters.Add(chapter);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(chapter.Id, ct)).ToDto();
|
||||
|
||||
// Just created it — the reload is only to pick up includes, not to check existence.
|
||||
return (await FindAsync(chapter.Id, ct))!.ToDto();
|
||||
}
|
||||
|
||||
public async Task<ChapterDto> UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default)
|
||||
public async Task<ChapterDto?> UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
Guard.Null(request, nameof(request));
|
||||
updateValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation("Updating chapter {ChapterId}", id);
|
||||
|
||||
var chapter = await FindAsync(id, ct);
|
||||
if (chapter is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
|
||||
chapter.Number = request.Number ?? chapter.Number;
|
||||
@@ -85,16 +110,25 @@ public class ChapterService(INovelDbContext db, TagService tags, ILogger<Chapter
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
return (await FindAsync(id, ct))!.ToDto();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
/// <summary>True if a chapter was deleted; false if no chapter had this id.</summary>
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Deleting chapter {ChapterId}", id);
|
||||
|
||||
var chapter = await FindAsync(id, ct);
|
||||
if (chapter is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
db.Chapters.Remove(chapter);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<int> NextChapterNumberAsync(Guid projectId, CancellationToken ct)
|
||||
@@ -110,7 +144,7 @@ public class ChapterService(INovelDbContext db, TagService tags, ILogger<Chapter
|
||||
return next;
|
||||
}
|
||||
|
||||
private async Task<Chapter> FindAsync(Guid id, CancellationToken ct)
|
||||
private async Task<Chapter?> FindAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
logger.LogDebug("Finding chapter {ChapterId}", id);
|
||||
|
||||
@@ -125,11 +159,13 @@ public class ChapterService(INovelDbContext db, TagService tags, ILogger<Chapter
|
||||
|
||||
if (chapter is null)
|
||||
{
|
||||
logger.LogWarning("Chapter {ChapterId} not found", id);
|
||||
throw new NotFoundException(nameof(Chapter), id);
|
||||
logger.LogInformation("Chapter {ChapterId} not found", id);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug("Found chapter {ChapterId}", id);
|
||||
}
|
||||
|
||||
logger.LogDebug("Found chapter {ChapterId}", id);
|
||||
return chapter;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
|
||||
namespace Novelly.Api.Characters;
|
||||
@@ -13,10 +14,17 @@ 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, ILogger<CharacterArcService> logger)
|
||||
public class CharacterArcService(
|
||||
INovelDbContext db,
|
||||
ILogger<CharacterArcService> logger,
|
||||
IModelValidator<CreateArcStageRequest> createValidator,
|
||||
IModelValidator<UpdateArcStageRequest> updateValidator,
|
||||
IModelValidator<ReorderArcStagesRequest> reorderValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<ArcStageDto>> ListAsync(Guid characterId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(characterId, nameof(characterId));
|
||||
|
||||
logger.LogInformation("Listing arc stages for character {CharacterId}", characterId);
|
||||
|
||||
var stages = await Query()
|
||||
@@ -27,21 +35,28 @@ public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService
|
||||
return [.. stages.Select(s => s.ToDto())];
|
||||
}
|
||||
|
||||
public async Task<ArcStageDto> GetAsync(Guid id, CancellationToken ct = default)
|
||||
/// <summary>Null when no arc stage has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<ArcStageDto?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Getting arc stage {ArcStageId}", id);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
return (await FindAsync(id, ct))?.ToDto();
|
||||
}
|
||||
|
||||
public async Task<ArcStageDto> CreateAsync(
|
||||
Guid characterId, CreateArcStageRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(characterId, nameof(characterId));
|
||||
Guard.Null(request, nameof(request));
|
||||
createValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
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);
|
||||
logger.LogWarning("Rejected arc stage creation: character {CharacterId} not found", characterId);
|
||||
throw new NotFoundException(nameof(Character), characterId);
|
||||
}
|
||||
|
||||
@@ -58,20 +73,32 @@ public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService
|
||||
|
||||
db.CharacterArcStages.Add(stage);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(stage.Id, ct)).ToDto();
|
||||
|
||||
// Just created it — the reload is only to pick up includes, not to check existence.
|
||||
return (await FindAsync(stage.Id, ct))!.ToDto();
|
||||
}
|
||||
|
||||
public async Task<ArcStageDto> UpdateAsync(
|
||||
public async Task<ArcStageDto?> UpdateAsync(
|
||||
Guid id, UpdateArcStageRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
Guard.Null(request, nameof(request));
|
||||
updateValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation("Updating arc stage {ArcStageId}", id);
|
||||
|
||||
var stage = await FindAsync(id, ct);
|
||||
if (stage is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct);
|
||||
if (character is null)
|
||||
{
|
||||
logger.LogWarning("Character {CharacterId} not found", stage.CharacterId);
|
||||
// The stage's own character should always exist via the FK — this is an
|
||||
// invariant failing, not a caller mistake, so it stays exceptional.
|
||||
logger.LogError("Arc stage {ArcStageId} references character {CharacterId} which does not exist", id, stage.CharacterId);
|
||||
throw new NotFoundException(nameof(Character), stage.CharacterId);
|
||||
}
|
||||
|
||||
@@ -84,16 +111,25 @@ public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService
|
||||
stage.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
return (await FindAsync(id, ct))!.ToDto();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
/// <summary>True if an arc stage was deleted; false if no stage had this id.</summary>
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Deleting arc stage {ArcStageId}", id);
|
||||
|
||||
var stage = await FindAsync(id, ct);
|
||||
if (stage is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
db.CharacterArcStages.Remove(stage);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -103,6 +139,10 @@ public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService
|
||||
public async Task<IReadOnlyList<ArcStageDto>> ReorderAsync(
|
||||
Guid characterId, ReorderArcStagesRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(characterId, nameof(characterId));
|
||||
Guard.Null(request, nameof(request));
|
||||
reorderValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation("Reordering {Count} arc stages for character {CharacterId}", request.StageIds.Count, characterId);
|
||||
|
||||
var stages = await db.CharacterArcStages
|
||||
@@ -164,18 +204,20 @@ public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService
|
||||
|
||||
private IQueryable<CharacterArcStage> Query() => db.CharacterArcStages.Include(s => s.Chapter);
|
||||
|
||||
private async Task<CharacterArcStage> FindAsync(Guid id, CancellationToken ct)
|
||||
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.LogInformation("CharacterArcStage {ArcStageId} not found", id);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug("Found arc stage {ArcStageId}", id);
|
||||
}
|
||||
|
||||
logger.LogDebug("Found arc stage {ArcStageId}", id);
|
||||
return stage;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Characters;
|
||||
@@ -52,6 +53,26 @@ public record CreateCharacterRequest(
|
||||
string? Notes = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
public class CreateCharacterRequestValidator : IModelValidator<CreateCharacterRequest>
|
||||
{
|
||||
public ValidationResult Validate(CreateCharacterRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.Name))
|
||||
result.AddError("Name", "'Name' must not be empty.");
|
||||
else if (model.Name.Length > 200)
|
||||
result.AddError("Name", "'Name' must be 200 characters or fewer.");
|
||||
|
||||
CharacterValidation.OptionalFields(
|
||||
model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory,
|
||||
model.Want, model.Need, model.InternalConflict, model.ExternalConflict, model.ArcSummary, model.Voice,
|
||||
model.Notes, model.Tags, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a
|
||||
/// <see cref="Tags"/> list replaces the character's tags outright.
|
||||
@@ -75,11 +96,87 @@ public record UpdateCharacterRequest(
|
||||
string? Notes = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
public class UpdateCharacterRequestValidator : IModelValidator<UpdateCharacterRequest>
|
||||
{
|
||||
public ValidationResult Validate(UpdateCharacterRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (model.Name is not null)
|
||||
{
|
||||
if (model.Name.Length == 0)
|
||||
result.AddError("Name", "'Name' can not be cleared — a character always needs one.");
|
||||
else if (model.Name.Length > 200)
|
||||
result.AddError("Name", "'Name' must be 200 characters or fewer.");
|
||||
}
|
||||
|
||||
CharacterValidation.OptionalFields(
|
||||
model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory,
|
||||
model.Want, model.Need, model.InternalConflict, model.ExternalConflict, model.ArcSummary, model.Voice,
|
||||
model.Notes, model.Tags, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
file static class CharacterValidation
|
||||
{
|
||||
public static void OptionalFields(
|
||||
string? age, string? pronouns, string? occupation, string? appearance, string? personality, string? backstory,
|
||||
string? want, string? need, string? internalConflict, string? externalConflict, string? arcSummary, string? voice,
|
||||
string? notes, IReadOnlyList<string>? tags, ValidationResult result)
|
||||
{
|
||||
Cap(age, "Age", 100, result);
|
||||
Cap(pronouns, "Pronouns", 100, result);
|
||||
Cap(occupation, "Occupation", 200, result);
|
||||
Cap(appearance, "Appearance", 20000, result);
|
||||
Cap(personality, "Personality", 20000, result);
|
||||
Cap(backstory, "Backstory", 20000, result);
|
||||
Cap(want, "Want", 2000, result);
|
||||
Cap(need, "Need", 2000, result);
|
||||
Cap(internalConflict, "InternalConflict", 2000, result);
|
||||
Cap(externalConflict, "ExternalConflict", 2000, result);
|
||||
Cap(arcSummary, "ArcSummary", 20000, result);
|
||||
Cap(voice, "Voice", 2000, result);
|
||||
Cap(notes, "Notes", 20000, result);
|
||||
|
||||
if (tags is not null && tags.Any(string.IsNullOrWhiteSpace))
|
||||
result.AddError("Tags", "'Tags' must not contain blank entries.");
|
||||
}
|
||||
|
||||
private static void Cap(string? value, string field, int max, ValidationResult result)
|
||||
{
|
||||
if (value is { Length: var length } && length > max)
|
||||
result.AddError(field, $"'{field}' must be {max:N0} characters or fewer.");
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateRelationshipRequest(
|
||||
Guid RelatedCharacterId,
|
||||
string RelationshipType,
|
||||
string? Description = null);
|
||||
|
||||
public class CreateRelationshipRequestValidator : IModelValidator<CreateRelationshipRequest>
|
||||
{
|
||||
public ValidationResult Validate(CreateRelationshipRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (model.RelatedCharacterId == Guid.Empty)
|
||||
result.AddError("RelatedCharacterId", "'Related Character Id' must not be empty.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.RelationshipType))
|
||||
result.AddError("RelationshipType", "'Relationship Type' must not be empty.");
|
||||
else if (model.RelationshipType.Length > 100)
|
||||
result.AddError("RelationshipType", "'Relationship Type' must be 100 characters or fewer.");
|
||||
|
||||
if (model.Description is { Length: > 2000 })
|
||||
result.AddError("Description", "'Description' must be 2,000 characters or fewer.");
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public record ArcStageDto(
|
||||
Guid Id,
|
||||
Guid CharacterId,
|
||||
@@ -97,6 +194,23 @@ public record CreateArcStageRequest(
|
||||
string? Description = null,
|
||||
Guid? ChapterId = null);
|
||||
|
||||
public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequest>
|
||||
{
|
||||
public ValidationResult Validate(CreateArcStageRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.Title))
|
||||
result.AddError("Title", "'Title' must not be empty.");
|
||||
else if (model.Title.Length > 200)
|
||||
result.AddError("Title", "'Title' must be 200 characters or fewer.");
|
||||
|
||||
ArcStageValidation.OptionalFields(model.SortOrder, model.Description, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Patch-style update. A null field is left alone; an empty string clears it.</summary>
|
||||
public record UpdateArcStageRequest(
|
||||
string? Title = null,
|
||||
@@ -104,9 +218,54 @@ public record UpdateArcStageRequest(
|
||||
string? Description = null,
|
||||
Guid? ChapterId = null);
|
||||
|
||||
public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequest>
|
||||
{
|
||||
public ValidationResult Validate(UpdateArcStageRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (model.Title is not null)
|
||||
{
|
||||
if (model.Title.Length == 0)
|
||||
result.AddError("Title", "'Title' can not be cleared — an arc stage always needs one.");
|
||||
else if (model.Title.Length > 200)
|
||||
result.AddError("Title", "'Title' must be 200 characters or fewer.");
|
||||
}
|
||||
|
||||
ArcStageValidation.OptionalFields(model.SortOrder, model.Description, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
file static class ArcStageValidation
|
||||
{
|
||||
public static void OptionalFields(int? sortOrder, string? description, ValidationResult result)
|
||||
{
|
||||
if (sortOrder is < 0)
|
||||
result.AddError("SortOrder", "'Sort Order' must be zero or greater.");
|
||||
|
||||
if (description is { Length: > 20000 })
|
||||
result.AddError("Description", "'Description' must be 20,000 characters or fewer.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reorders a character's arc in one call, by listing the stage ids in the order wanted.</summary>
|
||||
public record ReorderArcStagesRequest(IReadOnlyList<Guid> StageIds);
|
||||
|
||||
public class ReorderArcStagesRequestValidator : IModelValidator<ReorderArcStagesRequest>
|
||||
{
|
||||
public ValidationResult Validate(ReorderArcStagesRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (model.StageIds is null || model.StageIds.Count == 0)
|
||||
result.AddError("StageIds", "'Stage Ids' must not be empty.");
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static class CharacterMapping
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Characters;
|
||||
|
||||
@@ -6,7 +7,9 @@ public static class CharacterEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/characters").WithTags("Characters").AddEndpointFilter<RequestLoggingEndpointFilter>();
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/characters").WithTags("Characters")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/", async (Guid projectId, CharacterService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(projectId, ct)))
|
||||
@@ -20,35 +23,31 @@ public static class CharacterEndpoints
|
||||
})
|
||||
.WithSummary("Add a character dossier.");
|
||||
|
||||
var characters = app.MapGroup("/api/characters").WithTags("Characters").AddEndpointFilter<RequestLoggingEndpointFilter>();
|
||||
var characters = app.MapGroup("/api/characters").WithTags("Characters")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.GetAsync(id, ct)))
|
||||
(await service.GetAsync(id, ct)).ToApiResult())
|
||||
.WithSummary("Read a character dossier.");
|
||||
|
||||
characters.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateCharacterRequest request, CharacterService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.UpdateAsync(id, request, ct)))
|
||||
(await service.UpdateAsync(id, request, ct)).ToApiResult())
|
||||
.WithSummary("Update a character dossier.");
|
||||
|
||||
characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
|
||||
{
|
||||
await service.DeleteAsync(id, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithSummary("Delete a character.");
|
||||
|
||||
characters.MapPost("/{id:guid}/relationships", async (
|
||||
Guid id, CreateRelationshipRequest request, CharacterService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.AddRelationshipAsync(id, request, ct)))
|
||||
(await service.AddRelationshipAsync(id, request, ct)).ToApiResult())
|
||||
.WithSummary("Relate this character to another in the same project.");
|
||||
|
||||
characters.MapDelete("/relationships/{relationshipId:guid}", async (
|
||||
Guid relationshipId, CharacterService service, CancellationToken ct) =>
|
||||
{
|
||||
await service.RemoveRelationshipAsync(relationshipId, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
await service.RemoveRelationshipAsync(relationshipId, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithSummary("Remove a relationship.");
|
||||
|
||||
characters.MapGet("/{id:guid}/arc", async (
|
||||
@@ -69,22 +68,21 @@ 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").AddEndpointFilter<RequestLoggingEndpointFilter>();
|
||||
var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
arcStages.MapGet("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.GetAsync(id, ct)))
|
||||
(await service.GetAsync(id, ct)).ToApiResult())
|
||||
.WithSummary("Read one arc stage.");
|
||||
|
||||
arcStages.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateArcStageRequest request, CharacterArcService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.UpdateAsync(id, request, ct)))
|
||||
(await service.UpdateAsync(id, request, ct)).ToApiResult())
|
||||
.WithSummary("Update an arc stage.");
|
||||
|
||||
arcStages.MapDelete("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) =>
|
||||
{
|
||||
await service.DeleteAsync(id, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithSummary("Delete an arc stage.");
|
||||
|
||||
return app;
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Characters;
|
||||
|
||||
public class CharacterService(INovelDbContext db, TagService tags, ILogger<CharacterService> logger)
|
||||
public class CharacterService(
|
||||
INovelDbContext db,
|
||||
TagService tags,
|
||||
ILogger<CharacterService> logger,
|
||||
IModelValidator<CreateCharacterRequest> createValidator,
|
||||
IModelValidator<UpdateCharacterRequest> updateValidator,
|
||||
IModelValidator<CreateRelationshipRequest> relationshipValidator)
|
||||
{
|
||||
/// <summary>
|
||||
/// Main characters first, then by the part they play, then by name.
|
||||
@@ -20,6 +27,8 @@ public class CharacterService(INovelDbContext db, TagService tags, ILogger<Chara
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<CharacterDto>> ListAsync(Guid projectId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
|
||||
logger.LogInformation("Listing characters for project {ProjectId}", projectId);
|
||||
|
||||
var characters = await Query()
|
||||
@@ -36,14 +45,21 @@ public class CharacterService(INovelDbContext db, TagService tags, ILogger<Chara
|
||||
];
|
||||
}
|
||||
|
||||
public async Task<CharacterDto> GetAsync(Guid id, CancellationToken ct = default)
|
||||
/// <summary>Null when no character has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<CharacterDto?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Getting character {CharacterId}", id);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
return (await FindAsync(id, ct))?.ToDto();
|
||||
}
|
||||
|
||||
public async Task<CharacterDto> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Null(request, nameof(request));
|
||||
createValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation("Creating character {Name} for project {ProjectId}, role {Role}, importance {Importance}", request.Name, projectId, request.Role, request.Importance);
|
||||
|
||||
await EnsureProjectExists(projectId, ct);
|
||||
@@ -76,14 +92,24 @@ public class CharacterService(INovelDbContext db, TagService tags, ILogger<Chara
|
||||
|
||||
db.Characters.Add(character);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(character.Id, ct)).ToDto();
|
||||
|
||||
// Just created it — the reload is only to pick up includes, not to check existence.
|
||||
return (await FindAsync(character.Id, ct))!.ToDto();
|
||||
}
|
||||
|
||||
public async Task<CharacterDto> UpdateAsync(Guid id, UpdateCharacterRequest request, CancellationToken ct = default)
|
||||
public async Task<CharacterDto?> UpdateAsync(Guid id, UpdateCharacterRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
Guard.Null(request, nameof(request));
|
||||
updateValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation("Updating character {CharacterId}", id);
|
||||
|
||||
var character = await FindAsync(id, ct);
|
||||
if (character is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name;
|
||||
character.Role = request.Role ?? character.Role;
|
||||
@@ -109,29 +135,47 @@ public class CharacterService(INovelDbContext db, TagService tags, ILogger<Chara
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
return (await FindAsync(id, ct))!.ToDto();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
/// <summary>True if a character was deleted; false if no character had this id.</summary>
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Deleting character {CharacterId}", id);
|
||||
|
||||
var character = await FindAsync(id, ct);
|
||||
if (character is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
db.Characters.Remove(character);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<CharacterDto> AddRelationshipAsync(
|
||||
/// <summary>Null when the subject character (<paramref name="characterId"/>) doesn't exist.</summary>
|
||||
public async Task<CharacterDto?> AddRelationshipAsync(
|
||||
Guid characterId, CreateRelationshipRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(characterId, nameof(characterId));
|
||||
Guard.Null(request, nameof(request));
|
||||
relationshipValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation("Adding relationship {RelationshipType} from character {CharacterId} to {RelatedCharacterId}", request.RelationshipType, characterId, request.RelatedCharacterId);
|
||||
|
||||
var character = await FindAsync(characterId, ct);
|
||||
if (character is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var related = await db.Characters.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct);
|
||||
if (related is null)
|
||||
{
|
||||
logger.LogWarning("Character {RelatedCharacterId} not found", request.RelatedCharacterId);
|
||||
logger.LogWarning("Rejected relationship: related character {RelatedCharacterId} not found", request.RelatedCharacterId);
|
||||
throw new NotFoundException(nameof(Character), request.RelatedCharacterId);
|
||||
}
|
||||
|
||||
@@ -150,22 +194,26 @@ public class CharacterService(INovelDbContext db, TagService tags, ILogger<Chara
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(characterId, ct)).ToDto();
|
||||
return (await FindAsync(characterId, ct))!.ToDto();
|
||||
}
|
||||
|
||||
public async Task RemoveRelationshipAsync(Guid relationshipId, CancellationToken ct = default)
|
||||
/// <summary>True if a relationship was removed; false if no relationship had this id.</summary>
|
||||
public async Task<bool> RemoveRelationshipAsync(Guid relationshipId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(relationshipId, nameof(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);
|
||||
logger.LogInformation("CharacterRelationship {RelationshipId} not found", relationshipId);
|
||||
return false;
|
||||
}
|
||||
|
||||
db.CharacterRelationships.Remove(relationship);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private IQueryable<Character> Query() =>
|
||||
@@ -176,18 +224,20 @@ public class CharacterService(INovelDbContext db, TagService tags, ILogger<Chara
|
||||
.Include(c => c.ArcStages)
|
||||
.ThenInclude(s => s.Chapter);
|
||||
|
||||
private async Task<Character> FindAsync(Guid id, CancellationToken ct)
|
||||
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.LogInformation("Character {CharacterId} not found", id);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug("Found character {CharacterId}", id);
|
||||
}
|
||||
|
||||
logger.LogDebug("Found character {CharacterId}", id);
|
||||
return character;
|
||||
}
|
||||
|
||||
@@ -197,7 +247,7 @@ public class CharacterService(INovelDbContext db, TagService tags, ILogger<Chara
|
||||
|
||||
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
|
||||
{
|
||||
logger.LogWarning("Project {ProjectId} not found", projectId);
|
||||
logger.LogWarning("Rejected character creation: project {ProjectId} not found", projectId);
|
||||
throw new NotFoundException(nameof(Project), projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Novelly.Api.Common;
|
||||
|
||||
public static class ApiResultExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// A missing entity is not exceptional, so lookups return null instead of throwing.
|
||||
/// This is where that null finally becomes an HTTP 404 — the one place the API layer
|
||||
/// needs to know about it.
|
||||
/// </summary>
|
||||
public static IResult ToApiResult<T>(this T? value) where T : class =>
|
||||
value is null ? Results.NotFound() : Results.Ok(value);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace Novelly.Api.Common;
|
||||
|
||||
public static class Guard
|
||||
{
|
||||
public static void Null<T>(T t, string parameterName) where T : class
|
||||
{
|
||||
if (t is null)
|
||||
throw new ArgumentNullException(parameterName, $"{nameof(parameterName)} can not be null");
|
||||
}
|
||||
|
||||
public static void Empty(string value, string parameterName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new ArgumentException($"{parameterName} can not be empty", parameterName);
|
||||
}
|
||||
|
||||
public static void Empty<T>(IEnumerable<T> collection, string parameterName)
|
||||
{
|
||||
if (collection == null || !collection.Any())
|
||||
throw new ArgumentException($"{parameterName} can not be empty", parameterName);
|
||||
}
|
||||
|
||||
public static void Negative(int value, string parameterName)
|
||||
{
|
||||
if (value < 0)
|
||||
throw new ArgumentOutOfRangeException(parameterName, $"{parameterName} must be a positive number or zero");
|
||||
}
|
||||
|
||||
public static void NegativeOrZero(int value, string parameterName)
|
||||
{
|
||||
if (value <= 0)
|
||||
throw new ArgumentOutOfRangeException(parameterName, $"{nameof(parameterName)} must be a positive number greater then zero");
|
||||
}
|
||||
|
||||
public static void Default<T>(T value, string parameterName)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(value, default))
|
||||
throw new ArgumentException($"{parameterName} can not be a default value", parameterName);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Novelly.Api.Agent;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Questions;
|
||||
@@ -40,6 +41,8 @@ public static class NovellyServiceRegistration
|
||||
services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName));
|
||||
services.AddScoped<IAgentModelClient, AnthropicAgentModelClient>();
|
||||
|
||||
services.AddModelValidatorsFromAssemblyContaining<Program>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Novelly.Api.Common.Validation;
|
||||
|
||||
public interface IModelValidator
|
||||
{
|
||||
ValidationResult Validate(object model);
|
||||
}
|
||||
|
||||
public interface IModelValidator<in T> : IModelValidator
|
||||
{
|
||||
ValidationResult Validate(T model);
|
||||
|
||||
ValidationResult IModelValidator.Validate(object model) => Validate((T)model);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Novelly.Api.Common.Validation;
|
||||
|
||||
public static class ModelValidatorServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddModelValidatorsFromAssemblyContaining<TMarker>(this IServiceCollection services)
|
||||
{
|
||||
var registrations = typeof(TMarker).Assembly.GetTypes()
|
||||
.Where(type => !type.IsAbstract && !type.IsInterface)
|
||||
.SelectMany(type => type.GetInterfaces()
|
||||
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IModelValidator<>))
|
||||
.Select(i => (Interface: i, Implementation: type)));
|
||||
|
||||
foreach (var (@interface, implementation) in registrations)
|
||||
services.AddScoped(@interface, implementation);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Novelly.Api.Common.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal-API equivalent of mic-check's MVC <c>ModelValidationActionFilter</c>. Runs every
|
||||
/// endpoint argument that has a registered <see cref="IModelValidator{T}"/> through it and,
|
||||
/// if any fail, short-circuits with a 400 naming every field and message a caller can act on.
|
||||
/// </summary>
|
||||
public class ValidationEndpointFilter : IEndpointFilter
|
||||
{
|
||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
var errors = new Dictionary<string, string[]>();
|
||||
|
||||
foreach (var argument in context.Arguments)
|
||||
{
|
||||
if (argument is null) continue;
|
||||
|
||||
var validatorType = typeof(IModelValidator<>).MakeGenericType(argument.GetType());
|
||||
if (context.HttpContext.RequestServices.GetService(validatorType) is not IModelValidator validator) continue;
|
||||
|
||||
var result = validator.Validate(argument);
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
foreach (var group in result.Errors.GroupBy(e => e.PropertyName))
|
||||
{
|
||||
errors[group.Key] = [.. group.Select(e => e.Message)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return Results.ValidationProblem(errors);
|
||||
}
|
||||
|
||||
return await next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Novelly.Api.Common.Validation;
|
||||
|
||||
public record ValidationError(string PropertyName, string Message);
|
||||
|
||||
public class ValidationResult
|
||||
{
|
||||
private readonly List<ValidationError> _errors = [];
|
||||
|
||||
public IReadOnlyList<ValidationError> Errors => _errors;
|
||||
public bool IsValid => _errors.Count == 0;
|
||||
public bool IsInvalid => _errors.Count > 0;
|
||||
|
||||
public void AddError(string propertyName, string message) => _errors.Add(new ValidationError(propertyName, message));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Novelly.Api.Common.Validation;
|
||||
|
||||
public static class ValidationResultExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// The service-level half of "validate again and throw if invalid": callers that reach
|
||||
/// a service directly (agent tools, MCP, tests) skip the API's <see cref="ValidationEndpointFilter"/>,
|
||||
/// so services re-run the same validator and throw rather than act on bad data.
|
||||
/// </summary>
|
||||
public static void ThrowIfInvalid(this ValidationResult result)
|
||||
{
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
throw new ArgumentException(string.Join("; ", result.Errors.Select(e => $"{e.PropertyName}: {e.Message}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Projects;
|
||||
|
||||
public record ProjectSummaryDto(
|
||||
@@ -33,6 +35,19 @@ public record CreateProjectRequest(
|
||||
string? Notes = null,
|
||||
int? TargetWordCount = null);
|
||||
|
||||
public class CreateProjectRequestValidator : IModelValidator<CreateProjectRequest>
|
||||
{
|
||||
public ValidationResult Validate(CreateProjectRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
ProjectValidation.Title(model.Title, result);
|
||||
ProjectValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Patch-style update: every field is optional and null means "leave alone".
|
||||
/// Clearing a field is done by sending an empty string.
|
||||
@@ -46,6 +61,59 @@ public record UpdateProjectRequest(
|
||||
string? Notes = null,
|
||||
int? TargetWordCount = null);
|
||||
|
||||
public class UpdateProjectRequestValidator : IModelValidator<UpdateProjectRequest>
|
||||
{
|
||||
public ValidationResult Validate(UpdateProjectRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (model.Title is not null)
|
||||
{
|
||||
if (model.Title.Length == 0)
|
||||
result.AddError("Title", "'Title' can not be cleared — a project always needs one.");
|
||||
else if (model.Title.Length > 200)
|
||||
result.AddError("Title", "'Title' must be 200 characters or fewer.");
|
||||
}
|
||||
|
||||
ProjectValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
file static class ProjectValidation
|
||||
{
|
||||
public static void Title(string title, ValidationResult result)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
result.AddError("Title", "'Title' must not be empty.");
|
||||
else if (title.Length > 200)
|
||||
result.AddError("Title", "'Title' must be 200 characters or fewer.");
|
||||
}
|
||||
|
||||
public static void OptionalFields(
|
||||
string? author, string? genre, string? logline, string? synopsis, string? notes, int? targetWordCount, ValidationResult result)
|
||||
{
|
||||
if (author is { Length: > 200 })
|
||||
result.AddError("Author", "'Author' must be 200 characters or fewer.");
|
||||
|
||||
if (genre is { Length: > 100 })
|
||||
result.AddError("Genre", "'Genre' must be 100 characters or fewer.");
|
||||
|
||||
if (logline is { Length: > 500 })
|
||||
result.AddError("Logline", "'Logline' must be 500 characters or fewer.");
|
||||
|
||||
if (synopsis is { Length: > 20000 })
|
||||
result.AddError("Synopsis", "'Synopsis' must be 20,000 characters or fewer.");
|
||||
|
||||
if (notes is { Length: > 20000 })
|
||||
result.AddError("Notes", "'Notes' must be 20,000 characters or fewer.");
|
||||
|
||||
if (targetWordCount is < 0)
|
||||
result.AddError("TargetWordCount", "'Target Word Count' must be zero or greater.");
|
||||
}
|
||||
}
|
||||
|
||||
public static class ProjectMapping
|
||||
{
|
||||
public static ProjectDto ToDto(this Project p) => new(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Projects;
|
||||
|
||||
@@ -6,14 +7,16 @@ public static class ProjectEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/projects").WithTags("Projects").AddEndpointFilter<RequestLoggingEndpointFilter>();
|
||||
var group = app.MapGroup("/api/projects").WithTags("Projects")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
group.MapGet("/", async (ProjectService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(ct)))
|
||||
.WithSummary("List all novel projects.");
|
||||
|
||||
group.MapGet("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.GetAsync(id, ct)))
|
||||
(await service.GetAsync(id, ct)).ToApiResult())
|
||||
.WithSummary("Read a project's brief.");
|
||||
|
||||
group.MapPost("/", async (CreateProjectRequest request, ProjectService service, CancellationToken ct) =>
|
||||
@@ -25,14 +28,11 @@ public static class ProjectEndpoints
|
||||
|
||||
group.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateProjectRequest request, ProjectService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.UpdateAsync(id, request, ct)))
|
||||
(await service.UpdateAsync(id, request, ct)).ToApiResult())
|
||||
.WithSummary("Update a project's brief.");
|
||||
|
||||
group.MapDelete("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
|
||||
{
|
||||
await service.DeleteAsync(id, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithSummary("Delete a project and everything in it.");
|
||||
|
||||
return app;
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
|
||||
namespace Novelly.Api.Projects;
|
||||
|
||||
public class ProjectService(INovelDbContext db, ILogger<ProjectService> logger)
|
||||
public class ProjectService(
|
||||
INovelDbContext db,
|
||||
ILogger<ProjectService> logger,
|
||||
IModelValidator<CreateProjectRequest> createValidator,
|
||||
IModelValidator<UpdateProjectRequest> updateValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<ProjectSummaryDto>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
@@ -26,14 +31,20 @@ public class ProjectService(INovelDbContext db, ILogger<ProjectService> logger)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<ProjectDto> GetAsync(Guid id, CancellationToken ct = default)
|
||||
/// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<ProjectDto?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Getting project {ProjectId}", id);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
return (await FindAsync(id, ct))?.ToDto();
|
||||
}
|
||||
|
||||
public async Task<ProjectDto> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Null(request, nameof(request));
|
||||
createValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation("Creating project {Title}", request.Title);
|
||||
|
||||
var project = new Project
|
||||
@@ -52,11 +63,19 @@ public class ProjectService(INovelDbContext db, ILogger<ProjectService> logger)
|
||||
return project.ToDto();
|
||||
}
|
||||
|
||||
public async Task<ProjectDto> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
|
||||
public async Task<ProjectDto?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
Guard.Null(request, nameof(request));
|
||||
updateValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation("Updating project {ProjectId}", id);
|
||||
|
||||
var project = await FindAsync(id, ct);
|
||||
if (project is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
project.Title = Patch.Apply(project.Title, request.Title) ?? project.Title;
|
||||
project.Author = Patch.Apply(project.Author, request.Author);
|
||||
@@ -71,27 +90,38 @@ public class ProjectService(INovelDbContext db, ILogger<ProjectService> logger)
|
||||
return project.ToDto();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
/// <summary>True if a project was deleted; false if no project had this id.</summary>
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Deleting project {ProjectId}", id);
|
||||
|
||||
var project = await FindAsync(id, ct);
|
||||
if (project is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
db.Projects.Remove(project);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<Project> FindAsync(Guid id, CancellationToken ct)
|
||||
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.LogInformation("Project {ProjectId} not found", id);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug("Found project {ProjectId}", id);
|
||||
}
|
||||
|
||||
logger.LogDebug("Found project {ProjectId}", id);
|
||||
return project;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Questions;
|
||||
|
||||
public record OpenQuestionDto(
|
||||
@@ -22,6 +24,24 @@ public record CreateOpenQuestionRequest(
|
||||
Guid? ChapterId = null,
|
||||
Guid? CharacterId = null);
|
||||
|
||||
public class CreateOpenQuestionRequestValidator : IModelValidator<CreateOpenQuestionRequest>
|
||||
{
|
||||
public ValidationResult Validate(CreateOpenQuestionRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.Question))
|
||||
result.AddError("Question", "'Question' must not be empty.");
|
||||
else if (model.Question.Length > 1000)
|
||||
result.AddError("Question", "'Question' must be 1,000 characters or fewer.");
|
||||
|
||||
if (model.Detail is { Length: > 20000 })
|
||||
result.AddError("Detail", "'Detail' must be 20,000 characters or fewer.");
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Patch-style update. A null field is left alone; an empty string clears it. Use
|
||||
/// <see cref="ClearChapter"/> / <see cref="ClearCharacter"/> to detach a question, since a
|
||||
@@ -35,6 +55,27 @@ public record UpdateOpenQuestionRequest(
|
||||
bool ClearChapter = false,
|
||||
bool ClearCharacter = false);
|
||||
|
||||
public class UpdateOpenQuestionRequestValidator : IModelValidator<UpdateOpenQuestionRequest>
|
||||
{
|
||||
public ValidationResult Validate(UpdateOpenQuestionRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (model.Question is not null)
|
||||
{
|
||||
if (model.Question.Length == 0)
|
||||
result.AddError("Question", "'Question' can not be cleared — a question always needs one.");
|
||||
else if (model.Question.Length > 1000)
|
||||
result.AddError("Question", "'Question' must be 1,000 characters or fewer.");
|
||||
}
|
||||
|
||||
if (model.Detail is { Length: > 20000 })
|
||||
result.AddError("Detail", "'Detail' must be 20,000 characters or fewer.");
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Settles a question. The resolution is kept on the question itself; setting
|
||||
/// <see cref="AppendToNotes"/> also appends it to the notes of whatever the question is
|
||||
@@ -42,6 +83,21 @@ public record UpdateOpenQuestionRequest(
|
||||
/// </summary>
|
||||
public record ResolveOpenQuestionRequest(string Resolution, bool AppendToNotes = false);
|
||||
|
||||
public class ResolveOpenQuestionRequestValidator : IModelValidator<ResolveOpenQuestionRequest>
|
||||
{
|
||||
public ValidationResult Validate(ResolveOpenQuestionRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.Resolution))
|
||||
result.AddError("Resolution", "'Resolution' must not be empty.");
|
||||
else if (model.Resolution.Length > 20000)
|
||||
result.AddError("Resolution", "'Resolution' must be 20,000 characters or fewer.");
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public static class OpenQuestionMapping
|
||||
{
|
||||
public static OpenQuestionDto ToDto(this OpenQuestion q) => new(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Questions;
|
||||
|
||||
@@ -6,7 +7,9 @@ public static class OpenQuestionEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapOpenQuestionEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/questions").WithTags("Questions").AddEndpointFilter<RequestLoggingEndpointFilter>();
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/questions").WithTags("Questions")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/", async (
|
||||
Guid projectId,
|
||||
@@ -26,31 +29,30 @@ 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").AddEndpointFilter<RequestLoggingEndpointFilter>();
|
||||
var questions = app.MapGroup("/api/questions").WithTags("Questions")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
questions.MapGet("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.GetAsync(id, ct)))
|
||||
(await service.GetAsync(id, ct)).ToApiResult())
|
||||
.WithSummary("Read one question.");
|
||||
|
||||
questions.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.UpdateAsync(id, request, ct)))
|
||||
(await service.UpdateAsync(id, request, ct)).ToApiResult())
|
||||
.WithSummary("Update a question or change what it is attached to.");
|
||||
|
||||
questions.MapPost("/{id:guid}/resolve", async (
|
||||
Guid id, ResolveOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ResolveAsync(id, request, ct)))
|
||||
(await service.ResolveAsync(id, request, ct)).ToApiResult())
|
||||
.WithSummary("Settle a question, optionally appending the resolution to the notes it hangs off.");
|
||||
|
||||
questions.MapPost("/{id:guid}/reopen", async (Guid id, OpenQuestionService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ReopenAsync(id, ct)))
|
||||
(await service.ReopenAsync(id, ct)).ToApiResult())
|
||||
.WithSummary("Put a resolved question back on the list.");
|
||||
|
||||
questions.MapDelete("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) =>
|
||||
{
|
||||
await service.DeleteAsync(id, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithSummary("Delete a question.");
|
||||
|
||||
return app;
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
|
||||
@@ -11,7 +12,12 @@ 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, ILogger<OpenQuestionService> logger)
|
||||
public class OpenQuestionService(
|
||||
INovelDbContext db,
|
||||
ILogger<OpenQuestionService> logger,
|
||||
IModelValidator<CreateOpenQuestionRequest> createValidator,
|
||||
IModelValidator<UpdateOpenQuestionRequest> updateValidator,
|
||||
IModelValidator<ResolveOpenQuestionRequest> resolveValidator)
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists a project's questions, open ones first and newest first within each group.
|
||||
@@ -25,6 +31,8 @@ public class OpenQuestionService(INovelDbContext db, ILogger<OpenQuestionService
|
||||
bool includeResolved = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
|
||||
logger.LogInformation(
|
||||
"Listing open questions for project {ProjectId}, chapter {ChapterId}, character {CharacterId}, includeResolved {IncludeResolved}",
|
||||
projectId, chapterId, characterId, includeResolved);
|
||||
@@ -57,29 +65,30 @@ public class OpenQuestionService(INovelDbContext db, ILogger<OpenQuestionService
|
||||
];
|
||||
}
|
||||
|
||||
public async Task<OpenQuestionDto> GetAsync(Guid id, CancellationToken ct = default)
|
||||
/// <summary>Null when no open question has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<OpenQuestionDto?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Getting open question {QuestionId}", id);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
return (await FindAsync(id, ct))?.ToDto();
|
||||
}
|
||||
|
||||
public async Task<OpenQuestionDto> CreateAsync(
|
||||
Guid projectId, CreateOpenQuestionRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Null(request, nameof(request));
|
||||
createValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
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);
|
||||
logger.LogWarning("Rejected open question creation: 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.");
|
||||
}
|
||||
|
||||
await ValidateAssociationsAsync(projectId, request.ChapterId, request.CharacterId, ct);
|
||||
|
||||
var question = new OpenQuestion
|
||||
@@ -93,15 +102,25 @@ public class OpenQuestionService(INovelDbContext db, ILogger<OpenQuestionService
|
||||
|
||||
db.OpenQuestions.Add(question);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(question.Id, ct)).ToDto();
|
||||
|
||||
// Just created it — the reload is only to pick up includes, not to check existence.
|
||||
return (await FindAsync(question.Id, ct))!.ToDto();
|
||||
}
|
||||
|
||||
public async Task<OpenQuestionDto> UpdateAsync(
|
||||
public async Task<OpenQuestionDto?> UpdateAsync(
|
||||
Guid id, UpdateOpenQuestionRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
Guard.Null(request, nameof(request));
|
||||
updateValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation("Updating open question {QuestionId}", id);
|
||||
|
||||
var question = await FindAsync(id, ct);
|
||||
if (question is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await ValidateAssociationsAsync(question.ProjectId, request.ChapterId, request.CharacterId, ct);
|
||||
|
||||
@@ -112,25 +131,28 @@ public class OpenQuestionService(INovelDbContext db, ILogger<OpenQuestionService
|
||||
question.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
return (await FindAsync(id, ct))!.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Settles a question. With <c>AppendToNotes</c> the resolution is also appended to the
|
||||
/// notes of the chapter and character it hangs off, so the decision ends up where the
|
||||
/// writer reads rather than only in a list they have stopped looking at.
|
||||
/// writer reads rather than only in a list they have stopped looking at. Null when no
|
||||
/// open question has this id.
|
||||
/// </summary>
|
||||
public async Task<OpenQuestionDto> ResolveAsync(
|
||||
public async Task<OpenQuestionDto?> ResolveAsync(
|
||||
Guid id, ResolveOpenQuestionRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
Guard.Null(request, nameof(request));
|
||||
resolveValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation("Resolving open question {QuestionId}, appendToNotes {AppendToNotes}", id, request.AppendToNotes);
|
||||
|
||||
var question = await FindAsync(id, ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Resolution))
|
||||
if (question is null)
|
||||
{
|
||||
logger.LogWarning("Rejected resolution for open question {QuestionId}: resolution text was blank", id);
|
||||
throw new ArgumentException("A resolution needs to say what was decided.");
|
||||
return null;
|
||||
}
|
||||
|
||||
question.Resolution = request.Resolution.Trim();
|
||||
@@ -167,31 +189,46 @@ public class OpenQuestionService(INovelDbContext db, ILogger<OpenQuestionService
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
return (await FindAsync(id, ct))!.ToDto();
|
||||
}
|
||||
|
||||
/// <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)
|
||||
/// <summary>Puts a question back on the list. The resolution goes; anything already appended to notes stays. Null when no open question has this id.</summary>
|
||||
public async Task<OpenQuestionDto?> ReopenAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Reopening open question {QuestionId}", id);
|
||||
|
||||
var question = await FindAsync(id, ct);
|
||||
if (question is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
question.Resolution = null;
|
||||
question.ResolvedAt = null;
|
||||
question.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
return (await FindAsync(id, ct))!.ToDto();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
/// <summary>True if an open question was deleted; false if no question had this id.</summary>
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Deleting open question {QuestionId}", id);
|
||||
|
||||
var question = await FindAsync(id, ct);
|
||||
if (question is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
db.OpenQuestions.Remove(question);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Blank line between entries, so appended resolutions stay readable as notes accumulate.</summary>
|
||||
@@ -223,18 +260,20 @@ public class OpenQuestionService(INovelDbContext db, ILogger<OpenQuestionService
|
||||
private IQueryable<OpenQuestion> Query() =>
|
||||
db.OpenQuestions.Include(q => q.Chapter).Include(q => q.Character);
|
||||
|
||||
private async Task<OpenQuestion> FindAsync(Guid id, CancellationToken ct)
|
||||
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.LogInformation("OpenQuestion {QuestionId} not found", id);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug("Found open question {QuestionId}", id);
|
||||
}
|
||||
|
||||
logger.LogDebug("Found open question {QuestionId}", id);
|
||||
return question;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Scenes;
|
||||
|
||||
@@ -31,6 +32,23 @@ public record CreateSceneRequest(
|
||||
string? Prose = null,
|
||||
DraftStatus Status = DraftStatus.Planned);
|
||||
|
||||
public class CreateSceneRequestValidator : IModelValidator<CreateSceneRequest>
|
||||
{
|
||||
public ValidationResult Validate(CreateSceneRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.Title))
|
||||
result.AddError("Title", "'Title' must not be empty.");
|
||||
else if (model.Title.Length > 200)
|
||||
result.AddError("Title", "'Title' must be 200 characters or fewer.");
|
||||
|
||||
SceneValidation.OptionalFields(model.SortOrder, model.Summary, model.Goal, model.Conflict, model.Outcome, model.Location, model.Prose, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateSceneRequest(
|
||||
string? Title = null,
|
||||
int? SortOrder = null,
|
||||
@@ -43,6 +61,54 @@ public record UpdateSceneRequest(
|
||||
string? Prose = null,
|
||||
DraftStatus? Status = null);
|
||||
|
||||
public class UpdateSceneRequestValidator : IModelValidator<UpdateSceneRequest>
|
||||
{
|
||||
public ValidationResult Validate(UpdateSceneRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (model.Title is not null)
|
||||
{
|
||||
if (model.Title.Length == 0)
|
||||
result.AddError("Title", "'Title' can not be cleared — a scene always needs one.");
|
||||
else if (model.Title.Length > 200)
|
||||
result.AddError("Title", "'Title' must be 200 characters or fewer.");
|
||||
}
|
||||
|
||||
SceneValidation.OptionalFields(model.SortOrder, model.Summary, model.Goal, model.Conflict, model.Outcome, model.Location, model.Prose, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
file static class SceneValidation
|
||||
{
|
||||
public static void OptionalFields(
|
||||
int? sortOrder, string? summary, string? goal, string? conflict, string? outcome, string? location, string? prose, ValidationResult result)
|
||||
{
|
||||
if (sortOrder is < 0)
|
||||
result.AddError("SortOrder", "'Sort Order' must be zero or greater.");
|
||||
|
||||
if (summary is { Length: > 20000 })
|
||||
result.AddError("Summary", "'Summary' must be 20,000 characters or fewer.");
|
||||
|
||||
if (goal is { Length: > 20000 })
|
||||
result.AddError("Goal", "'Goal' must be 20,000 characters or fewer.");
|
||||
|
||||
if (conflict is { Length: > 20000 })
|
||||
result.AddError("Conflict", "'Conflict' must be 20,000 characters or fewer.");
|
||||
|
||||
if (outcome is { Length: > 20000 })
|
||||
result.AddError("Outcome", "'Outcome' must be 20,000 characters or fewer.");
|
||||
|
||||
if (location is { Length: > 500 })
|
||||
result.AddError("Location", "'Location' must be 500 characters or fewer.");
|
||||
|
||||
if (prose is { Length: > 100000 })
|
||||
result.AddError("Prose", "'Prose' must be 100,000 characters or fewer.");
|
||||
}
|
||||
}
|
||||
|
||||
public static class SceneMapping
|
||||
{
|
||||
public static SceneDto ToDto(this Scene s) => new(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Scenes;
|
||||
|
||||
@@ -6,7 +7,9 @@ public static class SceneEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSceneEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/scenes").WithTags("Scenes").AddEndpointFilter<RequestLoggingEndpointFilter>();
|
||||
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/scenes").WithTags("Scenes")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
chapterScoped.MapGet("/", async (Guid chapterId, SceneService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(chapterId, ct)))
|
||||
@@ -20,22 +23,21 @@ public static class SceneEndpoints
|
||||
})
|
||||
.WithSummary("Add a scene to a chapter.");
|
||||
|
||||
var scenes = app.MapGroup("/api/scenes").WithTags("Scenes").AddEndpointFilter<RequestLoggingEndpointFilter>();
|
||||
var scenes = app.MapGroup("/api/scenes").WithTags("Scenes")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
scenes.MapGet("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.GetAsync(id, ct)))
|
||||
(await service.GetAsync(id, ct)).ToApiResult())
|
||||
.WithSummary("Read a scene, including its prose.");
|
||||
|
||||
scenes.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateSceneRequest request, SceneService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.UpdateAsync(id, request, ct)))
|
||||
(await service.UpdateAsync(id, request, ct)).ToApiResult())
|
||||
.WithSummary("Update a scene. Sending prose recomputes the word count.");
|
||||
|
||||
scenes.MapDelete("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) =>
|
||||
{
|
||||
await service.DeleteAsync(id, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithSummary("Delete a scene.");
|
||||
|
||||
return app;
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
|
||||
namespace Novelly.Api.Scenes;
|
||||
|
||||
public class SceneService(INovelDbContext db, ILogger<SceneService> logger)
|
||||
public class SceneService(
|
||||
INovelDbContext db,
|
||||
ILogger<SceneService> logger,
|
||||
IModelValidator<CreateSceneRequest> createValidator,
|
||||
IModelValidator<UpdateSceneRequest> updateValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<SceneDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(chapterId, nameof(chapterId));
|
||||
|
||||
logger.LogInformation("Listing scenes for chapter {ChapterId}", chapterId);
|
||||
|
||||
var scenes = await Query()
|
||||
@@ -19,19 +26,26 @@ public class SceneService(INovelDbContext db, ILogger<SceneService> logger)
|
||||
return [.. scenes.Select(s => s.ToDto())];
|
||||
}
|
||||
|
||||
public async Task<SceneDto> GetAsync(Guid id, CancellationToken ct = default)
|
||||
/// <summary>Null when no scene has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<SceneDto?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Getting scene {SceneId}", id);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
return (await FindAsync(id, ct))?.ToDto();
|
||||
}
|
||||
|
||||
public async Task<SceneDto> CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(chapterId, nameof(chapterId));
|
||||
Guard.Null(request, nameof(request));
|
||||
createValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
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);
|
||||
logger.LogWarning("Rejected scene creation: chapter {ChapterId} not found", chapterId);
|
||||
throw new NotFoundException(nameof(Chapter), chapterId);
|
||||
}
|
||||
|
||||
@@ -53,14 +67,24 @@ public class SceneService(INovelDbContext db, ILogger<SceneService> logger)
|
||||
|
||||
db.Scenes.Add(scene);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(scene.Id, ct)).ToDto();
|
||||
|
||||
// Just created it — the reload is only to pick up includes, not to check existence.
|
||||
return (await FindAsync(scene.Id, ct))!.ToDto();
|
||||
}
|
||||
|
||||
public async Task<SceneDto> UpdateAsync(Guid id, UpdateSceneRequest request, CancellationToken ct = default)
|
||||
public async Task<SceneDto?> UpdateAsync(Guid id, UpdateSceneRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
Guard.Null(request, nameof(request));
|
||||
updateValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation("Updating scene {SceneId}, prose length {ProseLength}", id, request.Prose?.Length ?? 0);
|
||||
|
||||
var scene = await FindAsync(id, ct);
|
||||
if (scene is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
scene.Title = Patch.Apply(scene.Title, request.Title) ?? scene.Title;
|
||||
scene.SortOrder = request.SortOrder ?? scene.SortOrder;
|
||||
@@ -81,16 +105,25 @@ public class SceneService(INovelDbContext db, ILogger<SceneService> logger)
|
||||
scene.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
return (await FindAsync(id, ct))!.ToDto();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
/// <summary>True if a scene was deleted; false if no scene had this id.</summary>
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Deleting scene {SceneId}", id);
|
||||
|
||||
var scene = await FindAsync(id, ct);
|
||||
if (scene is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
db.Scenes.Remove(scene);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
|
||||
@@ -106,18 +139,20 @@ public class SceneService(INovelDbContext db, ILogger<SceneService> logger)
|
||||
|
||||
private IQueryable<Scene> Query() => db.Scenes.Include(s => s.PovCharacter);
|
||||
|
||||
private async Task<Scene> FindAsync(Guid id, CancellationToken ct)
|
||||
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.LogInformation("Scene {SceneId} not found", id);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug("Found scene {SceneId}", id);
|
||||
}
|
||||
|
||||
logger.LogDebug("Found scene {SceneId}", id);
|
||||
return scene;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Tags;
|
||||
|
||||
public record TagDto(Guid Id, string Name, string? Color);
|
||||
@@ -15,8 +17,47 @@ public record TagSummaryDto(
|
||||
|
||||
public record CreateTagRequest(string Name, string? Color = null);
|
||||
|
||||
public class CreateTagRequestValidator : IModelValidator<CreateTagRequest>
|
||||
{
|
||||
public ValidationResult Validate(CreateTagRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.Name))
|
||||
result.AddError("Name", "'Name' must not be empty.");
|
||||
else if (model.Name.Length > 100)
|
||||
result.AddError("Name", "'Name' must be 100 characters or fewer.");
|
||||
|
||||
if (model.Color is { Length: > 50 })
|
||||
result.AddError("Color", "'Color' must be 50 characters or fewer.");
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateTagRequest(string? Name = null, string? Color = null);
|
||||
|
||||
public class UpdateTagRequestValidator : IModelValidator<UpdateTagRequest>
|
||||
{
|
||||
public ValidationResult Validate(UpdateTagRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (model.Name is not null)
|
||||
{
|
||||
if (model.Name.Length == 0)
|
||||
result.AddError("Name", "'Name' can not be cleared — a tag always needs one.");
|
||||
else if (model.Name.Length > 100)
|
||||
result.AddError("Name", "'Name' must be 100 characters or fewer.");
|
||||
}
|
||||
|
||||
if (model.Color is { Length: > 50 })
|
||||
result.AddError("Color", "'Color' must be 50 characters or fewer.");
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything carrying one tag, gathered in a single response. This is the whole point of
|
||||
/// tags — seeing that a motif touches two characters, a chapter and four beats is what
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Tags;
|
||||
|
||||
@@ -6,7 +7,9 @@ public static class TagEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapTagEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/tags").WithTags("Tags").AddEndpointFilter<RequestLoggingEndpointFilter>();
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/tags").WithTags("Tags")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/", async (Guid projectId, TagService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(projectId, ct)))
|
||||
@@ -20,22 +23,21 @@ 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").AddEndpointFilter<RequestLoggingEndpointFilter>();
|
||||
var tags = app.MapGroup("/api/tags").WithTags("Tags")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
tags.MapGet("/{id:guid}/references", async (Guid id, TagService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.GetReferencesAsync(id, ct)))
|
||||
(await service.GetReferencesAsync(id, ct)).ToApiResult())
|
||||
.WithSummary("Cross-reference: every character, chapter and beat carrying this tag.");
|
||||
|
||||
tags.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateTagRequest request, TagService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.UpdateAsync(id, request, ct)))
|
||||
(await service.UpdateAsync(id, request, ct)).ToApiResult())
|
||||
.WithSummary("Rename or recolour a tag.");
|
||||
|
||||
tags.MapDelete("/{id:guid}", async (Guid id, TagService service, CancellationToken ct) =>
|
||||
{
|
||||
await service.DeleteAsync(id, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithSummary("Delete a tag. Whatever carried it is left alone.");
|
||||
|
||||
return app;
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Tags;
|
||||
|
||||
public class TagService(INovelDbContext db, ILogger<TagService> logger)
|
||||
public class TagService(
|
||||
INovelDbContext db,
|
||||
ILogger<TagService> logger,
|
||||
IModelValidator<CreateTagRequest> createValidator,
|
||||
IModelValidator<UpdateTagRequest> updateValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<TagSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
|
||||
logger.LogInformation("Listing tags for project {ProjectId}", projectId);
|
||||
|
||||
return await db.Tags
|
||||
@@ -20,9 +27,11 @@ public class TagService(INovelDbContext db, ILogger<TagService> logger)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Everything in the project carrying this tag.</summary>
|
||||
public async Task<TagReferencesDto> GetReferencesAsync(Guid tagId, CancellationToken ct = default)
|
||||
/// <summary>Everything in the project carrying this tag. Null when no tag has this id.</summary>
|
||||
public async Task<TagReferencesDto?> GetReferencesAsync(Guid tagId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(tagId, nameof(tagId));
|
||||
|
||||
logger.LogInformation("Getting references for tag {TagId}", tagId);
|
||||
|
||||
var tag = await db.Tags
|
||||
@@ -34,8 +43,8 @@ public class TagService(INovelDbContext db, ILogger<TagService> logger)
|
||||
|
||||
if (tag is null)
|
||||
{
|
||||
logger.LogWarning("Tag {TagId} not found", tagId);
|
||||
throw new NotFoundException(nameof(Tag), tagId);
|
||||
logger.LogInformation("Tag {TagId} not found", tagId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new TagReferencesDto(
|
||||
@@ -62,20 +71,19 @@ public class TagService(INovelDbContext db, ILogger<TagService> logger)
|
||||
|
||||
public async Task<TagDto> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Null(request, nameof(request));
|
||||
createValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
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);
|
||||
logger.LogWarning("Rejected tag creation: 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)
|
||||
@@ -90,25 +98,24 @@ public class TagService(INovelDbContext db, ILogger<TagService> logger)
|
||||
return tag.ToDto();
|
||||
}
|
||||
|
||||
public async Task<TagDto> UpdateAsync(Guid tagId, UpdateTagRequest request, CancellationToken ct = default)
|
||||
public async Task<TagDto?> UpdateAsync(Guid tagId, UpdateTagRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(tagId, nameof(tagId));
|
||||
Guard.Null(request, nameof(request));
|
||||
updateValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
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);
|
||||
logger.LogInformation("Tag {TagId} not found", tagId);
|
||||
return null;
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -125,20 +132,23 @@ public class TagService(INovelDbContext db, ILogger<TagService> logger)
|
||||
return tag.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>Deletes a tag. Whatever carried it keeps existing — only the label goes.</summary>
|
||||
public async Task DeleteAsync(Guid tagId, CancellationToken ct = default)
|
||||
/// <summary>Deletes a tag. Whatever carried it keeps existing — only the label goes. True if deleted.</summary>
|
||||
public async Task<bool> DeleteAsync(Guid tagId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(tagId, nameof(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);
|
||||
logger.LogInformation("Tag {TagId} not found", tagId);
|
||||
return false;
|
||||
}
|
||||
|
||||
db.Tags.Remove(tag);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -149,6 +159,9 @@ public class TagService(INovelDbContext db, ILogger<TagService> logger)
|
||||
internal async Task<List<Tag>> ResolveAsync(
|
||||
Guid projectId, IReadOnlyList<string> names, CancellationToken ct)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Null(names, nameof(names));
|
||||
|
||||
logger.LogDebug("Resolving {Count} tag names for project {ProjectId}", names.Count, projectId);
|
||||
|
||||
var wanted = names
|
||||
|
||||
Reference in New Issue
Block a user