Reorganise by feature, rename to Novelly, add Aspire and a pre-push hook

The layered split into Domain/Application/Infrastructure/Api was forcing
organisation by layer: adding one capability meant touching four projects and
four folders that each held a slice of it. Those four projects are now one
feature-organised Novelly.Api, where each folder — Projects, Characters,
Chapters, Beats, Scenes, Tags, Agent — holds its entity, DTOs, service and
endpoints together. Common/ holds what genuinely crosses features (the patch
semantics, the two exception types, DraftStatus) and Data/ holds the DbContext
and migrations.

Six .NET projects become five: the three layer projects are gone, and
Novelly.AppHost and Novelly.ServiceDefaults are new.

- Namespaces move from NovelSoftware.* to Novelly.*, including the entity type
  names recorded in the EF model snapshots. The migration ids are untouched, so
  an existing novel.db still migrates cleanly — verified against a fresh file.
- Aspire orchestration mirrors the mic-check setup: the AppHost starts the API
  on :5080 and the Vite dev server on :5173, and the API picks up OpenTelemetry,
  health checks and service discovery from ServiceDefaults. /health and /alive
  now answer in development.
- A Husky pre-push hook runs scripts/ci/prepush.sh: build, test, then a web
  build. The scripts are plain bash so CI can run the same steps.
- The MCP server's env var is now NOVELLY_API_URL.

Verified beyond the build: 44 tests pass, the web client builds, the API was
exercised over curl (project/chapter/beat/tag round trip, tag cross-reference,
503 on the agent without a key while conversation listing still returns 200),
the MCP server was driven over stdio JSON-RPC (26 tools, errors still surface
the API's own message rather than being flattened), and the AppHost was run to
confirm both resources come up and Vite proxies /api through to the API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
James Wampler
2026-08-06 12:11:20 -07:00
co-authored by Claude Opus 5
parent 30e0c6926e
commit 725758ccd9
120 changed files with 811 additions and 421 deletions
+60
View File
@@ -0,0 +1,60 @@
using System.Text.Json;
namespace Novelly.Api.Agent;
/// <summary>A tool the model may call, described in the shape the Messages API expects.</summary>
public record AgentToolDefinition(string Name, string Description, JsonElement InputSchema);
/// <summary>One content block in a model turn.</summary>
public abstract record AgentContentBlock;
public record AgentTextBlock(string Text) : AgentContentBlock;
public record AgentToolUseBlock(string Id, string Name, JsonElement Input) : AgentContentBlock;
public record AgentToolResultBlock(string ToolUseId, string Content, bool IsError = false) : AgentContentBlock;
/// <summary>A full turn in the conversation sent to or received from the model.</summary>
public record AgentChatMessage(string Role, IReadOnlyList<AgentContentBlock> Content)
{
public static AgentChatMessage User(params AgentContentBlock[] content) => new("user", content);
public static AgentChatMessage Assistant(IReadOnlyList<AgentContentBlock> content) => new("assistant", content);
}
public record AgentModelResponse(IReadOnlyList<AgentContentBlock> Content, string? StopReason);
/// <summary>
/// The model-facing seam. Infrastructure implements this against the Anthropic SDK;
/// tests substitute a scripted stand-in so the agent loop can be exercised offline.
/// </summary>
public interface IAgentModelClient
{
Task<AgentModelResponse> CompleteAsync(
string systemPrompt,
IReadOnlyList<AgentChatMessage> messages,
IReadOnlyList<AgentToolDefinition> tools,
CancellationToken ct = default);
}
/// <summary>Configuration for the embedded writing agent.</summary>
public class AgentOptions
{
public const string SectionName = "Agent";
/// <summary>Anthropic model id. Defaults to the current Opus.</summary>
public string Model { get; set; } = "claude-opus-5";
public int MaxTokens { get; set; } = 16000;
/// <summary>Thinking depth: low | medium | high | xhigh | max.</summary>
public string Effort { get; set; } = "high";
/// <summary>
/// Ceiling on model round-trips per user turn. Each tool call costs one; without a
/// cap a confused model could loop indefinitely.
/// </summary>
public int MaxIterations { get; set; } = 12;
/// <summary>Falls back to the ANTHROPIC_API_KEY environment variable when unset.</summary>
public string? ApiKey { get; set; }
}
@@ -0,0 +1,49 @@
using Novelly.Api.Projects;
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 string Title { get; set; } = "New conversation";
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public List<AgentMessage> Messages { get; set; } = [];
}
/// <summary>
/// One turn in an agent conversation. Assistant turns may carry a record of the tools
/// the agent called, so the UI can show what it changed and the next request can replay
/// the turn back to the model.
/// </summary>
public class AgentMessage
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ConversationId { get; set; }
public AgentConversation? Conversation { get; set; }
public AgentRole Role { get; set; }
/// <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; }
/// <summary>The visible text of the turn.</summary>
public string Content { get; set; } = 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 DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
+29
View File
@@ -0,0 +1,29 @@
namespace Novelly.Api.Agent;
public record ConversationSummaryDto(
Guid Id,
Guid ProjectId,
string Title,
int MessageCount,
DateTimeOffset UpdatedAt);
public record ConversationDto(
Guid Id,
Guid ProjectId,
string Title,
IReadOnlyList<AgentMessageDto> Messages,
DateTimeOffset UpdatedAt);
public record AgentMessageDto(
Guid Id,
AgentRole Role,
string Content,
IReadOnlyList<ToolCallDto> ToolCalls,
DateTimeOffset CreatedAt);
/// <summary>A record of one tool the agent invoked, surfaced so the writer can audit changes.</summary>
public record ToolCallDto(string Name, string Input, string Result);
public record SendAgentMessageRequest(string Message, Guid? ConversationId = null);
public record AgentTurnDto(Guid ConversationId, AgentMessageDto Message);
+37
View File
@@ -0,0 +1,37 @@
namespace Novelly.Api.Agent;
public static class AgentEndpoints
{
public static IEndpointRouteBuilder MapAgentEndpoints(this IEndpointRouteBuilder app)
{
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/agent").WithTags("Agent");
projectScoped.MapGet("/conversations", async (
Guid projectId, NovelAgentService agent, CancellationToken ct) =>
Results.Ok(await agent.ListConversationsAsync(projectId, ct)))
.WithSummary("List the project's agent conversations.");
projectScoped.MapPost("/messages", async (
Guid projectId,
SendAgentMessageRequest request,
NovelAgentService agent,
CancellationToken ct) =>
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");
conversations.MapGet("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) =>
Results.Ok(await agent.GetConversationAsync(id, ct)))
.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();
})
.WithSummary("Delete a conversation.");
return app;
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace Novelly.Api.Agent;
/// <summary>Who produced a message in an agent conversation.</summary>
public enum AgentRole
{
User,
Assistant
}
@@ -0,0 +1,161 @@
using System.Text.Json;
using Anthropic.Models.Messages;
using Anthropic;
using Microsoft.Extensions.Options;
using Novelly.Api.Common;
namespace Novelly.Api.Agent;
/// <summary>
/// Talks to the Anthropic Messages API. Translates between the application's
/// model-agnostic block types and the SDK's request/response shapes; the tool-use loop
/// itself lives in <see cref="NovelAgentService"/>.
/// </summary>
public class AnthropicAgentModelClient(IOptions<AgentOptions> options) : IAgentModelClient
{
private readonly AgentOptions _options = options.Value;
private AnthropicClient? _client;
/// <summary>
/// Built on first use rather than at construction. This type is injected into the
/// agent service, which also serves read-only endpoints like listing conversations —
/// those should keep working on an install that has not set up a key yet.
/// </summary>
private AnthropicClient Client => _client ??= new AnthropicClient
{
ApiKey = _options.ApiKey
?? Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")
?? throw new AgentNotConfiguredException(
"No Anthropic API key configured. Set the ANTHROPIC_API_KEY environment "
+ "variable or the Agent:ApiKey setting, then restart the API.")
};
public async Task<AgentModelResponse> CompleteAsync(
string systemPrompt,
IReadOnlyList<AgentChatMessage> messages,
IReadOnlyList<AgentToolDefinition> tools,
CancellationToken ct = default)
{
var parameters = new MessageCreateParams
{
Model = _options.Model,
MaxTokens = _options.MaxTokens,
System = new List<TextBlockParam>
{
// The system prompt is stable across a conversation, so cache it: every
// turn after the first reads it back at a tenth of the input price.
new() { Text = systemPrompt, CacheControl = new CacheControlEphemeral() }
},
OutputConfig = new OutputConfig { Effort = ParseEffort(_options.Effort) },
Tools = [.. tools.Select(ToSdkTool)],
Messages = [.. messages.Select(ToSdkMessage)]
};
var response = await Client.Messages.Create(parameters, cancellationToken: ct);
return new AgentModelResponse(
[.. response.Content.Select(FromSdkBlock).OfType<AgentContentBlock>()],
response.StopReason?.ToString());
}
private static Effort ParseEffort(string effort) => effort.ToLowerInvariant() switch
{
"low" => Effort.Low,
"medium" => Effort.Medium,
"high" => Effort.High,
"max" => Effort.Max,
_ => Effort.High
};
private static ToolUnion ToSdkTool(AgentToolDefinition definition)
{
var properties = new Dictionary<string, JsonElement>();
if (definition.InputSchema.TryGetProperty("properties", out var props)
&& props.ValueKind == JsonValueKind.Object)
{
foreach (var property in props.EnumerateObject())
{
properties[property.Name] = property.Value;
}
}
List<string> required = [];
if (definition.InputSchema.TryGetProperty("required", out var req)
&& req.ValueKind == JsonValueKind.Array)
{
required = [.. req.EnumerateArray().Select(r => r.GetString()!).Where(r => r is not null)];
}
return new Tool
{
Name = definition.Name,
Description = definition.Description,
InputSchema = new()
{
Properties = properties,
Required = required
}
};
}
private static MessageParam ToSdkMessage(AgentChatMessage message) => new()
{
Role = message.Role == "assistant" ? Role.Assistant : Role.User,
Content = new List<ContentBlockParam>([.. message.Content.Select(ToSdkBlock)])
};
private static ContentBlockParam ToSdkBlock(AgentContentBlock block) => block switch
{
AgentTextBlock text => new TextBlockParam { Text = text.Text },
AgentToolUseBlock toolUse => new ToolUseBlockParam
{
ID = toolUse.Id,
Name = toolUse.Name,
Input = ToInputDictionary(toolUse.Input)
},
AgentToolResultBlock result => new ToolResultBlockParam
{
ToolUseID = result.ToolUseId,
Content = result.Content,
IsError = result.IsError
},
_ => throw new NotSupportedException($"Unsupported content block: {block.GetType().Name}")
};
private static AgentContentBlock? FromSdkBlock(ContentBlock block)
{
if (block.TryPickText(out TextBlock? text))
{
return new AgentTextBlock(text!.Text);
}
if (block.TryPickToolUse(out ToolUseBlock? toolUse))
{
return new AgentToolUseBlock(
toolUse!.ID,
toolUse.Name,
JsonSerializer.SerializeToElement(toolUse.Input));
}
// Thinking blocks and any future block types carry nothing the loop acts on.
return null;
}
private static Dictionary<string, JsonElement> ToInputDictionary(JsonElement input)
{
var dictionary = new Dictionary<string, JsonElement>();
if (input.ValueKind == JsonValueKind.Object)
{
foreach (var property in input.EnumerateObject())
{
dictionary[property.Name] = property.Value;
}
}
return dictionary;
}
}
+138
View File
@@ -0,0 +1,138 @@
using System.Text.Json.Nodes;
using System.Text.Json;
namespace Novelly.Api.Agent;
/// <summary>
/// Small builder for the JSON Schema objects tool definitions need. Hand-writing these
/// as string literals is where tool definitions usually rot, so build them structurally.
/// </summary>
public class JsonSchemaBuilder
{
private readonly JsonObject _properties = [];
private readonly JsonArray _required = [];
public JsonSchemaBuilder Str(string name, string description, bool required = false) =>
Add(name, "string", description, required);
public JsonSchemaBuilder Int(string name, string description, bool required = false) =>
Add(name, "integer", description, required);
public JsonSchemaBuilder Bool(string name, string description, bool required = false) =>
Add(name, "boolean", description, required);
public JsonSchemaBuilder StringArray(string name, string description, bool required = false)
{
_properties[name] = new JsonObject
{
["type"] = "array",
["description"] = description,
["items"] = new JsonObject { ["type"] = "string" }
};
if (required)
{
_required.Add(name);
}
return this;
}
public JsonSchemaBuilder Enum(string name, string description, IEnumerable<string> values, bool required = false)
{
var node = new JsonObject
{
["type"] = "string",
["description"] = description,
["enum"] = new JsonArray([.. values.Select(v => JsonValue.Create(v))])
};
_properties[name] = node;
if (required)
{
_required.Add(name);
}
return this;
}
private JsonSchemaBuilder Add(string name, string type, string description, bool required)
{
_properties[name] = new JsonObject { ["type"] = type, ["description"] = description };
if (required)
{
_required.Add(name);
}
return this;
}
public JsonElement Build()
{
var schema = new JsonObject
{
["type"] = "object",
["properties"] = _properties,
["required"] = _required
};
return JsonSerializer.Deserialize<JsonElement>(schema.ToJsonString());
}
}
/// <summary>Lenient readers for tool input, which arrives as untyped JSON.</summary>
public static class JsonInput
{
public static string? String(JsonElement input, string name) =>
input.ValueKind == JsonValueKind.Object
&& input.TryGetProperty(name, out var value)
&& value.ValueKind is JsonValueKind.String
? value.GetString()
: null;
public static string RequiredString(JsonElement input, string name) =>
String(input, name) ?? throw new ArgumentException($"Missing required argument '{name}'.");
public static Guid? Guid(JsonElement input, string name) =>
System.Guid.TryParse(String(input, name), out var id) ? id : null;
public static Guid RequiredGuid(JsonElement input, string name) =>
Guid(input, name) ?? throw new ArgumentException($"Missing or malformed id argument '{name}'.");
public static int? Int(JsonElement input, string name)
{
if (input.ValueKind != JsonValueKind.Object || !input.TryGetProperty(name, out var value))
{
return null;
}
return value.ValueKind switch
{
JsonValueKind.Number when value.TryGetInt32(out var n) => n,
JsonValueKind.String when int.TryParse(value.GetString(), out var n) => n,
_ => null
};
}
/// <summary>
/// Reads an array of strings. Returns null when the property is absent, which the
/// services read as "leave the existing list alone" — distinct from an empty array,
/// which clears it.
/// </summary>
public static IReadOnlyList<string>? Strings(JsonElement input, string name)
{
if (input.ValueKind != JsonValueKind.Object
|| !input.TryGetProperty(name, out var value)
|| value.ValueKind != JsonValueKind.Array)
{
return null;
}
return [.. value.EnumerateArray()
.Where(item => item.ValueKind == JsonValueKind.String)
.Select(item => item.GetString()!)];
}
public static TEnum? Enum<TEnum>(JsonElement input, string name) where TEnum : struct, System.Enum =>
System.Enum.TryParse<TEnum>(String(input, name), ignoreCase: true, out var parsed) ? parsed : null;
}
+265
View File
@@ -0,0 +1,265 @@
using System.Text.Json;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.Projects;
namespace Novelly.Api.Agent;
/// <summary>
/// The embedded writing agent. Runs the tool-use loop against the model, persists the
/// conversation, and returns the finished turn together with a record of what it changed.
/// </summary>
public class NovelAgentService(
INovelDbContext db,
IAgentModelClient model,
NovelAgentToolset toolset,
IOptions<AgentOptions> options,
ILogger<NovelAgentService> logger)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
private readonly AgentOptions _options = options.Value;
public async Task<IReadOnlyList<ConversationSummaryDto>> ListConversationsAsync(
Guid projectId, CancellationToken ct = default) =>
await db.Conversations
.Where(c => c.ProjectId == projectId)
.OrderByDescending(c => c.UpdatedAt)
.Select(c => new ConversationSummaryDto(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt))
.ToListAsync(ct);
public async Task<ConversationDto> GetConversationAsync(Guid conversationId, CancellationToken ct = default)
{
var conversation = await LoadConversationAsync(conversationId, ct);
return new ConversationDto(
conversation.Id,
conversation.ProjectId,
conversation.Title,
[.. conversation.Messages.OrderBy(m => m.Sequence).Select(ToDto)],
conversation.UpdatedAt);
}
public async Task DeleteConversationAsync(Guid conversationId, CancellationToken ct = default)
{
var conversation = await LoadConversationAsync(conversationId, ct);
db.Conversations.Remove(conversation);
await db.SaveChangesAsync(ct);
}
/// <summary>
/// Sends a message to the agent and runs it to completion, executing any tools it
/// calls along the way. Returns the assistant's final turn.
/// </summary>
public async Task<AgentTurnDto> SendMessageAsync(
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
{
var conversation = request.ConversationId is { } id
? await LoadConversationAsync(id, ct)
: await StartConversationAsync(projectId, request.Message, ct);
// Persist the user's turn before running the loop. The tools save through the
// same DbContext, so leaving this pending would entangle it with their writes —
// and recording the question even if the model call fails is the behaviour we want.
await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct);
var systemPrompt = await BuildSystemPromptAsync(projectId, ct);
var transcript = BuildTranscript(conversation);
var toolCalls = new List<ToolCallDto>();
var text = new StringBuilder();
for (var iteration = 0; iteration < _options.MaxIterations; iteration++)
{
var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct);
foreach (var block in response.Content.OfType<AgentTextBlock>())
{
if (!string.IsNullOrWhiteSpace(block.Text))
{
text.AppendLine(block.Text.Trim());
}
}
var requestedTools = response.Content.OfType<AgentToolUseBlock>().ToList();
if (requestedTools.Count == 0)
{
break;
}
// Echo the assistant's turn back verbatim, then answer every tool_use block in a
// single user turn — splitting the results would train the model out of
// requesting tools in parallel.
transcript.Add(AgentChatMessage.Assistant(response.Content));
var results = new List<AgentContentBlock>();
foreach (var call in requestedTools)
{
var outcome = await toolset.ExecuteAsync(call.Name, projectId, call.Input, ct);
logger.LogInformation(
"Agent tool {Tool} on project {ProjectId} {Outcome}",
call.Name, projectId, outcome.IsError ? "failed" : "succeeded");
toolCalls.Add(new ToolCallDto(call.Name, call.Input.ToString(), outcome.Content));
results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError));
}
transcript.Add(AgentChatMessage.User([.. results]));
if (iteration == _options.MaxIterations - 1)
{
logger.LogWarning(
"Agent hit the {Max}-iteration ceiling on project {ProjectId}",
_options.MaxIterations, projectId);
text.AppendLine(
"_I reached my tool-call limit for this turn. Ask me to continue if there's more to do._");
}
}
var reply = await AppendMessageAsync(
conversation,
AgentRole.Assistant,
text.ToString().TrimEnd(),
toolCalls.Count > 0 ? JsonSerializer.Serialize(toolCalls, JsonOptions) : null,
ct);
return new AgentTurnDto(conversation.Id, ToDto(reply));
}
/// <summary>
/// Appends a turn and commits it. Messages are added to the set directly rather than
/// through the parent's collection so their insert never depends on EF discovering
/// the graph change at an inconvenient moment.
/// </summary>
private async Task<AgentMessage> AppendMessageAsync(
AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct)
{
var message = new AgentMessage
{
ConversationId = conversation.Id,
Role = role,
Sequence = conversation.Messages.Count == 0 ? 0 : conversation.Messages.Max(m => m.Sequence) + 1,
Content = content,
ToolCallsJson = toolCallsJson
};
db.AgentMessages.Add(message);
conversation.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
// EF's relationship fixup normally puts the message into the parent's collection
// once both are tracked. Guard rather than assume, since the sequence number of
// the next turn is derived from it.
if (!conversation.Messages.Contains(message))
{
conversation.Messages.Add(message);
}
return message;
}
private async Task<AgentConversation> StartConversationAsync(
Guid projectId, string firstMessage, CancellationToken ct)
{
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
throw new NotFoundException(nameof(Project), projectId);
}
var conversation = new AgentConversation
{
ProjectId = projectId,
Title = Summarise(firstMessage)
};
db.Conversations.Add(conversation);
return conversation;
}
private async Task<AgentConversation> LoadConversationAsync(Guid conversationId, CancellationToken ct) =>
await db.Conversations
.Include(c => c.Messages)
.FirstOrDefaultAsync(c => c.Id == conversationId, ct)
?? throw new NotFoundException(nameof(AgentConversation), conversationId);
/// <summary>
/// Replays the stored conversation as plain text turns. Tool calls are not replayed —
/// the agent re-reads current state through its tools, which is more reliable than
/// trusting a transcript of edits that may since have been changed in the UI.
/// </summary>
private static List<AgentChatMessage> BuildTranscript(AgentConversation conversation) =>
[
.. conversation.Messages
.Where(m => !string.IsNullOrWhiteSpace(m.Content))
.OrderBy(m => m.Sequence)
.Select(m => new AgentChatMessage(
m.Role == AgentRole.User ? "user" : "assistant",
[new AgentTextBlock(m.Content)]))
];
private async Task<string> BuildSystemPromptAsync(Guid projectId, CancellationToken ct)
{
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct)
?? throw new NotFoundException(nameof(Project), projectId);
var brief = new StringBuilder();
brief.AppendLine($"Title: {project.Title}");
if (!string.IsNullOrWhiteSpace(project.Genre)) brief.AppendLine($"Genre: {project.Genre}");
if (!string.IsNullOrWhiteSpace(project.Logline)) brief.AppendLine($"Logline: {project.Logline}");
if (project.TargetWordCount is { } target) brief.AppendLine($"Target length: {target:N0} words");
return $"""
You are a developmental editor and writing partner embedded in the software the
writer is using to plan their novel. You have tools that read and write the
project's real data: the brief, character dossiers, the outline tree, chapters
and scenes.
The project you are working on:
{brief}
Working principles:
- Read before you write. Call get_project_brief, get_outline, or list_characters
to ground yourself rather than assuming what is already there.
- The book is the writer's. Ask about the choices that define the story — what a
character wants, what the ending costs them — instead of deciding for them.
- Do not invent biographical detail to fill an empty field. An unanswered
question in a dossier is more useful than a plausible-sounding fabrication.
- When you do have enough to act, act. Make the edit and say what you changed in
a sentence; do not narrate every tool call or ask permission for routine work.
- Prefer structural help — where a beat lands, whether a want and a need are
genuinely in tension, what the outline is missing — over line-level polish,
unless the writer asks for prose.
- When drafting prose into a scene, match the voice already established in the
project. Write the scene, then stop; do not append notes about your choices.
- Destructive operations (deleting outline nodes) need the writer's explicit
go-ahead first.
Keep replies short. Lead with the outcome, then the reasoning if it earns its place.
""";
}
private static AgentMessageDto ToDto(AgentMessage message) => new(
message.Id,
message.Role,
message.Content,
message.ToolCallsJson is null
? []
: JsonSerializer.Deserialize<List<ToolCallDto>>(message.ToolCallsJson, JsonOptions) ?? [],
message.CreatedAt);
/// <summary>Derives a conversation title from its opening message.</summary>
private static string Summarise(string message)
{
var trimmed = message.Trim().ReplaceLineEndings(" ");
return trimmed.Length <= 60 ? trimmed : string.Concat(trimmed.AsSpan(0, 57), "...");
}
}
+416
View File
@@ -0,0 +1,416 @@
using System.Text.Json;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Projects;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
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 tool the agent can call, bound to a handler that runs against the project's data.</summary>
public record AgentTool(
string Name,
string Description,
JsonElement InputSchema,
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler);
/// <summary>
/// The tools the writing agent can reach for. Everything here goes through the same
/// application services the REST API uses, so an edit made by the agent is
/// indistinguishable from one made in the UI.
/// </summary>
public class NovelAgentToolset(
ProjectService projects,
CharacterService characters,
ChapterService chapters,
BeatService beats,
SceneService scenes,
TagService tags)
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = false,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
private Dictionary<string, AgentTool>? _byName;
public IReadOnlyList<AgentTool> Tools => [.. ByName.Values];
public IReadOnlyList<AgentToolDefinition> Definitions =>
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
/// <summary>
/// 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)
{
if (!ByName.TryGetValue(name, out var tool))
{
return new AgentToolResult($"No such tool: '{name}'.", true);
}
try
{
var result = await tool.Handler(projectId, input, ct);
return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false);
}
catch (NotFoundException ex)
{
return new AgentToolResult(ex.Message, true);
}
catch (ArgumentException ex)
{
return new AgentToolResult(ex.Message, true);
}
catch (InvalidOperationException ex)
{
return new AgentToolResult(ex.Message, true);
}
}
private Dictionary<string, AgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name);
private IEnumerable<AgentTool> Build()
{
yield return new AgentTool(
"get_project_brief",
"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));
yield return new AgentTool(
"update_project_brief",
"Revise the project's top-level fields. Only the fields you supply change; "
+ "pass an empty string to clear a field.",
new JsonSchemaBuilder()
.Str("title", "New title.")
.Str("author", "Author name.")
.Str("genre", "Genre or category.")
.Str("logline", "One-sentence pitch.")
.Str("synopsis", "Paragraph-length summary of the whole book.")
.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(
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));
yield return new AgentTool(
"list_characters",
"List every character in the project with their full dossiers.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await characters.ListAsync(projectId, ct));
yield return new AgentTool(
"create_character",
"Add a character dossier. Name is the only requirement — leave fields blank when "
+ "the writer has not decided them yet rather than inventing detail.",
CharacterSchema(includeName: true, nameRequired: true).Build(),
async (projectId, input, ct) => await characters.CreateAsync(projectId, new CreateCharacterRequest(
JsonInput.RequiredString(input, "name"),
JsonInput.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting,
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));
yield return new AgentTool(
"update_character",
"Revise an existing character dossier. Only the fields you supply change.",
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.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));
yield return new AgentTool(
"get_chapter_outline",
"Read a chapter's outline: its summary paragraph and its beat table, in order. "
+ "A beat is one row — a short title, whose beat it is, what happened, and what it sets up.",
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter whose outline to read.", required: true)
.Build(),
async (_, input, ct) => await beats.ListAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct));
yield return new AgentTool(
"create_beat",
"Add a beat to a chapter's outline. Keep the title to three to five words — it is a "
+ "handle, not a sentence; the detail belongs in what_happened and whats_next.",
BeatSchema()
.Str("chapter_id", "Id of the chapter the beat belongs to.", required: true)
.Str("title", "Three to five words naming the beat.", required: true)
.Build(),
async (_, input, ct) => await beats.CreateAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new CreateBeatRequest(
JsonInput.RequiredString(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));
yield return new AgentTool(
"update_beat",
"Revise a beat. Only the fields you supply change. Supplying a tag list replaces "
+ "the beat's tags outright, so include the ones you want to keep.",
BeatSchema()
.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));
yield return new AgentTool(
"delete_beat",
"Remove a beat from a chapter's outline. Confirm with the writer before calling it.",
new JsonSchemaBuilder()
.Str("beat_id", "Id of the beat to delete.", required: true)
.Build(),
async (_, input, ct) =>
{
await beats.DeleteAsync(JsonInput.RequiredGuid(input, "beat_id"), ct);
return new { deleted = true };
});
yield return new AgentTool(
"reorder_beats",
"Renumber a chapter's beats to match the order given. List every beat id in the "
+ "order you want; any you leave out keep their relative position at the end.",
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter whose beats to reorder.", required: true)
.StringArray("beat_ids", "Beat ids in their new order.", required: true)
.Build(),
async (_, input, ct) => await beats.ReorderAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new ReorderBeatsRequest(
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
.Where(g => g != Guid.Empty)]), ct));
yield return new AgentTool(
"list_tags",
"List the project's tags with how many characters, chapters and beats carry each. "
+ "Read this before inventing a new tag so you reuse the writer's vocabulary.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await tags.ListAsync(projectId, ct));
yield return new AgentTool(
"get_tag_references",
"Cross-reference a tag: every character, chapter and beat carrying it. Use this to "
+ "trace a motif or a thread through the book.",
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));
yield return new AgentTool(
"list_chapters",
"List the project's chapters in manuscript order with scene and word counts.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await chapters.ListAsync(projectId, ct));
yield return new AgentTool(
"get_chapter",
"Read one chapter in full, including all of its scenes and any drafted prose.",
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));
yield return new AgentTool(
"create_chapter",
"Add a chapter. Its number is appended to the end of the manuscript unless you supply one.",
new JsonSchemaBuilder()
.Str("title", "Chapter title.", required: true)
.Int("number", "Position in the manuscript, 1-based.")
.Str("summary", "What the chapter covers.")
.Str("pov_character_id", "Id of the point-of-view character.")
.Str("setting", "Where and when the chapter takes place.")
.Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
.Int("target_word_count", "Target length in words.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(),
async (projectId, input, ct) => await chapters.CreateAsync(projectId, new CreateChapterRequest(
JsonInput.RequiredString(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") ?? DraftStatus.Planned,
JsonInput.Int(input, "target_word_count"),
JsonInput.Strings(input, "tags")), ct));
yield return new AgentTool(
"update_chapter",
"Revise a chapter's title, number, summary, POV, setting, notes or status.",
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter to update.", required: true)
.Str("title", "New title.")
.Int("number", "Position in the manuscript.")
.Str("summary", "What the chapter covers.")
.Str("pov_character_id", "Id of the point-of-view character.")
.Str("setting", "Where and when the chapter takes place.")
.Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
.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));
yield return new AgentTool(
"create_scene",
"Add a scene to a chapter. The goal/conflict/outcome trio is what makes a scene "
+ "draftable later, so fill those in when the writer has given you enough to work with.",
SceneSchema()
.Str("chapter_id", "Id of the chapter the scene belongs to.", required: true)
.Str("title", "Scene title.", required: true)
.Build(),
async (_, input, ct) => await scenes.CreateAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new CreateSceneRequest(
JsonInput.RequiredString(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") ?? DraftStatus.Planned), ct));
yield return new AgentTool(
"update_scene",
"Revise a scene. Use the 'prose' argument to write or replace the scene's draft text; "
+ "the word count is recomputed automatically.",
SceneSchema()
.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));
}
private static JsonSchemaBuilder CharacterSchema(bool includeName, bool nameRequired)
{
var schema = new JsonSchemaBuilder();
if (includeName)
{
schema.Str("name", "The character's name.", nameRequired);
}
return schema
.Enum("role", "The part they play in the story.", System.Enum.GetNames<CharacterRole>())
.Str("age", "Age, exact or approximate.")
.Str("pronouns", "The pronouns this character uses.")
.Str("occupation", "What they do.")
.Str("appearance", "How they look.")
.Str("personality", "Temperament, habits, how they treat people.")
.Str("backstory", "History that shapes who they are now.")
.Str("want", "What they consciously pursue.")
.Str("need", "What they actually need, usually at odds with what they want.")
.Str("internal_conflict", "The war inside them.")
.Str("external_conflict", "What in the world opposes them.")
.Str("arc_summary", "How they change over the course of the book.")
.Str("voice", "Speech patterns and register that make their dialogue theirs.")
.Str("notes", "Anything else worth recording.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
}
private static JsonSchemaBuilder BeatSchema() =>
new JsonSchemaBuilder()
.Int("sort_order", "Position in the chapter. Appended to the end when omitted.")
.Str("character_id", "Id of the character whose beat this is.")
.Str("what_happened", "The event itself.")
.Str("whats_next", "What it sets in motion — the hook into the next beat.")
.Str("scene_id", "Id of the scene this beat will be written into, if decided.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
private static JsonSchemaBuilder SceneSchema() =>
new JsonSchemaBuilder()
.Int("sort_order", "Position within the chapter. Appended to the end when omitted.")
.Str("summary", "What happens in the scene.")
.Str("goal", "What the POV character is trying to achieve.")
.Str("conflict", "What stands in the way.")
.Str("outcome", "How it lands, and what it costs.")
.Str("pov_character_id", "Id of the point-of-view character.")
.Str("location", "Where the scene takes place.")
.Str("prose", "The drafted prose for this scene.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>());
}
+45
View File
@@ -0,0 +1,45 @@
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
namespace Novelly.Api.Beats;
/// <summary>
/// One row of a chapter's outline: a short label, who it belongs to, what happened, and
/// what it sets up. Beats are the planning layer — flat and ordered within a chapter,
/// with no nesting. A beat may optionally be grouped under the <see cref="Scene"/> that
/// will eventually carry its prose.
/// </summary>
public class Beat
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ChapterId { get; set; }
public Chapter? Chapter { get; set; }
/// <summary>Optional grouping: the scene this beat will be written into.</summary>
public Guid? SceneId { get; set; }
public Scene? Scene { get; set; }
/// <summary>Position within the chapter. Gaps are allowed.</summary>
public int SortOrder { get; set; }
/// <summary>A three-to-five word handle for the beat, not a sentence.</summary>
public string Title { get; set; } = string.Empty;
/// <summary>Whose beat this is. Optional — not every beat belongs to one person.</summary>
public Guid? CharacterId { get; set; }
public Character? Character { get; set; }
/// <summary>The event itself.</summary>
public string? WhatHappened { get; set; }
/// <summary>What it sets in motion — the hook into the next beat.</summary>
public string? WhatsNext { get; set; }
public List<Tag> Tags { get; set; } = [];
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
+59
View File
@@ -0,0 +1,59 @@
using Novelly.Api.Tags;
namespace Novelly.Api.Beats;
public record BeatDto(
Guid Id,
Guid ChapterId,
int SortOrder,
string Title,
Guid? CharacterId,
string? CharacterName,
string? WhatHappened,
string? WhatsNext,
Guid? SceneId,
string? SceneTitle,
IReadOnlyList<TagDto> Tags,
DateTimeOffset UpdatedAt);
public record CreateBeatRequest(
string Title,
int? SortOrder = null,
Guid? CharacterId = null,
string? WhatHappened = null,
string? WhatsNext = null,
Guid? SceneId = null,
IReadOnlyList<string>? Tags = null);
/// <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.
/// </summary>
public record UpdateBeatRequest(
string? Title = null,
int? SortOrder = null,
Guid? CharacterId = null,
string? WhatHappened = null,
string? WhatsNext = null,
Guid? SceneId = null,
IReadOnlyList<string>? Tags = null);
/// <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 static class BeatMapping
{
public static BeatDto ToDto(this Beat b) => new(
b.Id,
b.ChapterId,
b.SortOrder,
b.Title,
b.CharacterId,
b.Character?.Name,
b.WhatHappened,
b.WhatsNext,
b.SceneId,
b.Scene?.Title,
[.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())],
b.UpdatedAt);
}
+46
View File
@@ -0,0 +1,46 @@
namespace Novelly.Api.Beats;
public static class BeatEndpoints
{
public static IEndpointRouteBuilder MapBeatEndpoints(this IEndpointRouteBuilder app)
{
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/beats").WithTags("Beats");
chapterScoped.MapGet("/", async (Guid chapterId, BeatService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(chapterId, ct)))
.WithSummary("Read a chapter's outline: its beats, in order.");
chapterScoped.MapPost("/", async (
Guid chapterId, CreateBeatRequest request, BeatService service, CancellationToken ct) =>
{
var created = await service.CreateAsync(chapterId, request, ct);
return Results.Created($"/api/beats/{created.Id}", created);
})
.WithSummary("Add a beat to a chapter's outline.");
chapterScoped.MapPost("/reorder", async (
Guid chapterId, ReorderBeatsRequest request, BeatService service, CancellationToken ct) =>
Results.Ok(await service.ReorderAsync(chapterId, request, ct)))
.WithSummary("Renumber a chapter's beats to match the order given.");
var beats = app.MapGroup("/api/beats").WithTags("Beats");
beats.MapGet("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct)))
.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)))
.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();
})
.WithSummary("Delete a beat.");
return app;
}
}
+165
View File
@@ -0,0 +1,165 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Chapters;
using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.Tags;
namespace Novelly.Api.Beats;
/// <summary>
/// Beats are a chapter's outline: a flat, ordered table rather than a tree. Everything
/// here is scoped to one chapter.
/// </summary>
public class BeatService(INovelDbContext db, TagService tags)
{
public async Task<IReadOnlyList<BeatDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
{
var beats = await Query()
.Where(b => b.ChapterId == chapterId)
.OrderBy(b => b.SortOrder)
.ToListAsync(ct);
return [.. beats.Select(b => b.ToDto())];
}
public async Task<BeatDto> GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
public async Task<BeatDto> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
{
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct)
?? throw new NotFoundException(nameof(Chapter), chapterId);
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
var beat = new Beat
{
ChapterId = chapterId,
Title = request.Title,
SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct),
CharacterId = request.CharacterId,
WhatHappened = request.WhatHappened,
WhatsNext = request.WhatsNext,
SceneId = request.SceneId
};
if (request.Tags is { } names)
{
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
}
db.Beats.Add(beat);
await db.SaveChangesAsync(ct);
return (await FindAsync(beat.Id, ct)).ToDto();
}
public async Task<BeatDto> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default)
{
var beat = await FindAsync(id, ct);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct)
?? throw new NotFoundException(nameof(Chapter), beat.ChapterId);
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
beat.SortOrder = request.SortOrder ?? beat.SortOrder;
beat.CharacterId = request.CharacterId ?? beat.CharacterId;
beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened);
beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext);
beat.SceneId = request.SceneId ?? beat.SceneId;
beat.UpdatedAt = DateTimeOffset.UtcNow;
if (request.Tags is { } names)
{
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
}
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct)).ToDto();
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var beat = await FindAsync(id, ct);
db.Beats.Remove(beat);
await db.SaveChangesAsync(ct);
}
/// <summary>
/// Renumbers a chapter's beats to match the order given. Sending the whole list beats
/// patching sort orders one at a time, which is fiddly to get right from a drag handle.
/// </summary>
public async Task<IReadOnlyList<BeatDto>> ReorderAsync(
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
{
var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct);
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
if (missing.Count > 0)
{
throw new NotFoundException(nameof(Beat), missing[0]);
}
// Listed beats take the order given; anything omitted keeps its relative position
// after them rather than silently jumping to the front.
var order = 1;
foreach (var id in request.BeatIds)
{
beats.Single(b => b.Id == id).SortOrder = order++;
}
foreach (var beat in beats.Where(b => !request.BeatIds.Contains(b.Id)).OrderBy(b => b.SortOrder))
{
beat.SortOrder = order++;
}
await db.SaveChangesAsync(ct);
return await ListAsync(chapterId, ct);
}
private async Task ValidateReferencesAsync(
Chapter chapter, Guid? characterId, Guid? sceneId, CancellationToken ct)
{
if (characterId is { } cid)
{
var belongs = await db.Characters
.AnyAsync(c => c.Id == cid && c.ProjectId == chapter.ProjectId, ct);
if (!belongs)
{
throw new InvalidOperationException(
"A beat's character must belong to the same project as its chapter.");
}
}
if (sceneId is { } sid)
{
var belongs = await db.Scenes.AnyAsync(s => s.Id == sid && s.ChapterId == chapter.Id, ct);
if (!belongs)
{
throw new InvalidOperationException(
"A beat can only be grouped under a scene in the same chapter.");
}
}
}
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
{
var max = await db.Beats
.Where(b => b.ChapterId == chapterId)
.MaxAsync(b => (int?)b.SortOrder, ct);
return (max ?? 0) + 1;
}
private IQueryable<Beat> Query() =>
db.Beats
.Include(b => b.Character)
.Include(b => b.Scene)
.Include(b => b.Tags);
private async Task<Beat> FindAsync(Guid id, CancellationToken ct) =>
await Query().FirstOrDefaultAsync(b => b.Id == id, ct)
?? throw new NotFoundException(nameof(Beat), id);
}
+47
View File
@@ -0,0 +1,47 @@
using Novelly.Api.Beats;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Projects;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
namespace Novelly.Api.Chapters;
/// <summary>A chapter: an ordered container of scenes plus its own planning fields.</summary>
public class Chapter
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; }
public Project? Project { get; set; }
/// <summary>Position in the manuscript, 1-based.</summary>
public int Number { get; set; }
public string Title { get; set; } = string.Empty;
/// <summary>
/// The paragraph that opens the chapter's outline, above the beat table.
/// </summary>
public string? Summary { get; set; }
/// <summary>Whose head we are in for this chapter.</summary>
public Guid? PovCharacterId { get; set; }
public Character? PovCharacter { get; set; }
public string? Setting { get; set; }
public string? Notes { get; set; }
public DraftStatus Status { get; set; } = DraftStatus.Planned;
public int? TargetWordCount { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
/// <summary>The chapter's outline: an ordered, flat list of beats.</summary>
public List<Beat> Beats { get; set; } = [];
/// <summary>The prose layer. Beats may optionally be grouped under these.</summary>
public List<Scene> Scenes { get; set; } = [];
public List<Tag> Tags { get; set; } = [];
}
+87
View File
@@ -0,0 +1,87 @@
using Novelly.Api.Beats;
using Novelly.Api.Common;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
namespace Novelly.Api.Chapters;
public record ChapterSummaryDto(
Guid Id,
Guid ProjectId,
int Number,
string Title,
string? Summary,
Guid? PovCharacterId,
string? PovCharacterName,
string? Setting,
DraftStatus Status,
int? TargetWordCount,
int BeatCount,
int SceneCount,
int WordCount,
IReadOnlyList<TagDto> Tags);
/// <summary>
/// A chapter in full: the outline (a paragraph of summary plus an ordered beat table)
/// and the prose layer (scenes).
/// </summary>
public record ChapterDto(
Guid Id,
Guid ProjectId,
int Number,
string Title,
string? Summary,
Guid? PovCharacterId,
string? PovCharacterName,
string? Setting,
string? Notes,
DraftStatus Status,
int? TargetWordCount,
IReadOnlyList<BeatDto> Beats,
IReadOnlyList<SceneDto> Scenes,
IReadOnlyList<TagDto> Tags,
DateTimeOffset UpdatedAt);
public record CreateChapterRequest(
string Title,
int? Number = null,
string? Summary = null,
Guid? PovCharacterId = null,
string? Setting = null,
string? Notes = null,
DraftStatus Status = DraftStatus.Planned,
int? TargetWordCount = null,
IReadOnlyList<string>? Tags = null);
/// <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.
/// </summary>
public record UpdateChapterRequest(
string? Title = null,
int? Number = null,
string? Summary = null,
Guid? PovCharacterId = null,
string? Setting = null,
string? Notes = null,
DraftStatus? Status = null,
int? TargetWordCount = null,
IReadOnlyList<string>? Tags = null);
public static class ChapterMapping
{
public static ChapterDto ToDto(this Chapter c) => new(
c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Notes,
c.Status, c.TargetWordCount,
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToDto())],
[.. c.Scenes.OrderBy(s => s.SortOrder).Select(s => s.ToDto())],
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())],
c.UpdatedAt);
public static ChapterSummaryDto ToSummaryDto(this Chapter c) => new(
c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount,
c.Beats.Count, c.Scenes.Count, c.Scenes.Sum(s => s.WordCount),
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())]);
}
@@ -0,0 +1,41 @@
namespace Novelly.Api.Chapters;
public static class ChapterEndpoints
{
public static IEndpointRouteBuilder MapChapterEndpoints(this IEndpointRouteBuilder app)
{
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/chapters").WithTags("Chapters");
projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(projectId, ct)))
.WithSummary("List a project's chapters in manuscript order.");
projectScoped.MapPost("/", async (
Guid projectId, CreateChapterRequest request, ChapterService service, CancellationToken ct) =>
{
var created = await service.CreateAsync(projectId, request, ct);
return Results.Created($"/api/chapters/{created.Id}", created);
})
.WithSummary("Add a chapter.");
var chapters = app.MapGroup("/api/chapters").WithTags("Chapters");
chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct)))
.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)))
.WithSummary("Update a chapter.");
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
{
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
.WithSummary("Delete a chapter and its scenes.");
return app;
}
}
+107
View File
@@ -0,0 +1,107 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.Projects;
using Novelly.Api.Tags;
namespace Novelly.Api.Chapters;
public class ChapterService(INovelDbContext db, TagService tags)
{
public async Task<IReadOnlyList<ChapterSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default)
{
var chapters = await db.Chapters
.Include(c => c.PovCharacter)
.Include(c => c.Beats)
.Include(c => c.Scenes)
.Include(c => c.Tags)
.Where(c => c.ProjectId == projectId)
.OrderBy(c => c.Number)
.ToListAsync(ct);
return [.. chapters.Select(c => c.ToSummaryDto())];
}
public async Task<ChapterDto> GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
public async Task<ChapterDto> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default)
{
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
throw new NotFoundException(nameof(Project), projectId);
}
var chapter = new Chapter
{
ProjectId = projectId,
Title = request.Title,
Number = request.Number ?? await NextChapterNumberAsync(projectId, ct),
Summary = request.Summary,
PovCharacterId = request.PovCharacterId,
Setting = request.Setting,
Notes = request.Notes,
Status = request.Status,
TargetWordCount = request.TargetWordCount
};
if (request.Tags is { } names)
{
chapter.Tags = await tags.ResolveAsync(projectId, names, ct);
}
db.Chapters.Add(chapter);
await db.SaveChangesAsync(ct);
return (await FindAsync(chapter.Id, ct)).ToDto();
}
public async Task<ChapterDto> UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default)
{
var chapter = await FindAsync(id, ct);
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
chapter.Number = request.Number ?? chapter.Number;
chapter.Summary = Patch.Apply(chapter.Summary, request.Summary);
chapter.PovCharacterId = request.PovCharacterId ?? chapter.PovCharacterId;
chapter.Setting = Patch.Apply(chapter.Setting, request.Setting);
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
chapter.Status = request.Status ?? chapter.Status;
chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
chapter.UpdatedAt = DateTimeOffset.UtcNow;
if (request.Tags is { } names)
{
chapter.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
}
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct)).ToDto();
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var chapter = await FindAsync(id, ct);
db.Chapters.Remove(chapter);
await db.SaveChangesAsync(ct);
}
private async Task<int> NextChapterNumberAsync(Guid projectId, CancellationToken ct)
{
var max = await db.Chapters
.Where(c => c.ProjectId == projectId)
.MaxAsync(c => (int?)c.Number, ct);
return (max ?? 0) + 1;
}
private async Task<Chapter> FindAsync(Guid id, CancellationToken ct) =>
await db.Chapters
.Include(c => c.PovCharacter)
.Include(c => c.Beats).ThenInclude(b => b.Character)
.Include(c => c.Beats).ThenInclude(b => b.Scene)
.Include(c => c.Beats).ThenInclude(b => b.Tags)
.Include(c => c.Scenes).ThenInclude(s => s.PovCharacter)
.Include(c => c.Tags)
.FirstOrDefaultAsync(c => c.Id == id, ct)
?? throw new NotFoundException(nameof(Chapter), id);
}
+66
View File
@@ -0,0 +1,66 @@
using Novelly.Api.Projects;
using Novelly.Api.Tags;
namespace Novelly.Api.Characters;
/// <summary>
/// A character dossier. Every field beyond <see cref="Name"/> is optional so a writer can
/// start with a name and fill the sheet in as the character comes into focus.
/// </summary>
public class Character
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; }
public Project? Project { get; set; }
public string Name { get; set; } = string.Empty;
public CharacterRole Role { get; set; } = CharacterRole.Supporting;
public string? Age { get; set; }
public string? Pronouns { get; set; }
public string? Occupation { get; set; }
public string? Appearance { get; set; }
public string? Personality { get; set; }
public string? Backstory { get; set; }
/// <summary>What the character consciously wants.</summary>
public string? Want { get; set; }
/// <summary>What the character actually needs — usually at odds with <see cref="Want"/>.</summary>
public string? Need { get; set; }
public string? InternalConflict { get; set; }
public string? ExternalConflict { get; set; }
/// <summary>How the character changes over the course of the book.</summary>
public string? ArcSummary { get; set; }
/// <summary>Speech patterns, verbal tics, register — anything that makes dialogue sound like them.</summary>
public string? Voice { get; set; }
public string? Notes { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public List<CharacterRelationship> Relationships { get; set; } = [];
public List<Tag> Tags { get; set; } = [];
}
/// <summary>A directed relationship from one character to another.</summary>
public class CharacterRelationship
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid CharacterId { get; set; }
public Character? Character { get; set; }
public Guid RelatedCharacterId { get; set; }
public Character? RelatedCharacter { get; set; }
/// <summary>e.g. "sister", "rival", "former mentor".</summary>
public string RelationshipType { get; set; } = string.Empty;
public string? Description { get; set; }
}
@@ -0,0 +1,93 @@
using Novelly.Api.Tags;
namespace Novelly.Api.Characters;
public record CharacterDto(
Guid Id,
Guid ProjectId,
string Name,
CharacterRole Role,
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<RelationshipDto> Relationships,
IReadOnlyList<TagDto> Tags,
DateTimeOffset UpdatedAt);
public record RelationshipDto(
Guid Id,
Guid RelatedCharacterId,
string RelatedCharacterName,
string RelationshipType,
string? Description);
public record CreateCharacterRequest(
string Name,
CharacterRole Role = CharacterRole.Supporting,
string? Age = null,
string? Pronouns = null,
string? Occupation = null,
string? Appearance = null,
string? Personality = null,
string? Backstory = null,
string? Want = null,
string? Need = null,
string? InternalConflict = null,
string? ExternalConflict = null,
string? ArcSummary = null,
string? Voice = null,
string? Notes = null,
IReadOnlyList<string>? Tags = null);
/// <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.
/// </summary>
public record UpdateCharacterRequest(
string? Name = null,
CharacterRole? Role = null,
string? Age = null,
string? Pronouns = null,
string? Occupation = null,
string? Appearance = null,
string? Personality = null,
string? Backstory = null,
string? Want = null,
string? Need = null,
string? InternalConflict = null,
string? ExternalConflict = null,
string? ArcSummary = null,
string? Voice = null,
string? Notes = null,
IReadOnlyList<string>? Tags = null);
public record CreateRelationshipRequest(
Guid RelatedCharacterId,
string RelationshipType,
string? Description = null);
public static class CharacterMapping
{
public static CharacterDto ToDto(this Character c) => new(
c.Id, c.ProjectId, c.Name, c.Role, c.Age, c.Pronouns, c.Occupation,
c.Appearance, c.Personality, c.Backstory, c.Want, c.Need,
c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes,
[.. c.Relationships.Select(r => new RelationshipDto(
r.Id,
r.RelatedCharacterId,
r.RelatedCharacter?.Name ?? "(unknown)",
r.RelationshipType,
r.Description))],
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())],
c.UpdatedAt);
}
@@ -0,0 +1,54 @@
namespace Novelly.Api.Characters;
public static class CharacterEndpoints
{
public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app)
{
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/characters").WithTags("Characters");
projectScoped.MapGet("/", async (Guid projectId, CharacterService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(projectId, ct)))
.WithSummary("List a project's character dossiers.");
projectScoped.MapPost("/", async (
Guid projectId, CreateCharacterRequest request, CharacterService service, CancellationToken ct) =>
{
var created = await service.CreateAsync(projectId, request, ct);
return Results.Created($"/api/characters/{created.Id}", created);
})
.WithSummary("Add a character dossier.");
var characters = app.MapGroup("/api/characters").WithTags("Characters");
characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct)))
.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)))
.WithSummary("Update a character dossier.");
characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
{
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
.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)))
.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();
})
.WithSummary("Remove a relationship.");
return app;
}
}
@@ -0,0 +1,14 @@
namespace Novelly.Api.Characters;
/// <summary>The role a character plays in the story.</summary>
public enum CharacterRole
{
Protagonist,
Antagonist,
Deuteragonist,
Supporting,
Minor,
Mentor,
LoveInterest,
Foil
}
@@ -0,0 +1,149 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.Projects;
using Novelly.Api.Tags;
namespace Novelly.Api.Characters;
public class CharacterService(INovelDbContext db, TagService tags)
{
public async Task<IReadOnlyList<CharacterDto>> ListAsync(Guid projectId, CancellationToken ct = default)
{
var characters = await Query()
.Where(c => c.ProjectId == projectId)
.OrderBy(c => c.Role)
.ThenBy(c => c.Name)
.ToListAsync(ct);
return [.. characters.Select(c => c.ToDto())];
}
public async Task<CharacterDto> GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
public async Task<CharacterDto> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default)
{
await EnsureProjectExists(projectId, ct);
var character = new Character
{
ProjectId = projectId,
Name = request.Name,
Role = request.Role,
Age = request.Age,
Pronouns = request.Pronouns,
Occupation = request.Occupation,
Appearance = request.Appearance,
Personality = request.Personality,
Backstory = request.Backstory,
Want = request.Want,
Need = request.Need,
InternalConflict = request.InternalConflict,
ExternalConflict = request.ExternalConflict,
ArcSummary = request.ArcSummary,
Voice = request.Voice,
Notes = request.Notes
};
if (request.Tags is { } names)
{
character.Tags = await tags.ResolveAsync(projectId, names, ct);
}
db.Characters.Add(character);
await db.SaveChangesAsync(ct);
return (await FindAsync(character.Id, ct)).ToDto();
}
public async Task<CharacterDto> UpdateAsync(Guid id, UpdateCharacterRequest request, CancellationToken ct = default)
{
var character = await FindAsync(id, ct);
character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name;
character.Role = request.Role ?? character.Role;
character.Age = Patch.Apply(character.Age, request.Age);
character.Pronouns = Patch.Apply(character.Pronouns, request.Pronouns);
character.Occupation = Patch.Apply(character.Occupation, request.Occupation);
character.Appearance = Patch.Apply(character.Appearance, request.Appearance);
character.Personality = Patch.Apply(character.Personality, request.Personality);
character.Backstory = Patch.Apply(character.Backstory, request.Backstory);
character.Want = Patch.Apply(character.Want, request.Want);
character.Need = Patch.Apply(character.Need, request.Need);
character.InternalConflict = Patch.Apply(character.InternalConflict, request.InternalConflict);
character.ExternalConflict = Patch.Apply(character.ExternalConflict, request.ExternalConflict);
character.ArcSummary = Patch.Apply(character.ArcSummary, request.ArcSummary);
character.Voice = Patch.Apply(character.Voice, request.Voice);
character.Notes = Patch.Apply(character.Notes, request.Notes);
character.UpdatedAt = DateTimeOffset.UtcNow;
if (request.Tags is { } names)
{
character.Tags = await tags.ResolveAsync(character.ProjectId, names, ct);
}
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct)).ToDto();
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var character = await FindAsync(id, ct);
db.Characters.Remove(character);
await db.SaveChangesAsync(ct);
}
public async Task<CharacterDto> AddRelationshipAsync(
Guid characterId, CreateRelationshipRequest request, CancellationToken ct = default)
{
var character = await FindAsync(characterId, ct);
var related = await db.Characters
.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct)
?? throw new NotFoundException(nameof(Character), request.RelatedCharacterId);
if (related.ProjectId != character.ProjectId)
{
throw new InvalidOperationException("Characters must belong to the same project to be related.");
}
db.CharacterRelationships.Add(new CharacterRelationship
{
CharacterId = characterId,
RelatedCharacterId = request.RelatedCharacterId,
RelationshipType = request.RelationshipType,
Description = request.Description
});
await db.SaveChangesAsync(ct);
return (await FindAsync(characterId, ct)).ToDto();
}
public async Task RemoveRelationshipAsync(Guid relationshipId, CancellationToken ct = default)
{
var relationship = await db.CharacterRelationships
.FirstOrDefaultAsync(r => r.Id == relationshipId, ct)
?? throw new NotFoundException(nameof(CharacterRelationship), relationshipId);
db.CharacterRelationships.Remove(relationship);
await db.SaveChangesAsync(ct);
}
private IQueryable<Character> Query() =>
db.Characters
.Include(c => c.Relationships)
.ThenInclude(r => r.RelatedCharacter)
.Include(c => c.Tags);
private async Task<Character> FindAsync(Guid id, CancellationToken ct) =>
await Query().FirstOrDefaultAsync(c => c.Id == id, ct)
?? throw new NotFoundException(nameof(Character), id);
private async Task EnsureProjectExists(Guid projectId, CancellationToken ct)
{
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
throw new NotFoundException(nameof(Project), projectId);
}
}
}
@@ -0,0 +1,8 @@
namespace Novelly.Api.Common;
/// <summary>
/// Thrown when the agent is asked to run but has no model credentials. This is a
/// deployment problem rather than a bad request, so the API reports it as 503 — the rest
/// of the app works fine without a key.
/// </summary>
public class AgentNotConfiguredException(string message) : Exception(message);
+11
View File
@@ -0,0 +1,11 @@
namespace Novelly.Api.Common;
/// <summary>How far along a chapter or scene is in the drafting pipeline.</summary>
public enum DraftStatus
{
Planned,
Outlined,
Drafted,
Revised,
Final
}
@@ -0,0 +1,12 @@
namespace Novelly.Api.Common;
/// <summary>
/// Thrown when a service is asked for an entity that does not exist. The API translates
/// this into a 404 so services never have to know about HTTP.
/// </summary>
public class NotFoundException(string entity, Guid id)
: Exception($"{entity} '{id}' was not found.")
{
public string Entity { get; } = entity;
public Guid Id { get; } = id;
}
@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Agent;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Data;
using Novelly.Api.Projects;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
namespace Novelly.Api.Common;
/// <summary>
/// Wires up every feature's services in one place. Endpoints, the embedded agent and the
/// MCP server all resolve the same instances, so a capability added here is available to
/// all three.
/// </summary>
public static class NovellyServiceRegistration
{
public static IServiceCollection AddNovelly(this IServiceCollection services, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("Novel")
?? "Data Source=novel.db";
services.AddDbContext<NovelDbContext>(options => options.UseSqlite(connectionString));
services.AddScoped<INovelDbContext>(sp => sp.GetRequiredService<NovelDbContext>());
services.AddScoped<ProjectService>();
services.AddScoped<CharacterService>();
services.AddScoped<BeatService>();
services.AddScoped<TagService>();
services.AddScoped<ChapterService>();
services.AddScoped<SceneService>();
services.AddScoped<NovelAgentToolset>();
services.AddScoped<NovelAgentService>();
services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName));
services.AddScoped<IAgentModelClient, AnthropicAgentModelClient>();
return services;
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace Novelly.Api.Common;
/// <summary>
/// Patch semantics shared by every update endpoint: a null value leaves the field
/// untouched, an empty string clears it.
/// </summary>
internal static class Patch
{
public static string? Apply(string? current, string? incoming) => incoming switch
{
null => current,
"" => null,
_ => incoming
};
}
+29
View File
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Agent;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Projects;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
namespace Novelly.Api.Data;
/// <summary>
/// The persistence surface the application services depend on. Infrastructure supplies
/// the EF Core implementation; tests can point it at an in-memory SQLite connection.
/// </summary>
public interface INovelDbContext
{
DbSet<Project> Projects { get; }
DbSet<Character> Characters { get; }
DbSet<CharacterRelationship> CharacterRelationships { get; }
DbSet<Beat> Beats { get; }
DbSet<Tag> Tags { get; }
DbSet<Chapter> Chapters { get; }
DbSet<Scene> Scenes { get; }
DbSet<AgentConversation> Conversations { get; }
DbSet<AgentMessage> AgentMessages { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,532 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Novelly.Api.Data;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Novelly.Api.Migrations
{
[DbContext(typeof(NovelDbContext))]
[Migration("20260806023249_InitialSchema")]
partial class InitialSchema
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("Conversations");
});
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("ConversationId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<int>("Sequence")
.HasColumnType("INTEGER");
b.Property<string>("ToolCallsJson")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ConversationId", "Sequence")
.IsUnique();
b.ToTable("AgentMessages");
});
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<Guid?>("PovCharacterId")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Setting")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<int?>("TargetWordCount")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PovCharacterId");
b.HasIndex("ProjectId", "Number");
b.ToTable("Chapters");
});
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Age")
.HasColumnType("TEXT");
b.Property<string>("Appearance")
.HasColumnType("TEXT");
b.Property<string>("ArcSummary")
.HasColumnType("TEXT");
b.Property<string>("Backstory")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("ExternalConflict")
.HasColumnType("TEXT");
b.Property<string>("InternalConflict")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Need")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<string>("Occupation")
.HasColumnType("TEXT");
b.Property<string>("Personality")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Pronouns")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Voice")
.HasColumnType("TEXT");
b.Property<string>("Want")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("Characters");
});
modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<Guid>("RelatedCharacterId")
.HasColumnType("TEXT");
b.Property<string>("RelationshipType")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("RelatedCharacterId");
b.ToTable("CharacterRelationships");
});
modelBuilder.Entity("Novelly.Api.Chapters.OutlineNode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ChapterId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("NodeType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid?>("ParentId")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.HasIndex("ParentId");
b.HasIndex("ProjectId", "ParentId", "SortOrder");
b.ToTable("OutlineNodes");
});
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Author")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Genre")
.HasColumnType("TEXT");
b.Property<string>("Logline")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<string>("Synopsis")
.HasColumnType("TEXT");
b.Property<int?>("TargetWordCount")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Novelly.Api.Scenes.Scene", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("ChapterId")
.HasColumnType("TEXT");
b.Property<string>("Conflict")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Goal")
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("Outcome")
.HasColumnType("TEXT");
b.Property<Guid?>("PovCharacterId")
.HasColumnType("TEXT");
b.Property<string>("Prose")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<int>("WordCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PovCharacterId");
b.HasIndex("ChapterId", "SortOrder");
b.ToTable("Scenes");
});
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{
b.HasOne("Novelly.Api.Projects.Project", "Project")
.WithMany("Conversations")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
{
b.HasOne("Novelly.Api.Agent.AgentConversation", "Conversation")
.WithMany("Messages")
.HasForeignKey("ConversationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Conversation");
});
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
{
b.HasOne("Novelly.Api.Characters.Character", "PovCharacter")
.WithMany()
.HasForeignKey("PovCharacterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Novelly.Api.Projects.Project", "Project")
.WithMany("Chapters")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PovCharacter");
b.Navigation("Project");
});
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
{
b.HasOne("Novelly.Api.Projects.Project", "Project")
.WithMany("Characters")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b =>
{
b.HasOne("Novelly.Api.Characters.Character", "Character")
.WithMany("Relationships")
.HasForeignKey("CharacterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Characters.Character", "RelatedCharacter")
.WithMany()
.HasForeignKey("RelatedCharacterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Character");
b.Navigation("RelatedCharacter");
});
modelBuilder.Entity("Novelly.Api.Chapters.OutlineNode", b =>
{
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
.WithMany()
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Novelly.Api.Chapters.OutlineNode", "Parent")
.WithMany("Children")
.HasForeignKey("ParentId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Novelly.Api.Projects.Project", "Project")
.WithMany("OutlineNodes")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
b.Navigation("Parent");
b.Navigation("Project");
});
modelBuilder.Entity("Novelly.Api.Scenes.Scene", b =>
{
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
.WithMany("Scenes")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Characters.Character", "PovCharacter")
.WithMany()
.HasForeignKey("PovCharacterId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Chapter");
b.Navigation("PovCharacter");
});
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{
b.Navigation("Messages");
});
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
{
b.Navigation("Scenes");
});
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
{
b.Navigation("Relationships");
});
modelBuilder.Entity("Novelly.Api.Chapters.OutlineNode", b =>
{
b.Navigation("Children");
});
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
{
b.Navigation("Chapters");
b.Navigation("Characters");
b.Navigation("Conversations");
b.Navigation("OutlineNodes");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,339 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Migrations
{
/// <inheritdoc />
public partial class InitialSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Projects",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Title = table.Column<string>(type: "TEXT", maxLength: 300, nullable: false),
Author = table.Column<string>(type: "TEXT", nullable: true),
Genre = table.Column<string>(type: "TEXT", nullable: true),
Logline = table.Column<string>(type: "TEXT", nullable: true),
Synopsis = table.Column<string>(type: "TEXT", nullable: true),
Notes = table.Column<string>(type: "TEXT", nullable: true),
TargetWordCount = table.Column<int>(type: "INTEGER", nullable: true),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Projects", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Characters",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Role = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Age = table.Column<string>(type: "TEXT", nullable: true),
Pronouns = table.Column<string>(type: "TEXT", nullable: true),
Occupation = table.Column<string>(type: "TEXT", nullable: true),
Appearance = table.Column<string>(type: "TEXT", nullable: true),
Personality = table.Column<string>(type: "TEXT", nullable: true),
Backstory = table.Column<string>(type: "TEXT", nullable: true),
Want = table.Column<string>(type: "TEXT", nullable: true),
Need = table.Column<string>(type: "TEXT", nullable: true),
InternalConflict = table.Column<string>(type: "TEXT", nullable: true),
ExternalConflict = table.Column<string>(type: "TEXT", nullable: true),
ArcSummary = table.Column<string>(type: "TEXT", nullable: true),
Voice = table.Column<string>(type: "TEXT", nullable: true),
Notes = table.Column<string>(type: "TEXT", nullable: true),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Characters", x => x.Id);
table.ForeignKey(
name: "FK_Characters_Projects_ProjectId",
column: x => x.ProjectId,
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Conversations",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Conversations", x => x.Id);
table.ForeignKey(
name: "FK_Conversations_Projects_ProjectId",
column: x => x.ProjectId,
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Chapters",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
Number = table.Column<int>(type: "INTEGER", nullable: false),
Title = table.Column<string>(type: "TEXT", maxLength: 300, nullable: false),
Summary = table.Column<string>(type: "TEXT", nullable: true),
PovCharacterId = table.Column<Guid>(type: "TEXT", nullable: true),
Setting = table.Column<string>(type: "TEXT", nullable: true),
Notes = table.Column<string>(type: "TEXT", nullable: true),
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
TargetWordCount = table.Column<int>(type: "INTEGER", nullable: true),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Chapters", x => x.Id);
table.ForeignKey(
name: "FK_Chapters_Characters_PovCharacterId",
column: x => x.PovCharacterId,
principalTable: "Characters",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_Chapters_Projects_ProjectId",
column: x => x.ProjectId,
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CharacterRelationships",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
CharacterId = table.Column<Guid>(type: "TEXT", nullable: false),
RelatedCharacterId = table.Column<Guid>(type: "TEXT", nullable: false),
RelationshipType = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
Description = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CharacterRelationships", x => x.Id);
table.ForeignKey(
name: "FK_CharacterRelationships_Characters_CharacterId",
column: x => x.CharacterId,
principalTable: "Characters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CharacterRelationships_Characters_RelatedCharacterId",
column: x => x.RelatedCharacterId,
principalTable: "Characters",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "AgentMessages",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
ConversationId = table.Column<Guid>(type: "TEXT", nullable: false),
Role = table.Column<string>(type: "TEXT", maxLength: 16, nullable: false),
Sequence = table.Column<int>(type: "INTEGER", nullable: false),
Content = table.Column<string>(type: "TEXT", nullable: false),
ToolCallsJson = table.Column<string>(type: "TEXT", nullable: true),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AgentMessages", x => x.Id);
table.ForeignKey(
name: "FK_AgentMessages_Conversations_ConversationId",
column: x => x.ConversationId,
principalTable: "Conversations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "OutlineNodes",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
ParentId = table.Column<Guid>(type: "TEXT", nullable: true),
NodeType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Title = table.Column<string>(type: "TEXT", maxLength: 300, nullable: false),
Summary = table.Column<string>(type: "TEXT", nullable: true),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
ChapterId = table.Column<Guid>(type: "TEXT", nullable: true),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OutlineNodes", x => x.Id);
table.ForeignKey(
name: "FK_OutlineNodes_Chapters_ChapterId",
column: x => x.ChapterId,
principalTable: "Chapters",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_OutlineNodes_OutlineNodes_ParentId",
column: x => x.ParentId,
principalTable: "OutlineNodes",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_OutlineNodes_Projects_ProjectId",
column: x => x.ProjectId,
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Scenes",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
ChapterId = table.Column<Guid>(type: "TEXT", nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
Title = table.Column<string>(type: "TEXT", maxLength: 300, nullable: false),
Summary = table.Column<string>(type: "TEXT", nullable: true),
Goal = table.Column<string>(type: "TEXT", nullable: true),
Conflict = table.Column<string>(type: "TEXT", nullable: true),
Outcome = table.Column<string>(type: "TEXT", nullable: true),
PovCharacterId = table.Column<Guid>(type: "TEXT", nullable: true),
Location = table.Column<string>(type: "TEXT", nullable: true),
Prose = table.Column<string>(type: "TEXT", nullable: true),
WordCount = table.Column<int>(type: "INTEGER", nullable: false),
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Scenes", x => x.Id);
table.ForeignKey(
name: "FK_Scenes_Chapters_ChapterId",
column: x => x.ChapterId,
principalTable: "Chapters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Scenes_Characters_PovCharacterId",
column: x => x.PovCharacterId,
principalTable: "Characters",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_AgentMessages_ConversationId_Sequence",
table: "AgentMessages",
columns: new[] { "ConversationId", "Sequence" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Chapters_PovCharacterId",
table: "Chapters",
column: "PovCharacterId");
migrationBuilder.CreateIndex(
name: "IX_Chapters_ProjectId_Number",
table: "Chapters",
columns: new[] { "ProjectId", "Number" });
migrationBuilder.CreateIndex(
name: "IX_CharacterRelationships_CharacterId",
table: "CharacterRelationships",
column: "CharacterId");
migrationBuilder.CreateIndex(
name: "IX_CharacterRelationships_RelatedCharacterId",
table: "CharacterRelationships",
column: "RelatedCharacterId");
migrationBuilder.CreateIndex(
name: "IX_Characters_ProjectId",
table: "Characters",
column: "ProjectId");
migrationBuilder.CreateIndex(
name: "IX_Conversations_ProjectId",
table: "Conversations",
column: "ProjectId");
migrationBuilder.CreateIndex(
name: "IX_OutlineNodes_ChapterId",
table: "OutlineNodes",
column: "ChapterId");
migrationBuilder.CreateIndex(
name: "IX_OutlineNodes_ParentId",
table: "OutlineNodes",
column: "ParentId");
migrationBuilder.CreateIndex(
name: "IX_OutlineNodes_ProjectId_ParentId_SortOrder",
table: "OutlineNodes",
columns: new[] { "ProjectId", "ParentId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_Scenes_ChapterId_SortOrder",
table: "Scenes",
columns: new[] { "ChapterId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_Scenes_PovCharacterId",
table: "Scenes",
column: "PovCharacterId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AgentMessages");
migrationBuilder.DropTable(
name: "CharacterRelationships");
migrationBuilder.DropTable(
name: "OutlineNodes");
migrationBuilder.DropTable(
name: "Scenes");
migrationBuilder.DropTable(
name: "Conversations");
migrationBuilder.DropTable(
name: "Chapters");
migrationBuilder.DropTable(
name: "Characters");
migrationBuilder.DropTable(
name: "Projects");
}
}
}
@@ -0,0 +1,657 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Novelly.Api.Data;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Novelly.Api.Migrations
{
[DbContext(typeof(NovelDbContext))]
[Migration("20260806031243_ReplaceOutlineWithBeatsAndTags")]
partial class ReplaceOutlineWithBeatsAndTags
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("BeatTag", b =>
{
b.Property<Guid>("BeatsId")
.HasColumnType("TEXT");
b.Property<Guid>("TagsId")
.HasColumnType("TEXT");
b.HasKey("BeatsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("BeatTags", (string)null);
});
modelBuilder.Entity("ChapterTag", b =>
{
b.Property<Guid>("ChaptersId")
.HasColumnType("TEXT");
b.Property<Guid>("TagsId")
.HasColumnType("TEXT");
b.HasKey("ChaptersId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("ChapterTags", (string)null);
});
modelBuilder.Entity("CharacterTag", b =>
{
b.Property<Guid>("CharactersId")
.HasColumnType("TEXT");
b.Property<Guid>("TagsId")
.HasColumnType("TEXT");
b.HasKey("CharactersId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("CharacterTags", (string)null);
});
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("Conversations");
});
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("ConversationId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<int>("Sequence")
.HasColumnType("INTEGER");
b.Property<string>("ToolCallsJson")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ConversationId", "Sequence")
.IsUnique();
b.ToTable("AgentMessages");
});
modelBuilder.Entity("Novelly.Api.Beats.Beat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("ChapterId")
.HasColumnType("TEXT");
b.Property<Guid?>("CharacterId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("SceneId")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<string>("WhatHappened")
.HasColumnType("TEXT");
b.Property<string>("WhatsNext")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("SceneId");
b.HasIndex("ChapterId", "SortOrder");
b.ToTable("Beats");
});
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<Guid?>("PovCharacterId")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Setting")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<int?>("TargetWordCount")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PovCharacterId");
b.HasIndex("ProjectId", "Number");
b.ToTable("Chapters");
});
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Age")
.HasColumnType("TEXT");
b.Property<string>("Appearance")
.HasColumnType("TEXT");
b.Property<string>("ArcSummary")
.HasColumnType("TEXT");
b.Property<string>("Backstory")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("ExternalConflict")
.HasColumnType("TEXT");
b.Property<string>("InternalConflict")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Need")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<string>("Occupation")
.HasColumnType("TEXT");
b.Property<string>("Personality")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Pronouns")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Voice")
.HasColumnType("TEXT");
b.Property<string>("Want")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("Characters");
});
modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<Guid>("RelatedCharacterId")
.HasColumnType("TEXT");
b.Property<string>("RelationshipType")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("RelatedCharacterId");
b.ToTable("CharacterRelationships");
});
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Author")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Genre")
.HasColumnType("TEXT");
b.Property<string>("Logline")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<string>("Synopsis")
.HasColumnType("TEXT");
b.Property<int?>("TargetWordCount")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Novelly.Api.Scenes.Scene", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("ChapterId")
.HasColumnType("TEXT");
b.Property<string>("Conflict")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Goal")
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("Outcome")
.HasColumnType("TEXT");
b.Property<Guid?>("PovCharacterId")
.HasColumnType("TEXT");
b.Property<string>("Prose")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<int>("WordCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PovCharacterId");
b.HasIndex("ChapterId", "SortOrder");
b.ToTable("Scenes");
});
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Color")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProjectId", "Name")
.IsUnique();
b.ToTable("Tags");
});
modelBuilder.Entity("BeatTag", b =>
{
b.HasOne("Novelly.Api.Beats.Beat", null)
.WithMany()
.HasForeignKey("BeatsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Tags.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ChapterTag", b =>
{
b.HasOne("Novelly.Api.Chapters.Chapter", null)
.WithMany()
.HasForeignKey("ChaptersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Tags.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CharacterTag", b =>
{
b.HasOne("Novelly.Api.Characters.Character", null)
.WithMany()
.HasForeignKey("CharactersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Tags.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{
b.HasOne("Novelly.Api.Projects.Project", "Project")
.WithMany("Conversations")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
{
b.HasOne("Novelly.Api.Agent.AgentConversation", "Conversation")
.WithMany("Messages")
.HasForeignKey("ConversationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Conversation");
});
modelBuilder.Entity("Novelly.Api.Beats.Beat", b =>
{
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
.WithMany("Beats")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Characters.Character", "Character")
.WithMany()
.HasForeignKey("CharacterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Novelly.Api.Scenes.Scene", "Scene")
.WithMany()
.HasForeignKey("SceneId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Chapter");
b.Navigation("Character");
b.Navigation("Scene");
});
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
{
b.HasOne("Novelly.Api.Characters.Character", "PovCharacter")
.WithMany()
.HasForeignKey("PovCharacterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Novelly.Api.Projects.Project", "Project")
.WithMany("Chapters")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PovCharacter");
b.Navigation("Project");
});
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
{
b.HasOne("Novelly.Api.Projects.Project", "Project")
.WithMany("Characters")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b =>
{
b.HasOne("Novelly.Api.Characters.Character", "Character")
.WithMany("Relationships")
.HasForeignKey("CharacterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Characters.Character", "RelatedCharacter")
.WithMany()
.HasForeignKey("RelatedCharacterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Character");
b.Navigation("RelatedCharacter");
});
modelBuilder.Entity("Novelly.Api.Scenes.Scene", b =>
{
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
.WithMany("Scenes")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Characters.Character", "PovCharacter")
.WithMany()
.HasForeignKey("PovCharacterId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Chapter");
b.Navigation("PovCharacter");
});
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
{
b.HasOne("Novelly.Api.Projects.Project", "Project")
.WithMany("Tags")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{
b.Navigation("Messages");
});
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
{
b.Navigation("Beats");
b.Navigation("Scenes");
});
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
{
b.Navigation("Relationships");
});
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
{
b.Navigation("Chapters");
b.Navigation("Characters");
b.Navigation("Conversations");
b.Navigation("Tags");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,257 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Migrations
{
/// <inheritdoc />
public partial class ReplaceOutlineWithBeatsAndTags : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "OutlineNodes");
migrationBuilder.CreateTable(
name: "Beats",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
ChapterId = table.Column<Guid>(type: "TEXT", nullable: false),
SceneId = table.Column<Guid>(type: "TEXT", nullable: true),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
CharacterId = table.Column<Guid>(type: "TEXT", nullable: true),
WhatHappened = table.Column<string>(type: "TEXT", nullable: true),
WhatsNext = table.Column<string>(type: "TEXT", nullable: true),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Beats", x => x.Id);
table.ForeignKey(
name: "FK_Beats_Chapters_ChapterId",
column: x => x.ChapterId,
principalTable: "Chapters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Beats_Characters_CharacterId",
column: x => x.CharacterId,
principalTable: "Characters",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_Beats_Scenes_SceneId",
column: x => x.SceneId,
principalTable: "Scenes",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "Tags",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Color = table.Column<string>(type: "TEXT", maxLength: 16, nullable: true),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tags", x => x.Id);
table.ForeignKey(
name: "FK_Tags_Projects_ProjectId",
column: x => x.ProjectId,
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "BeatTags",
columns: table => new
{
BeatsId = table.Column<Guid>(type: "TEXT", nullable: false),
TagsId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BeatTags", x => new { x.BeatsId, x.TagsId });
table.ForeignKey(
name: "FK_BeatTags_Beats_BeatsId",
column: x => x.BeatsId,
principalTable: "Beats",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_BeatTags_Tags_TagsId",
column: x => x.TagsId,
principalTable: "Tags",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ChapterTags",
columns: table => new
{
ChaptersId = table.Column<Guid>(type: "TEXT", nullable: false),
TagsId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChapterTags", x => new { x.ChaptersId, x.TagsId });
table.ForeignKey(
name: "FK_ChapterTags_Chapters_ChaptersId",
column: x => x.ChaptersId,
principalTable: "Chapters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ChapterTags_Tags_TagsId",
column: x => x.TagsId,
principalTable: "Tags",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CharacterTags",
columns: table => new
{
CharactersId = table.Column<Guid>(type: "TEXT", nullable: false),
TagsId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CharacterTags", x => new { x.CharactersId, x.TagsId });
table.ForeignKey(
name: "FK_CharacterTags_Characters_CharactersId",
column: x => x.CharactersId,
principalTable: "Characters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CharacterTags_Tags_TagsId",
column: x => x.TagsId,
principalTable: "Tags",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Beats_ChapterId_SortOrder",
table: "Beats",
columns: new[] { "ChapterId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_Beats_CharacterId",
table: "Beats",
column: "CharacterId");
migrationBuilder.CreateIndex(
name: "IX_Beats_SceneId",
table: "Beats",
column: "SceneId");
migrationBuilder.CreateIndex(
name: "IX_BeatTags_TagsId",
table: "BeatTags",
column: "TagsId");
migrationBuilder.CreateIndex(
name: "IX_ChapterTags_TagsId",
table: "ChapterTags",
column: "TagsId");
migrationBuilder.CreateIndex(
name: "IX_CharacterTags_TagsId",
table: "CharacterTags",
column: "TagsId");
migrationBuilder.CreateIndex(
name: "IX_Tags_ProjectId_Name",
table: "Tags",
columns: new[] { "ProjectId", "Name" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BeatTags");
migrationBuilder.DropTable(
name: "ChapterTags");
migrationBuilder.DropTable(
name: "CharacterTags");
migrationBuilder.DropTable(
name: "Beats");
migrationBuilder.DropTable(
name: "Tags");
migrationBuilder.CreateTable(
name: "OutlineNodes",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
ChapterId = table.Column<Guid>(type: "TEXT", nullable: true),
ParentId = table.Column<Guid>(type: "TEXT", nullable: true),
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
NodeType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
Summary = table.Column<string>(type: "TEXT", nullable: true),
Title = table.Column<string>(type: "TEXT", maxLength: 300, nullable: false),
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OutlineNodes", x => x.Id);
table.ForeignKey(
name: "FK_OutlineNodes_Chapters_ChapterId",
column: x => x.ChapterId,
principalTable: "Chapters",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_OutlineNodes_OutlineNodes_ParentId",
column: x => x.ParentId,
principalTable: "OutlineNodes",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_OutlineNodes_Projects_ProjectId",
column: x => x.ProjectId,
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_OutlineNodes_ChapterId",
table: "OutlineNodes",
column: "ChapterId");
migrationBuilder.CreateIndex(
name: "IX_OutlineNodes_ParentId",
table: "OutlineNodes",
column: "ParentId");
migrationBuilder.CreateIndex(
name: "IX_OutlineNodes_ProjectId_ParentId_SortOrder",
table: "OutlineNodes",
columns: new[] { "ProjectId", "ParentId", "SortOrder" });
}
}
}
@@ -0,0 +1,654 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Novelly.Api.Data;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Novelly.Api.Migrations
{
[DbContext(typeof(NovelDbContext))]
partial class NovelDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("BeatTag", b =>
{
b.Property<Guid>("BeatsId")
.HasColumnType("TEXT");
b.Property<Guid>("TagsId")
.HasColumnType("TEXT");
b.HasKey("BeatsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("BeatTags", (string)null);
});
modelBuilder.Entity("ChapterTag", b =>
{
b.Property<Guid>("ChaptersId")
.HasColumnType("TEXT");
b.Property<Guid>("TagsId")
.HasColumnType("TEXT");
b.HasKey("ChaptersId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("ChapterTags", (string)null);
});
modelBuilder.Entity("CharacterTag", b =>
{
b.Property<Guid>("CharactersId")
.HasColumnType("TEXT");
b.Property<Guid>("TagsId")
.HasColumnType("TEXT");
b.HasKey("CharactersId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("CharacterTags", (string)null);
});
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("Conversations");
});
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("ConversationId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<int>("Sequence")
.HasColumnType("INTEGER");
b.Property<string>("ToolCallsJson")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ConversationId", "Sequence")
.IsUnique();
b.ToTable("AgentMessages");
});
modelBuilder.Entity("Novelly.Api.Beats.Beat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("ChapterId")
.HasColumnType("TEXT");
b.Property<Guid?>("CharacterId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("SceneId")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<string>("WhatHappened")
.HasColumnType("TEXT");
b.Property<string>("WhatsNext")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("SceneId");
b.HasIndex("ChapterId", "SortOrder");
b.ToTable("Beats");
});
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<Guid?>("PovCharacterId")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Setting")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<int?>("TargetWordCount")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PovCharacterId");
b.HasIndex("ProjectId", "Number");
b.ToTable("Chapters");
});
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Age")
.HasColumnType("TEXT");
b.Property<string>("Appearance")
.HasColumnType("TEXT");
b.Property<string>("ArcSummary")
.HasColumnType("TEXT");
b.Property<string>("Backstory")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("ExternalConflict")
.HasColumnType("TEXT");
b.Property<string>("InternalConflict")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Need")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<string>("Occupation")
.HasColumnType("TEXT");
b.Property<string>("Personality")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Pronouns")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Voice")
.HasColumnType("TEXT");
b.Property<string>("Want")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("Characters");
});
modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<Guid>("RelatedCharacterId")
.HasColumnType("TEXT");
b.Property<string>("RelationshipType")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("RelatedCharacterId");
b.ToTable("CharacterRelationships");
});
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Author")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Genre")
.HasColumnType("TEXT");
b.Property<string>("Logline")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<string>("Synopsis")
.HasColumnType("TEXT");
b.Property<int?>("TargetWordCount")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Novelly.Api.Scenes.Scene", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("ChapterId")
.HasColumnType("TEXT");
b.Property<string>("Conflict")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Goal")
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("Outcome")
.HasColumnType("TEXT");
b.Property<Guid?>("PovCharacterId")
.HasColumnType("TEXT");
b.Property<string>("Prose")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<int>("WordCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PovCharacterId");
b.HasIndex("ChapterId", "SortOrder");
b.ToTable("Scenes");
});
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Color")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProjectId", "Name")
.IsUnique();
b.ToTable("Tags");
});
modelBuilder.Entity("BeatTag", b =>
{
b.HasOne("Novelly.Api.Beats.Beat", null)
.WithMany()
.HasForeignKey("BeatsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Tags.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ChapterTag", b =>
{
b.HasOne("Novelly.Api.Chapters.Chapter", null)
.WithMany()
.HasForeignKey("ChaptersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Tags.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CharacterTag", b =>
{
b.HasOne("Novelly.Api.Characters.Character", null)
.WithMany()
.HasForeignKey("CharactersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Tags.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{
b.HasOne("Novelly.Api.Projects.Project", "Project")
.WithMany("Conversations")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
{
b.HasOne("Novelly.Api.Agent.AgentConversation", "Conversation")
.WithMany("Messages")
.HasForeignKey("ConversationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Conversation");
});
modelBuilder.Entity("Novelly.Api.Beats.Beat", b =>
{
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
.WithMany("Beats")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Characters.Character", "Character")
.WithMany()
.HasForeignKey("CharacterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Novelly.Api.Scenes.Scene", "Scene")
.WithMany()
.HasForeignKey("SceneId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Chapter");
b.Navigation("Character");
b.Navigation("Scene");
});
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
{
b.HasOne("Novelly.Api.Characters.Character", "PovCharacter")
.WithMany()
.HasForeignKey("PovCharacterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Novelly.Api.Projects.Project", "Project")
.WithMany("Chapters")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PovCharacter");
b.Navigation("Project");
});
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
{
b.HasOne("Novelly.Api.Projects.Project", "Project")
.WithMany("Characters")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b =>
{
b.HasOne("Novelly.Api.Characters.Character", "Character")
.WithMany("Relationships")
.HasForeignKey("CharacterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Characters.Character", "RelatedCharacter")
.WithMany()
.HasForeignKey("RelatedCharacterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Character");
b.Navigation("RelatedCharacter");
});
modelBuilder.Entity("Novelly.Api.Scenes.Scene", b =>
{
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
.WithMany("Scenes")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Characters.Character", "PovCharacter")
.WithMany()
.HasForeignKey("PovCharacterId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Chapter");
b.Navigation("PovCharacter");
});
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
{
b.HasOne("Novelly.Api.Projects.Project", "Project")
.WithMany("Tags")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{
b.Navigation("Messages");
});
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
{
b.Navigation("Beats");
b.Navigation("Scenes");
});
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
{
b.Navigation("Relationships");
});
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
{
b.Navigation("Chapters");
b.Navigation("Characters");
b.Navigation("Conversations");
b.Navigation("Tags");
});
#pragma warning restore 612, 618
}
}
}
+149
View File
@@ -0,0 +1,149 @@
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Agent;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Projects;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
namespace Novelly.Api.Data;
/// <summary>
/// Stores a <see cref="DateTimeOffset"/> as UTC ticks. SQLite has no native type for it
/// and refuses to ORDER BY the default text form, which every "most recently updated
/// first" listing depends on. The domain only ever writes UtcNow, so normalising to UTC
/// loses nothing.
/// </summary>
internal class UtcTicksConverter()
: ValueConverter<DateTimeOffset, long>(
value => value.UtcTicks,
ticks => new DateTimeOffset(ticks, TimeSpan.Zero));
public class NovelDbContext(DbContextOptions<NovelDbContext> options)
: DbContext(options), INovelDbContext
{
public DbSet<Project> Projects => Set<Project>();
public DbSet<Character> Characters => Set<Character>();
public DbSet<CharacterRelationship> CharacterRelationships => Set<CharacterRelationship>();
public DbSet<Beat> Beats => Set<Beat>();
public DbSet<Tag> Tags => Set<Tag>();
public DbSet<Chapter> Chapters => Set<Chapter>();
public DbSet<Scene> Scenes => Set<Scene>();
public DbSet<AgentConversation> Conversations => Set<AgentConversation>();
public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>();
Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) =>
base.SaveChangesAsync(cancellationToken);
protected override void ConfigureConventions(ModelConfigurationBuilder builder) =>
builder.Properties<DateTimeOffset>().HaveConversion<UtcTicksConverter>();
protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<Project>(entity =>
{
entity.Property(p => p.Title).IsRequired().HasMaxLength(300);
entity.HasMany(p => p.Characters).WithOne(c => c.Project!)
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Chapters).WithOne(c => c.Project!)
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Tags).WithOne(t => t.Project!)
.HasForeignKey(t => t.ProjectId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Conversations).WithOne(c => c.Project!)
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<Character>(entity =>
{
entity.Property(c => c.Name).IsRequired().HasMaxLength(200);
entity.Property(c => c.Role).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(c => c.ProjectId);
entity.HasMany(c => c.Relationships).WithOne(r => r.Character!)
.HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<CharacterRelationship>(entity =>
{
entity.Property(r => r.RelationshipType).IsRequired().HasMaxLength(120);
// Restrict on the inverse side: deleting a character should not silently take
// the other character's relationship rows with it via a second cascade path,
// which SQLite rejects as a multiple-cascade cycle.
entity.HasOne(r => r.RelatedCharacter).WithMany()
.HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<Beat>(entity =>
{
entity.Property(b => b.Title).IsRequired().HasMaxLength(200);
entity.HasIndex(b => new { b.ChapterId, b.SortOrder });
entity.HasOne(b => b.Chapter).WithMany(c => c.Beats)
.HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade);
// A beat outlives the scene it was grouped under: deleting a scene is a
// decision about prose, not about the plan.
entity.HasOne(b => b.Scene).WithMany()
.HasForeignKey(b => b.SceneId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(b => b.Character).WithMany()
.HasForeignKey(b => b.CharacterId).OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<Tag>(entity =>
{
entity.Property(t => t.Name).IsRequired().HasMaxLength(64);
entity.Property(t => t.Color).HasMaxLength(16);
// One canonical tag per name per project, so "betrayal" always means the
// same tag no matter where it was typed.
entity.HasIndex(t => new { t.ProjectId, t.Name }).IsUnique();
entity.HasMany(t => t.Characters).WithMany(c => c.Tags)
.UsingEntity(join => join.ToTable("CharacterTags"));
entity.HasMany(t => t.Chapters).WithMany(c => c.Tags)
.UsingEntity(join => join.ToTable("ChapterTags"));
entity.HasMany(t => t.Beats).WithMany(b => b.Tags)
.UsingEntity(join => join.ToTable("BeatTags"));
});
builder.Entity<Chapter>(entity =>
{
entity.Property(c => c.Title).IsRequired().HasMaxLength(300);
entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(c => new { c.ProjectId, c.Number });
entity.HasOne(c => c.PovCharacter).WithMany()
.HasForeignKey(c => c.PovCharacterId).OnDelete(DeleteBehavior.SetNull);
entity.HasMany(c => c.Scenes).WithOne(s => s.Chapter!)
.HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<Scene>(entity =>
{
entity.Property(s => s.Title).IsRequired().HasMaxLength(300);
entity.Property(s => s.Status).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(s => new { s.ChapterId, s.SortOrder });
entity.HasOne(s => s.PovCharacter).WithMany()
.HasForeignKey(s => s.PovCharacterId).OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<AgentConversation>(entity =>
{
entity.Property(c => c.Title).IsRequired().HasMaxLength(200);
entity.HasMany(c => c.Messages).WithOne(m => m.Conversation!)
.HasForeignKey(m => m.ConversationId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<AgentMessage>(entity =>
{
entity.Property(m => m.Role).HasConversion<string>().HasMaxLength(16);
entity.HasIndex(m => new { m.ConversationId, m.Sequence }).IsUnique();
});
}
}
+26
View File
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\Novelly.ServiceDefaults\Novelly.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Anthropic" Version="12.39.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+87
View File
@@ -0,0 +1,87 @@
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Agent;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.Projects;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddNovelly(builder.Configuration);
builder.Services.AddOpenApi();
builder.Services.AddProblemDetails();
// Enums travel as their names, so the React client and the MCP server both read
// "Protagonist" rather than an ordinal that shifts whenever the enum is reordered.
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>()
?? ["http://localhost:5173"];
builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy
.WithOrigins(corsOrigins)
.AllowAnyHeader()
.AllowAnyMethod()));
var app = builder.Build();
// Local-first tool: bring the SQLite file up to date on boot rather than making the
// writer run a migration command before they can open the app.
using (var scope = app.Services.CreateScope())
{
await scope.ServiceProvider.GetRequiredService<NovelDbContext>().Database.MigrateAsync();
}
app.UseExceptionHandler(handler => handler.Run(async context =>
{
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
var (status, title) = exception switch
{
NotFoundException => (StatusCodes.Status404NotFound, "Not found"),
AgentNotConfiguredException => (StatusCodes.Status503ServiceUnavailable, "Agent unavailable"),
ArgumentException or InvalidOperationException => (StatusCodes.Status400BadRequest, "Invalid request"),
_ => (StatusCodes.Status500InternalServerError, "Unexpected error")
};
if (status == StatusCodes.Status500InternalServerError)
{
app.Logger.LogError(exception, "Unhandled exception on {Path}", context.Request.Path);
}
await Results
.Problem(title: title, detail: exception?.Message, statusCode: status)
.ExecuteAsync(context);
}));
app.UseCors();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.MapDefaultEndpoints();
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health");
app.MapProjectEndpoints()
.MapCharacterEndpoints()
.MapChapterEndpoints()
.MapBeatEndpoints()
.MapSceneEndpoints()
.MapTagEndpoints()
.MapAgentEndpoints();
app.Run();
/// <summary>Exposed so the tests can spin the API up with WebApplicationFactory.</summary>
public partial class Program;
+35
View File
@@ -0,0 +1,35 @@
using Novelly.Api.Agent;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Tags;
namespace Novelly.Api.Projects;
/// <summary>A single novel and everything that belongs to it.</summary>
public class Project
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Title { get; set; } = string.Empty;
public string? Author { get; set; }
public string? Genre { get; set; }
/// <summary>One-sentence pitch.</summary>
public string? Logline { get; set; }
/// <summary>Paragraph-length summary of the whole book.</summary>
public string? Synopsis { get; set; }
/// <summary>Free-form notes on theme, tone, comparable titles, etc.</summary>
public string? Notes { get; set; }
public int? TargetWordCount { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public List<Character> Characters { get; set; } = [];
public List<Chapter> Chapters { get; set; } = [];
public List<Tag> Tags { get; set; } = [];
public List<AgentConversation> Conversations { get; set; } = [];
}
+54
View File
@@ -0,0 +1,54 @@
namespace Novelly.Api.Projects;
public record ProjectSummaryDto(
Guid Id,
string Title,
string? Author,
string? Genre,
string? Logline,
int? TargetWordCount,
int CharacterCount,
int ChapterCount,
int WordCount,
DateTimeOffset UpdatedAt);
public record ProjectDto(
Guid Id,
string Title,
string? Author,
string? Genre,
string? Logline,
string? Synopsis,
string? Notes,
int? TargetWordCount,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
public record CreateProjectRequest(
string Title,
string? Author = null,
string? Genre = null,
string? Logline = null,
string? Synopsis = null,
string? Notes = null,
int? TargetWordCount = null);
/// <summary>
/// Patch-style update: every field is optional and null means "leave alone".
/// Clearing a field is done by sending an empty string.
/// </summary>
public record UpdateProjectRequest(
string? Title = null,
string? Author = null,
string? Genre = null,
string? Logline = null,
string? Synopsis = null,
string? Notes = null,
int? TargetWordCount = null);
public static class ProjectMapping
{
public static ProjectDto ToDto(this Project p) => new(
p.Id, p.Title, p.Author, p.Genre, p.Logline, p.Synopsis, p.Notes,
p.TargetWordCount, p.CreatedAt, p.UpdatedAt);
}
@@ -0,0 +1,38 @@
namespace Novelly.Api.Projects;
public static class ProjectEndpoints
{
public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/projects").WithTags("Projects");
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)))
.WithSummary("Read a project's brief.");
group.MapPost("/", async (CreateProjectRequest request, ProjectService service, CancellationToken ct) =>
{
var created = await service.CreateAsync(request, ct);
return Results.Created($"/api/projects/{created.Id}", created);
})
.WithSummary("Create a novel project.");
group.MapPatch("/{id:guid}", async (
Guid id, UpdateProjectRequest request, ProjectService service, CancellationToken ct) =>
Results.Ok(await service.UpdateAsync(id, request, ct)))
.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();
})
.WithSummary("Delete a project and everything in it.");
return app;
}
}
@@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Data;
namespace Novelly.Api.Projects;
public class ProjectService(INovelDbContext db)
{
public async Task<IReadOnlyList<ProjectSummaryDto>> ListAsync(CancellationToken ct = default) =>
await db.Projects
.OrderByDescending(p => p.UpdatedAt)
.Select(p => new ProjectSummaryDto(
p.Id,
p.Title,
p.Author,
p.Genre,
p.Logline,
p.TargetWordCount,
p.Characters.Count,
p.Chapters.Count,
p.Chapters.SelectMany(c => c.Scenes).Sum(s => (int?)s.WordCount) ?? 0,
p.UpdatedAt))
.ToListAsync(ct);
public async Task<ProjectDto> GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
public async Task<ProjectDto> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
{
var project = new Project
{
Title = request.Title,
Author = request.Author,
Genre = request.Genre,
Logline = request.Logline,
Synopsis = request.Synopsis,
Notes = request.Notes,
TargetWordCount = request.TargetWordCount
};
db.Projects.Add(project);
await db.SaveChangesAsync(ct);
return project.ToDto();
}
public async Task<ProjectDto> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
{
var project = await FindAsync(id, ct);
project.Title = Patch.Apply(project.Title, request.Title) ?? project.Title;
project.Author = Patch.Apply(project.Author, request.Author);
project.Genre = Patch.Apply(project.Genre, request.Genre);
project.Logline = Patch.Apply(project.Logline, request.Logline);
project.Synopsis = Patch.Apply(project.Synopsis, request.Synopsis);
project.Notes = Patch.Apply(project.Notes, request.Notes);
project.TargetWordCount = request.TargetWordCount ?? project.TargetWordCount;
project.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return project.ToDto();
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var project = await FindAsync(id, ct);
db.Projects.Remove(project);
await db.SaveChangesAsync(ct);
}
private async Task<Project> FindAsync(Guid id, CancellationToken ct) =>
await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct)
?? throw new NotFoundException(nameof(Project), id);
}
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5266",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7123;http://localhost:5266",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+45
View File
@@ -0,0 +1,45 @@
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
namespace Novelly.Api.Scenes;
/// <summary>
/// A scene inside a chapter. The goal/conflict/outcome trio is the unit the agent
/// works with when turning an outline into prose.
/// </summary>
public class Scene
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ChapterId { get; set; }
public Chapter? Chapter { get; set; }
/// <summary>Position within the chapter, 1-based.</summary>
public int SortOrder { get; set; }
public string Title { get; set; } = string.Empty;
public string? Summary { get; set; }
/// <summary>What the POV character is trying to achieve.</summary>
public string? Goal { get; set; }
/// <summary>What stands in the way.</summary>
public string? Conflict { get; set; }
/// <summary>How it lands — and what it costs.</summary>
public string? Outcome { get; set; }
public Guid? PovCharacterId { get; set; }
public Character? PovCharacter { get; set; }
public string? Location { get; set; }
/// <summary>The drafted prose, if any.</summary>
public string? Prose { get; set; }
public int WordCount { get; set; }
public DraftStatus Status { get; set; } = DraftStatus.Planned;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
+62
View File
@@ -0,0 +1,62 @@
using Novelly.Api.Common;
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 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 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;
}
+41
View File
@@ -0,0 +1,41 @@
namespace Novelly.Api.Scenes;
public static class SceneEndpoints
{
public static IEndpointRouteBuilder MapSceneEndpoints(this IEndpointRouteBuilder app)
{
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/scenes").WithTags("Scenes");
chapterScoped.MapGet("/", async (Guid chapterId, SceneService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(chapterId, ct)))
.WithSummary("List a chapter's scenes in order.");
chapterScoped.MapPost("/", async (
Guid chapterId, CreateSceneRequest request, SceneService service, CancellationToken ct) =>
{
var created = await service.CreateAsync(chapterId, request, ct);
return Results.Created($"/api/scenes/{created.Id}", created);
})
.WithSummary("Add a scene to a chapter.");
var scenes = app.MapGroup("/api/scenes").WithTags("Scenes");
scenes.MapGet("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct)))
.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)))
.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();
})
.WithSummary("Delete a scene.");
return app;
}
}
+98
View File
@@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Chapters;
using Novelly.Api.Common;
using Novelly.Api.Data;
namespace Novelly.Api.Scenes;
public class SceneService(INovelDbContext db)
{
public async Task<IReadOnlyList<SceneDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
{
var scenes = await Query()
.Where(s => s.ChapterId == chapterId)
.OrderBy(s => s.SortOrder)
.ToListAsync(ct);
return [.. scenes.Select(s => s.ToDto())];
}
public async Task<SceneDto> GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
public async Task<SceneDto> CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default)
{
if (!await db.Chapters.AnyAsync(c => c.Id == chapterId, ct))
{
throw new NotFoundException(nameof(Chapter), chapterId);
}
var scene = new Scene
{
ChapterId = chapterId,
Title = request.Title,
SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct),
Summary = request.Summary,
Goal = request.Goal,
Conflict = request.Conflict,
Outcome = request.Outcome,
PovCharacterId = request.PovCharacterId,
Location = request.Location,
Prose = request.Prose,
WordCount = SceneMapping.CountWords(request.Prose),
Status = request.Status
};
db.Scenes.Add(scene);
await db.SaveChangesAsync(ct);
return (await FindAsync(scene.Id, ct)).ToDto();
}
public async Task<SceneDto> UpdateAsync(Guid id, UpdateSceneRequest request, CancellationToken ct = default)
{
var scene = await FindAsync(id, ct);
scene.Title = Patch.Apply(scene.Title, request.Title) ?? scene.Title;
scene.SortOrder = request.SortOrder ?? scene.SortOrder;
scene.Summary = Patch.Apply(scene.Summary, request.Summary);
scene.Goal = Patch.Apply(scene.Goal, request.Goal);
scene.Conflict = Patch.Apply(scene.Conflict, request.Conflict);
scene.Outcome = Patch.Apply(scene.Outcome, request.Outcome);
scene.PovCharacterId = request.PovCharacterId ?? scene.PovCharacterId;
scene.Location = Patch.Apply(scene.Location, request.Location);
scene.Status = request.Status ?? scene.Status;
if (request.Prose is not null)
{
scene.Prose = Patch.Apply(scene.Prose, request.Prose);
scene.WordCount = SceneMapping.CountWords(scene.Prose);
}
scene.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct)).ToDto();
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var scene = await FindAsync(id, ct);
db.Scenes.Remove(scene);
await db.SaveChangesAsync(ct);
}
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
{
var max = await db.Scenes
.Where(s => s.ChapterId == chapterId)
.MaxAsync(s => (int?)s.SortOrder, ct);
return (max ?? 0) + 1;
}
private IQueryable<Scene> Query() => db.Scenes.Include(s => s.PovCharacter);
private async Task<Scene> FindAsync(Guid id, CancellationToken ct) =>
await Query().FirstOrDefaultAsync(s => s.Id == id, ct)
?? throw new NotFoundException(nameof(Scene), id);
}
+30
View File
@@ -0,0 +1,30 @@
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Projects;
namespace Novelly.Api.Tags;
/// <summary>
/// A free-form label scoped to one project. Tags are the cross-reference mechanism:
/// attach the same tag to a character, a chapter and a beat, then ask what else carries it.
/// Names are unique within a project so "betrayal" always means the same tag.
/// </summary>
public class Tag
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; }
public Project? Project { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>Optional hex colour for the UI, e.g. "#9a4a2f".</summary>
public string? Color { get; set; }
public List<Character> Characters { get; set; } = [];
public List<Chapter> Chapters { get; set; } = [];
public List<Beat> Beats { get; set; } = [];
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
+54
View File
@@ -0,0 +1,54 @@
namespace Novelly.Api.Tags;
public record TagDto(Guid Id, string Name, string? Color);
public record TagSummaryDto(
Guid Id,
string Name,
string? Color,
int CharacterCount,
int ChapterCount,
int BeatCount)
{
public int TotalCount => CharacterCount + ChapterCount + BeatCount;
}
public record CreateTagRequest(string Name, string? Color = null);
public record UpdateTagRequest(string? Name = null, string? Color = null);
/// <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
/// makes them worth maintaining.
/// </summary>
public record TagReferencesDto(
TagDto Tag,
IReadOnlyList<TaggedCharacterDto> Characters,
IReadOnlyList<TaggedChapterDto> Chapters,
IReadOnlyList<TaggedBeatDto> Beats);
public record TaggedCharacterDto(Guid Id, string Name, string Role);
public record TaggedChapterDto(Guid Id, int Number, string Title, string? Summary);
public record TaggedBeatDto(
Guid Id,
Guid ChapterId,
int ChapterNumber,
string ChapterTitle,
int SortOrder,
string Title,
string? CharacterName,
string? WhatHappened);
public static class TagMapping
{
public static TagDto ToDto(this Tag t) => new(t.Id, t.Name, t.Color);
/// <summary>
/// Tags are matched case-insensitively but stored as first typed, so "Betrayal" and
/// "betrayal" resolve to one tag rather than quietly becoming two.
/// </summary>
public static string Normalise(string name) => name.Trim();
}
+41
View File
@@ -0,0 +1,41 @@
namespace Novelly.Api.Tags;
public static class TagEndpoints
{
public static IEndpointRouteBuilder MapTagEndpoints(this IEndpointRouteBuilder app)
{
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/tags").WithTags("Tags");
projectScoped.MapGet("/", async (Guid projectId, TagService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(projectId, ct)))
.WithSummary("List a project's tags with usage counts.");
projectScoped.MapPost("/", async (
Guid projectId, CreateTagRequest request, TagService service, CancellationToken ct) =>
{
var created = await service.CreateAsync(projectId, request, ct);
return Results.Created($"/api/tags/{created.Id}", created);
})
.WithSummary("Create a tag. Tags are also created on demand when applied by name.");
var tags = app.MapGroup("/api/tags").WithTags("Tags");
tags.MapGet("/{id:guid}/references", async (Guid id, TagService service, CancellationToken ct) =>
Results.Ok(await service.GetReferencesAsync(id, ct)))
.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)))
.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();
})
.WithSummary("Delete a tag. Whatever carried it is left alone.");
return app;
}
}
+159
View File
@@ -0,0 +1,159 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.Projects;
namespace Novelly.Api.Tags;
public class TagService(INovelDbContext db)
{
public async Task<IReadOnlyList<TagSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default) =>
await db.Tags
.Where(t => t.ProjectId == projectId)
.OrderBy(t => t.Name)
.Select(t => new TagSummaryDto(
t.Id, t.Name, t.Color,
t.Characters.Count, t.Chapters.Count, t.Beats.Count))
.ToListAsync(ct);
/// <summary>Everything in the project carrying this tag.</summary>
public async Task<TagReferencesDto> GetReferencesAsync(Guid tagId, CancellationToken ct = default)
{
var tag = await db.Tags
.Include(t => t.Characters)
.Include(t => t.Chapters)
.Include(t => t.Beats).ThenInclude(b => b.Character)
.Include(t => t.Beats).ThenInclude(b => b.Chapter)
.FirstOrDefaultAsync(t => t.Id == tagId, ct)
?? throw new NotFoundException(nameof(Tag), tagId);
return new TagReferencesDto(
tag.ToDto(),
[.. tag.Characters
.OrderBy(c => c.Name)
.Select(c => new TaggedCharacterDto(c.Id, c.Name, c.Role.ToString()))],
[.. tag.Chapters
.OrderBy(c => c.Number)
.Select(c => new TaggedChapterDto(c.Id, c.Number, c.Title, c.Summary))],
[.. tag.Beats
.OrderBy(b => b.Chapter?.Number ?? 0)
.ThenBy(b => b.SortOrder)
.Select(b => new TaggedBeatDto(
b.Id,
b.ChapterId,
b.Chapter?.Number ?? 0,
b.Chapter?.Title ?? "(unknown chapter)",
b.SortOrder,
b.Title,
b.Character?.Name,
b.WhatHappened))]);
}
public async Task<TagDto> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default)
{
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
throw new NotFoundException(nameof(Project), projectId);
}
var name = TagMapping.Normalise(request.Name);
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("A tag needs a name.");
}
var existing = await FindByNameAsync(projectId, name, ct);
if (existing is not null)
{
throw new InvalidOperationException($"The project already has a tag called '{existing.Name}'.");
}
var tag = new Tag { ProjectId = projectId, Name = name, Color = request.Color };
db.Tags.Add(tag);
await db.SaveChangesAsync(ct);
return tag.ToDto();
}
public async Task<TagDto> UpdateAsync(Guid tagId, UpdateTagRequest request, CancellationToken ct = default)
{
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct)
?? throw new NotFoundException(nameof(Tag), tagId);
if (request.Name is not null)
{
var name = TagMapping.Normalise(request.Name);
if (string.IsNullOrWhiteSpace(name))
{
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)
{
throw new InvalidOperationException($"The project already has a tag called '{clash.Name}'.");
}
tag.Name = name;
}
tag.Color = Patch.Apply(tag.Color, request.Color);
await db.SaveChangesAsync(ct);
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)
{
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct)
?? throw new NotFoundException(nameof(Tag), tagId);
db.Tags.Remove(tag);
await db.SaveChangesAsync(ct);
}
/// <summary>
/// Turns a list of names into tag entities, creating any the project has not seen
/// before. Typing a new tag on a beat should just work rather than being a two-step
/// "create the tag, then apply it".
/// </summary>
internal async Task<List<Tag>> ResolveAsync(
Guid projectId, IReadOnlyList<string> names, CancellationToken ct)
{
var wanted = names
.Select(TagMapping.Normalise)
.Where(n => !string.IsNullOrWhiteSpace(n))
.DistinctBy(n => n.ToLowerInvariant())
.ToList();
if (wanted.Count == 0)
{
return [];
}
var existing = await db.Tags
.Where(t => t.ProjectId == projectId)
.ToListAsync(ct);
var resolved = new List<Tag>();
foreach (var name in wanted)
{
var match = existing.FirstOrDefault(
t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase));
if (match is null)
{
match = new Tag { ProjectId = projectId, Name = name };
db.Tags.Add(match);
existing.Add(match);
}
resolved.Add(match);
}
return resolved;
}
private async Task<Tag?> FindByNameAsync(Guid projectId, string name, CancellationToken ct) =>
await db.Tags.FirstOrDefaultAsync(
t => t.ProjectId == projectId && EF.Functions.Like(t.Name, name), ct);
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Novelly": "Debug",
"Microsoft.AspNetCore": "Warning"
}
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Novel": "Data Source=novel.db"
},
"Cors": {
"Origins": [ "http://localhost:5173" ]
},
"Agent": {
"Model": "claude-opus-5",
"MaxTokens": 16000,
"Effort": "high",
"MaxIterations": 12
}
}