Files
novelly/src/Novelly.Api/Scenes/SceneDtos.cs
T
James Wampler 40f93e40a8 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.
2026-08-06 15:13:36 -07:00

129 lines
4.1 KiB
C#

using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Scenes;
public record SceneDto(
Guid Id,
Guid ChapterId,
int SortOrder,
string Title,
string? Summary,
string? Goal,
string? Conflict,
string? Outcome,
Guid? PovCharacterId,
string? PovCharacterName,
string? Location,
string? Prose,
int WordCount,
DraftStatus Status,
DateTimeOffset UpdatedAt);
public record CreateSceneRequest(
string Title,
int? SortOrder = null,
string? Summary = null,
string? Goal = null,
string? Conflict = null,
string? Outcome = null,
Guid? PovCharacterId = null,
string? Location = null,
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,
string? Summary = null,
string? Goal = null,
string? Conflict = null,
string? Outcome = null,
Guid? PovCharacterId = null,
string? Location = null,
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(
s.Id, s.ChapterId, s.SortOrder, s.Title, s.Summary,
s.Goal, s.Conflict, s.Outcome,
s.PovCharacterId, s.PovCharacter?.Name, s.Location,
s.Prose, s.WordCount, s.Status, s.UpdatedAt);
/// <summary>
/// Whitespace-delimited word count. Good enough for progress tracking, and it costs
/// nothing to recompute on every save.
/// </summary>
public static int CountWords(string? prose) =>
string.IsNullOrWhiteSpace(prose)
? 0
: prose.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
}