Rename Project concept to Novel across the stack

Renames the domain concept from Project to Novel throughout the backend
(entities, DTOs, services, endpoints, ProjectAccessService/Permission,
ProjectId foreign keys), MCP server (tool names and routes), and the
React/Vite frontend (types, hooks, routes, components). Adds a new EF
Core migration (RenameProjectToNovel) using RenameTable/RenameColumn to
preserve existing data instead of dropping/recreating tables. Updates
CLAUDE.md's structure section to reference Novels/ instead of Projects/.
This commit is contained in:
James Wampler
2026-08-17 23:03:09 -07:00
parent 0ab4f568b5
commit 4313c8f206
95 changed files with 3192 additions and 1660 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ Novelly: software plan + write novel. ASP.NET Core 10, C#, TypeScript, React, .N
## Structure ## Structure
- `src/Novelly.Api/` — whole back end, organised by feature. One folder per feature holds - `src/Novelly.Api/` — whole back end, organised by feature. One folder per feature holds
entity, DTOs, service, endpoints together: `Projects/`, `Characters/`, `Chapters/`, `Beats/`, entity, DTOs, service, endpoints together: `Novels/`, `Characters/`, `Chapters/`, `Beats/`,
`Scenes/`, `Tags/`, `Agent/`. `Common/` holds what crosses features; `Data/` holds `Scenes/`, `Tags/`, `Agent/`. `Common/` holds what crosses features; `Data/` holds
`DbContext` + EF migrations. `DbContext` + EF migrations.
- `src/Novelly.AppHost/` — .NET Aspire orchestration; run this to bring up API + web client - `src/Novelly.AppHost/` — .NET Aspire orchestration; run this to bring up API + web client
+3 -3
View File
@@ -1,14 +1,14 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Agent; namespace Novelly.Api.Agent;
public class AgentConversation public class AgentConversation
{ {
public Guid Id { get; init; } = Guid.NewGuid(); public Guid Id { get; init; } = Guid.NewGuid();
public Guid ProjectId { get; init; } public Guid NovelId { get; init; }
public Project? Project { get; init; } public Novel? Novel { get; init; }
public string Title { get; init; } = "New conversation"; public string Title { get; init; } = "New conversation";
+8 -8
View File
@@ -7,22 +7,22 @@ public static class AgentEndpoints
{ {
public static IEndpointRouteBuilder MapAgentEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapAgentEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/agent").WithTags("Agent") var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/agent").WithTags("Agent")
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/conversations", async ( novelScoped.MapGet("/conversations", async (
Guid projectId, NovelAgentService agent, CancellationToken ct) => Guid novelId, NovelAgentService agent, CancellationToken ct) =>
Results.Ok(await agent.ListConversationsAsync(projectId, ct))) Results.Ok(await agent.ListConversationsAsync(novelId, ct)))
.WithSummary("List the project's agent conversations."); .WithSummary("List the novel's agent conversations.");
projectScoped.MapPost("/messages", async ( novelScoped.MapPost("/messages", async (
Guid projectId, Guid novelId,
SendAgentMessageRequest request, SendAgentMessageRequest request,
NovelAgentService agent, NovelAgentService agent,
CancellationToken ct) => CancellationToken ct) =>
{ {
var reply = await agent.SendMessageAsync(projectId, request, ct); var reply = await agent.SendMessageAsync(novelId, request, ct);
return reply is null return reply is null
? Results.NotFound() ? Results.NotFound()
: Results.Ok(new AgentTurnResponse(reply.ConversationId, reply.ToResponse())); : Results.Ok(new AgentTurnResponse(reply.ConversationId, reply.ToResponse()));
+3 -3
View File
@@ -4,9 +4,9 @@ using Novelly.Api.Common.Validation;
namespace Novelly.Api.Agent; namespace Novelly.Api.Agent;
public record ConversationSummaryResponse(Guid Id, Guid ProjectId, string Title, int MessageCount, DateTimeOffset UpdatedAt); public record ConversationSummaryResponse(Guid Id, Guid NovelId, string Title, int MessageCount, DateTimeOffset UpdatedAt);
public record ConversationResponse(Guid Id, Guid ProjectId, string Title, IReadOnlyList<AgentMessageResponse> Messages, DateTimeOffset UpdatedAt); public record ConversationResponse(Guid Id, Guid NovelId, string Title, IReadOnlyList<AgentMessageResponse> Messages, DateTimeOffset UpdatedAt);
public record AgentMessageResponse(Guid Id, AgentRole Role, string Content, IReadOnlyList<ToolCallResponse> ToolCalls, DateTimeOffset CreatedAt); public record AgentMessageResponse(Guid Id, AgentRole Role, string Content, IReadOnlyList<ToolCallResponse> ToolCalls, DateTimeOffset CreatedAt);
@@ -46,7 +46,7 @@ public static class AgentMapping
public static ConversationResponse ToResponse(this AgentConversation conversation) => new( public static ConversationResponse ToResponse(this AgentConversation conversation) => new(
conversation.Id, conversation.Id,
conversation.ProjectId, conversation.NovelId,
conversation.Title, conversation.Title,
[.. conversation.Messages.OrderBy(m => m.Sequence).Select(m => m.ToResponse())], [.. conversation.Messages.OrderBy(m => m.Sequence).Select(m => m.ToResponse())],
conversation.UpdatedAt); conversation.UpdatedAt);
+31 -31
View File
@@ -6,7 +6,7 @@ using Microsoft.Extensions.Options;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Agent; namespace Novelly.Api.Agent;
@@ -26,14 +26,14 @@ public class NovelAgentService(
private readonly AgentOptions _options = options.Value; private readonly AgentOptions _options = options.Value;
public async Task<IReadOnlyList<ConversationSummaryResponse>> ListConversationsAsync( public async Task<IReadOnlyList<ConversationSummaryResponse>> ListConversationsAsync(
Guid projectId, CancellationToken ct = default) Guid novelId, CancellationToken ct = default)
{ {
logger.LogInformation("Listing agent conversations for project {ProjectId}", projectId); logger.LogInformation("Listing agent conversations for novel {NovelId}", novelId);
return await db.Conversations return await db.Conversations
.Where(c => c.ProjectId == projectId) .Where(c => c.NovelId == novelId)
.OrderByDescending(c => c.UpdatedAt) .OrderByDescending(c => c.UpdatedAt)
.Select(c => new ConversationSummaryResponse(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt)) .Select(c => new ConversationSummaryResponse(c.Id, c.NovelId, c.Title, c.Messages.Count, c.UpdatedAt))
.ToListAsync(ct); .ToListAsync(ct);
} }
@@ -63,39 +63,39 @@ public class NovelAgentService(
return true; return true;
} }
public async Task<AgentMessage?> SendMessageAsync(Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default) public async Task<AgentMessage?> SendMessageAsync(Guid novelId, SendAgentMessageRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
sendMessageValidator.Validate(request).ThrowIfInvalid(logger); sendMessageValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation( logger.LogInformation(
"Sending agent message for project {ProjectId}, conversation {ConversationId}, message length {MessageLength}", "Sending agent message for novel {NovelId}, conversation {ConversationId}, message length {MessageLength}",
projectId, request.ConversationId, request.Message.Length); novelId, request.ConversationId, request.Message.Length);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct); var novel = await db.Novels.FirstOrDefaultAsync(p => p.Id == novelId, ct);
if (project is null) if (novel is null)
{ {
logger.LogWarning("Project {ProjectId} not found", projectId); logger.LogWarning("Novel {NovelId} not found", novelId);
return null; return null;
} }
var conversation = request.ConversationId is { } id var conversation = request.ConversationId is { } id
? await FindConversationAsync(id, ct) ? await FindConversationAsync(id, ct)
: StartConversation(projectId, request.Message); : StartConversation(novelId, request.Message);
if (conversation is null) return null; if (conversation is null) return null;
await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct); await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct);
var systemPrompt = BuildSystemPrompt(project); var systemPrompt = BuildSystemPrompt(novel);
var transcript = BuildTranscript(conversation); var transcript = BuildTranscript(conversation);
var toolCalls = new List<ToolCallResponse>(); var toolCalls = new List<ToolCallResponse>();
var text = new StringBuilder(); var text = new StringBuilder();
for (var iteration = 0; iteration < _options.MaxIterations; iteration++) for (var iteration = 0; iteration < _options.MaxIterations; iteration++)
{ {
logger.LogDebug("Agent iteration {Iteration} for project {ProjectId}", iteration, projectId); logger.LogDebug("Agent iteration {Iteration} for novel {NovelId}", iteration, novelId);
var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct); var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct);
@@ -113,9 +113,9 @@ public class NovelAgentService(
var results = new List<AgentContentBlock>(); var results = new List<AgentContentBlock>();
foreach (var call in requestedTools) foreach (var call in requestedTools)
{ {
var outcome = await toolset.ExecuteAsync(call.Name, projectId, call.Input, ct); var outcome = await toolset.ExecuteAsync(call.Name, novelId, call.Input, ct);
logger.Log(outcome.IsError ? LogLevel.Warning : LogLevel.Information, "Agent tool {Tool} on project {ProjectId} {Outcome}", call.Name, projectId, outcome.IsError ? "failed" : "succeeded"); logger.Log(outcome.IsError ? LogLevel.Warning : LogLevel.Information, "Agent tool {Tool} on novel {NovelId} {Outcome}", call.Name, novelId, outcome.IsError ? "failed" : "succeeded");
toolCalls.Add(new ToolCallResponse(call.Name, call.Input.ToString(), outcome.Content)); toolCalls.Add(new ToolCallResponse(call.Name, call.Input.ToString(), outcome.Content));
results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError)); results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError));
@@ -125,7 +125,7 @@ public class NovelAgentService(
if (iteration != _options.MaxIterations - 1) continue; if (iteration != _options.MaxIterations - 1) continue;
logger.LogWarning("Agent hit the {Max}-iteration ceiling on project {ProjectId}", _options.MaxIterations, projectId); logger.LogWarning("Agent hit the {Max}-iteration ceiling on novel {NovelId}", _options.MaxIterations, novelId);
text.AppendLine("_I reached my tool-call limit for this turn. Ask me to continue if there's more to do._"); text.AppendLine("_I reached my tool-call limit for this turn. Ask me to continue if there's more to do._");
} }
@@ -165,19 +165,19 @@ public class NovelAgentService(
return message; return message;
} }
private AgentConversation StartConversation(Guid projectId, string firstMessage) private AgentConversation StartConversation(Guid novelId, string firstMessage)
{ {
logger.LogDebug("Starting new agent conversation for project {ProjectId}", projectId); logger.LogDebug("Starting new agent conversation for novel {NovelId}", novelId);
var conversation = new AgentConversation var conversation = new AgentConversation
{ {
ProjectId = projectId, NovelId = novelId,
Title = Summarise(firstMessage) Title = Summarise(firstMessage)
}; };
db.Conversations.Add(conversation); db.Conversations.Add(conversation);
logger.LogDebug("Started agent conversation {ConversationId} for project {ProjectId}", conversation.Id, projectId); logger.LogDebug("Started agent conversation {ConversationId} for novel {NovelId}", conversation.Id, novelId);
return conversation; return conversation;
} }
@@ -209,25 +209,25 @@ public class NovelAgentService(
[new AgentTextBlock(m.Content)])) [new AgentTextBlock(m.Content)]))
]; ];
private static string BuildSystemPrompt(Project project) private static string BuildSystemPrompt(Novel novel)
{ {
var brief = new StringBuilder(); var brief = new StringBuilder();
brief.AppendLine($"Title: {project.Title}"); brief.AppendLine($"Title: {novel.Title}");
if (!string.IsNullOrWhiteSpace(project.Genre)) brief.AppendLine($"Genre: {project.Genre}"); if (!string.IsNullOrWhiteSpace(novel.Genre)) brief.AppendLine($"Genre: {novel.Genre}");
if (!string.IsNullOrWhiteSpace(project.Logline)) brief.AppendLine($"Logline: {project.Logline}"); if (!string.IsNullOrWhiteSpace(novel.Logline)) brief.AppendLine($"Logline: {novel.Logline}");
if (project.TargetWordCount is { } target) brief.AppendLine($"Target length: {target:N0} words"); if (novel.TargetWordCount is { } target) brief.AppendLine($"Target length: {target:N0} words");
return $""" return $"""
You are a developmental editor and writing partner embedded in the software the 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 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 (beats) and each novel's real data: the brief, character dossiers, the outline (beats) and each
chapter's drafted prose. chapter's drafted prose.
The project you are working on: The novel you are working on:
{brief} {brief}
Working principles: Working principles:
- Read before you write. Call get_project_brief, get_outline, or list_characters - Read before you write. Call get_novel_brief, get_outline, or list_characters
to ground yourself rather than assuming what is already there. 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 - 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. character wants, what the ending costs them — instead of deciding for them.
@@ -239,7 +239,7 @@ public class NovelAgentService(
genuinely in tension, what the outline is missing — over line-level polish, genuinely in tension, what the outline is missing — over line-level polish,
unless the writer asks for prose. unless the writer asks for prose.
- When drafting a chapter's prose, match the voice already established in the - When drafting a chapter's prose, match the voice already established in the
project. Write the chapter, then stop; do not append notes about your choices. novel. Write the chapter, then stop; do not append notes about your choices.
- Destructive operations (deleting outline nodes) need the writer's explicit - Destructive operations (deleting outline nodes) need the writer's explicit
go-ahead first. go-ahead first.
+32 -32
View File
@@ -3,7 +3,7 @@ using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Projects; using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
@@ -23,7 +23,7 @@ public record AgentTool(
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler); Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler);
public class NovelAgentToolset( public class NovelAgentToolset(
ProjectService projects, NovelService novels,
CharacterService characters, CharacterService characters,
CharacterArcService arcs, CharacterArcService arcs,
ChapterService chapters, ChapterService chapters,
@@ -45,7 +45,7 @@ public class NovelAgentToolset(
public IReadOnlyList<AgentToolDefinition> Definitions => public IReadOnlyList<AgentToolDefinition> Definitions =>
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))]; [.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
public async Task<AgentToolResult> ExecuteAsync(string name, Guid projectId, JsonElement input, CancellationToken ct = default) public async Task<AgentToolResult> ExecuteAsync(string name, Guid novelId, JsonElement input, CancellationToken ct = default)
{ {
if (!ByName.TryGetValue(name, out var tool)) if (!ByName.TryGetValue(name, out var tool))
{ {
@@ -53,29 +53,29 @@ public class NovelAgentToolset(
return new AgentToolResult($"No such tool: '{name}'.", true); return new AgentToolResult($"No such tool: '{name}'.", true);
} }
logger.LogDebug("Running tool {Tool} for project {ProjectId}", name, projectId); logger.LogDebug("Running tool {Tool} for novel {NovelId}", name, novelId);
try try
{ {
var result = await tool.Handler(projectId, input, ct); var result = await tool.Handler(novelId, input, ct);
if (result is ToolNotFound notFound) if (result is ToolNotFound notFound)
{ {
logger.LogWarning("Tool {Tool} for project {ProjectId} found no {Entity} {EntityId}", name, projectId, notFound.Entity, notFound.Id); logger.LogWarning("Tool {Tool} for novel {NovelId} found no {Entity} {EntityId}", name, novelId, notFound.Entity, notFound.Id);
return new AgentToolResult(notFound.Message, true); return new AgentToolResult(notFound.Message, true);
} }
logger.LogDebug("Tool {Tool} for project {ProjectId} succeeded", name, projectId); logger.LogDebug("Tool {Tool} for novel {NovelId} succeeded", name, novelId);
return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false); return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false);
} }
catch (ArgumentException ex) catch (ArgumentException ex)
{ {
logger.LogWarning(ex, "Tool {Tool} for project {ProjectId} failed: invalid argument", name, projectId); logger.LogWarning(ex, "Tool {Tool} for novel {NovelId} failed: invalid argument", name, novelId);
return new AgentToolResult(ex.Message, true); return new AgentToolResult(ex.Message, true);
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)
{ {
logger.LogWarning(ex, "Tool {Tool} for project {ProjectId} failed: invalid operation", name, projectId); logger.LogWarning(ex, "Tool {Tool} for novel {NovelId} failed: invalid operation", name, novelId);
return new AgentToolResult(ex.Message, true); return new AgentToolResult(ex.Message, true);
} }
} }
@@ -95,15 +95,15 @@ public class NovelAgentToolset(
private IEnumerable<AgentTool> Build() private IEnumerable<AgentTool> Build()
{ {
yield return new AgentTool( yield return new AgentTool(
"get_project_brief", "get_novel_brief",
"Read the project's title, logline, synopsis, genre, notes and word-count target. " "Read the novel's title, logline, synopsis, genre, notes and word-count target. "
+ "Call this first in a conversation to ground yourself in what the book is.", + "Call this first in a conversation to ground yourself in what the book is.",
new JsonSchemaBuilder().Build(), new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), p => p.ToResponse(null), "Project", projectId)); async (novelId, _, ct) => await OrNotFound(novels.GetAsync(novelId, ct), p => p.ToResponse(null), "Novel", novelId));
yield return new AgentTool( yield return new AgentTool(
"update_project_brief", "update_novel_brief",
"Revise the project's top-level fields. Only the fields you supply change; " "Revise the novel's top-level fields. Only the fields you supply change; "
+ "pass an empty string to clear a field.", + "pass an empty string to clear a field.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("title", "New title.") .Str("title", "New title.")
@@ -114,27 +114,27 @@ public class NovelAgentToolset(
.Str("notes", "Free-form notes on theme, tone, comparable titles.") .Str("notes", "Free-form notes on theme, tone, comparable titles.")
.Int("target_word_count", "Target manuscript length in words.") .Int("target_word_count", "Target manuscript length in words.")
.Build(), .Build(),
async (projectId, input, ct) => await OrNotFound(projects.UpdateAsync(projectId, new UpdateProjectRequest( async (novelId, input, ct) => await OrNotFound(novels.UpdateAsync(novelId, new UpdateNovelRequest(
JsonInput.String(input, "title"), JsonInput.String(input, "title"),
JsonInput.String(input, "author"), JsonInput.String(input, "author"),
JsonInput.String(input, "genre"), JsonInput.String(input, "genre"),
JsonInput.String(input, "logline"), JsonInput.String(input, "logline"),
JsonInput.String(input, "synopsis"), JsonInput.String(input, "synopsis"),
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(null), "Project", projectId)); JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(null), "Novel", novelId));
yield return new AgentTool( yield return new AgentTool(
"list_characters", "list_characters",
"List every character in the project with their full dossiers.", "List every character in the novel with their full dossiers.",
new JsonSchemaBuilder().Build(), new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => (await characters.ListAsync(projectId, ct)).Select(c => c.ToResponse())); async (novelId, _, ct) => (await characters.ListAsync(novelId, ct)).Select(c => c.ToResponse()));
yield return new AgentTool( yield return new AgentTool(
"create_character", "create_character",
"Add a character dossier. Name is the only requirement — leave fields blank when " "Add a character dossier. Name is the only requirement — leave fields blank when "
+ "the writer has not decided them yet rather than inventing detail.", + "the writer has not decided them yet rather than inventing detail.",
CharacterSchema(includeName: true, nameRequired: true).Build(), CharacterSchema(includeName: true, nameRequired: true).Build(),
async (projectId, input, ct) => await OrNotFound(characters.CreateAsync(projectId, new CreateCharacterRequest( async (novelId, input, ct) => await OrNotFound(characters.CreateAsync(novelId, new CreateCharacterRequest(
JsonInput.RequiredString(input, "name"), JsonInput.RequiredString(input, "name"),
JsonInput.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting, JsonInput.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting,
JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting, JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
@@ -152,7 +152,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "voice"), JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags"), JsonInput.Strings(input, "tags"),
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Project", projectId)); JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Novel", novelId));
yield return new AgentTool( yield return new AgentTool(
"update_character", "update_character",
@@ -189,7 +189,7 @@ public class NovelAgentToolset(
yield return new AgentTool( yield return new AgentTool(
"link_character_identity", "link_character_identity",
"Record that a character is really another character — e.g. one introduced under one name " "Record that a character is really another character — e.g. one introduced under one name "
+ "who is later revealed to be a character already in the project under another name. Both " + "who is later revealed to be a character already in the novel under another name. Both "
+ "keep their own dossier and beats; the canonical identity is whichever character you link to.", + "keep their own dossier and beats; the canonical identity is whichever character you link to.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("character_id", "Id of the character being revealed as someone else.", required: true) .Str("character_id", "Id of the character being revealed as someone else.", required: true)
@@ -348,10 +348,10 @@ public class NovelAgentToolset(
yield return new AgentTool( yield return new AgentTool(
"list_tags", "list_tags",
"List the project's tags with how many characters, chapters and beats carry each. " "List the novel'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.", + "Read this before inventing a new tag so you reuse the writer's vocabulary.",
new JsonSchemaBuilder().Build(), new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await tags.ListAsync(projectId, ct)); async (novelId, _, ct) => await tags.ListAsync(novelId, ct));
yield return new AgentTool( yield return new AgentTool(
"get_tag_references", "get_tag_references",
@@ -368,9 +368,9 @@ public class NovelAgentToolset(
yield return new AgentTool( yield return new AgentTool(
"list_chapters", "list_chapters",
"List the project's chapters in manuscript order with beat and word counts.", "List the novel's chapters in manuscript order with beat and word counts.",
new JsonSchemaBuilder().Build(), new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => (await chapters.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse())); async (novelId, _, ct) => (await chapters.ListAsync(novelId, ct)).Select(c => c.ToSummaryResponse()));
yield return new AgentTool( yield return new AgentTool(
"get_chapter", "get_chapter",
@@ -398,7 +398,7 @@ public class NovelAgentToolset(
.Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.") .Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.") .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(), .Build(),
async (projectId, input, ct) => await OrNotFound(chapters.CreateAsync(projectId, new CreateChapterRequest( async (novelId, input, ct) => await OrNotFound(chapters.CreateAsync(novelId, new CreateChapterRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"), JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"), JsonInput.String(input, "summary"),
@@ -407,7 +407,7 @@ public class NovelAgentToolset(
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned, JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
JsonInput.Int(input, "target_word_count"), JsonInput.Int(input, "target_word_count"),
JsonInput.String(input, "prose"), JsonInput.String(input, "prose"),
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Project", projectId)); JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Novel", novelId));
yield return new AgentTool( yield return new AgentTool(
"update_chapter", "update_chapter",
@@ -548,8 +548,8 @@ public class NovelAgentToolset(
.Str("character_id", "Narrow to questions about one character.") .Str("character_id", "Narrow to questions about one character.")
.Bool("include_resolved", "Include questions already settled. Defaults to false.") .Bool("include_resolved", "Include questions already settled. Defaults to false.")
.Build(), .Build(),
async (projectId, input, ct) => (await questions.ListAsync( async (novelId, input, ct) => (await questions.ListAsync(
projectId, novelId,
JsonInput.Guid(input, "chapter_id"), JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id"), JsonInput.Guid(input, "character_id"),
JsonInput.Bool(input, "include_resolved") ?? false, JsonInput.Bool(input, "include_resolved") ?? false,
@@ -566,13 +566,13 @@ public class NovelAgentToolset(
.Str("chapter_id", "The chapter outline this is about, if any.") .Str("chapter_id", "The chapter outline this is about, if any.")
.Str("character_id", "The character this is about, if any.") .Str("character_id", "The character this is about, if any.")
.Build(), .Build(),
async (projectId, input, ct) => await OrNotFound(questions.CreateAsync( async (novelId, input, ct) => await OrNotFound(questions.CreateAsync(
projectId, novelId,
new CreateOpenQuestionRequest( new CreateOpenQuestionRequest(
JsonInput.RequiredString(input, "question"), JsonInput.RequiredString(input, "question"),
JsonInput.String(input, "detail"), JsonInput.String(input, "detail"),
JsonInput.Guid(input, "chapter_id"), JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id")), ct), q => q.ToResponse(), "Project", projectId)); JsonInput.Guid(input, "character_id")), ct), q => q.ToResponse(), "Novel", novelId));
yield return new AgentTool( yield return new AgentTool(
"resolve_open_question", "resolve_open_question",
+32 -32
View File
@@ -11,7 +11,7 @@ namespace Novelly.Api.Beats;
public class BeatService( public class BeatService(
INovelDbContext db, INovelDbContext db,
ProjectAccessService access, NovelAccessService access,
TagService tags, TagService tags,
ILogger<BeatService> logger, ILogger<BeatService> logger,
IModelValidator<CreateBeatRequest> createValidator, IModelValidator<CreateBeatRequest> createValidator,
@@ -26,7 +26,7 @@ public class BeatService(
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId); logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
await RequireChapterAccessAsync(chapterId, ProjectPermission.Read, ct); await RequireChapterAccessAsync(chapterId, NovelPermission.Read, ct);
return await Query() return await Query()
.Where(b => b.ChapterId == chapterId) .Where(b => b.ChapterId == chapterId)
@@ -46,7 +46,7 @@ public class BeatService(
return null; return null;
} }
await RequireBeatAccessAsync(beat, ProjectPermission.Read, ct); await RequireBeatAccessAsync(beat, NovelPermission.Read, ct);
return beat; return beat;
} }
@@ -57,14 +57,14 @@ public class BeatService(
logger.LogInformation("Listing beats for character {CharacterId}", characterId); logger.LogInformation("Listing beats for character {CharacterId}", characterId);
var characterProjectId = await db.Characters.Where(c => c.Id == characterId).Select(c => (Guid?)c.ProjectId).FirstOrDefaultAsync(ct); var characterNovelId = await db.Characters.Where(c => c.Id == characterId).Select(c => (Guid?)c.NovelId).FirstOrDefaultAsync(ct);
if (characterProjectId is null) if (characterNovelId is null)
{ {
logger.LogWarning("Character {CharacterId} not found", characterId); logger.LogWarning("Character {CharacterId} not found", characterId);
return null; return null;
} }
await access.RequireAsync(characterProjectId.Value, ProjectPermission.Read, ct); await access.RequireAsync(characterNovelId.Value, NovelPermission.Read, ct);
var beats = await db.Beats var beats = await db.Beats
.Include(b => b.Chapter) .Include(b => b.Chapter)
@@ -95,7 +95,7 @@ public class BeatService(
return null; return null;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.CreateContent, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.CreateContent, ct);
var beat = new Beat var beat = new Beat
{ {
@@ -108,12 +108,12 @@ public class BeatService(
if (request.CharacterIds is { } characterIds) if (request.CharacterIds is { } characterIds)
{ {
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct); beat.Characters = await ResolveCharactersAsync(chapter.NovelId, characterIds, ct);
} }
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct); beat.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
} }
chapter.UpdatedAt = DateTimeOffset.UtcNow; chapter.UpdatedAt = DateTimeOffset.UtcNow;
@@ -145,7 +145,7 @@ public class BeatService(
return null; return null;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title; beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
beat.SortOrder = request.SortOrder ?? beat.SortOrder; beat.SortOrder = request.SortOrder ?? beat.SortOrder;
@@ -156,12 +156,12 @@ public class BeatService(
if (request.CharacterIds is { } characterIds) if (request.CharacterIds is { } characterIds)
{ {
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct); beat.Characters = await ResolveCharactersAsync(chapter.NovelId, characterIds, ct);
} }
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct); beat.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
} }
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -180,7 +180,7 @@ public class BeatService(
return false; return false;
} }
await RequireBeatAccessAsync(beat, ProjectPermission.DeleteContent, ct); await RequireBeatAccessAsync(beat, NovelPermission.DeleteContent, ct);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct); var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
if (chapter is not null) chapter.UpdatedAt = DateTimeOffset.UtcNow; if (chapter is not null) chapter.UpdatedAt = DateTimeOffset.UtcNow;
@@ -199,7 +199,7 @@ public class BeatService(
logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId); logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId);
await RequireChapterAccessAsync(chapterId, ProjectPermission.Write, ct); await RequireChapterAccessAsync(chapterId, NovelPermission.Write, ct);
var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct); var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct);
@@ -246,15 +246,15 @@ public class BeatService(
return null; return null;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
var character = await db.Characters var character = await db.Characters
.FirstOrDefaultAsync(c => c.Id == request.CharacterId && c.ProjectId == chapter.ProjectId, ct); .FirstOrDefaultAsync(c => c.Id == request.CharacterId && c.NovelId == chapter.NovelId, ct);
if (character is null) if (character is null)
{ {
logger.LogWarning( logger.LogWarning(
"Rejected character assignment: character {CharacterId} not found in project {ProjectId}", "Rejected character assignment: character {CharacterId} not found in novel {NovelId}",
request.CharacterId, chapter.ProjectId); request.CharacterId, chapter.NovelId);
return null; return null;
} }
@@ -297,16 +297,16 @@ public class BeatService(
} }
var targetChapter = await db.Chapters.FirstOrDefaultAsync( var targetChapter = await db.Chapters.FirstOrDefaultAsync(
c => c.Id == request.TargetChapterId && c.ProjectId == chapter.ProjectId, ct); c => c.Id == request.TargetChapterId && c.NovelId == chapter.NovelId, ct);
if (targetChapter is null) if (targetChapter is null)
{ {
logger.LogWarning( logger.LogWarning(
"Rejected beat move: target chapter {TargetChapterId} not found in project {ProjectId}", "Rejected beat move: target chapter {TargetChapterId} not found in novel {NovelId}",
request.TargetChapterId, chapter.ProjectId); request.TargetChapterId, chapter.NovelId);
return null; return null;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
var beats = await Query().Where(b => b.ChapterId == chapterId && request.BeatIds.Contains(b.Id)).ToListAsync(ct); var beats = await Query().Where(b => b.ChapterId == chapterId && request.BeatIds.Contains(b.Id)).ToListAsync(ct);
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList(); var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
@@ -338,9 +338,9 @@ public class BeatService(
return beats; return beats;
} }
private async Task<List<Character>> ResolveCharactersAsync(Guid projectId, IReadOnlyList<Guid> characterIds, CancellationToken ct) private async Task<List<Character>> ResolveCharactersAsync(Guid novelId, IReadOnlyList<Guid> characterIds, CancellationToken ct)
{ {
logger.LogDebug("Resolving {Count} characters for project {ProjectId}", characterIds.Count, projectId); logger.LogDebug("Resolving {Count} characters for novel {NovelId}", characterIds.Count, novelId);
var distinct = characterIds.Distinct().ToList(); var distinct = characterIds.Distinct().ToList();
if (distinct.Count == 0) if (distinct.Count == 0)
@@ -349,17 +349,17 @@ public class BeatService(
} }
var found = await db.Characters var found = await db.Characters
.Where(c => c.ProjectId == projectId && distinct.Contains(c.Id)) .Where(c => c.NovelId == novelId && distinct.Contains(c.Id))
.ToListAsync(ct); .ToListAsync(ct);
if (found.Count != distinct.Count) if (found.Count != distinct.Count)
{ {
logger.LogWarning("Rejected beat reference: one or more characters do not belong to project {ProjectId}", projectId); logger.LogWarning("Rejected beat reference: one or more characters do not belong to novel {NovelId}", novelId);
throw new InvalidOperationException( throw new InvalidOperationException(
"A beat's characters must belong to the same project as its chapter."); "A beat's characters must belong to the same novel as its chapter.");
} }
logger.LogDebug("Resolved {Count} characters for project {ProjectId}", found.Count, projectId); logger.LogDebug("Resolved {Count} characters for novel {NovelId}", found.Count, novelId);
return found; return found;
} }
@@ -376,13 +376,13 @@ public class BeatService(
return next; return next;
} }
private async Task RequireChapterAccessAsync(Guid chapterId, ProjectPermission permission, CancellationToken ct) private async Task RequireChapterAccessAsync(Guid chapterId, NovelPermission permission, CancellationToken ct)
{ {
var projectId = await db.Chapters.Where(c => c.Id == chapterId).Select(c => c.ProjectId).FirstOrDefaultAsync(ct); var novelId = await db.Chapters.Where(c => c.Id == chapterId).Select(c => c.NovelId).FirstOrDefaultAsync(ct);
await access.RequireAsync(projectId, permission, ct); await access.RequireAsync(novelId, permission, ct);
} }
private Task RequireBeatAccessAsync(Beat beat, ProjectPermission permission, CancellationToken ct) => private Task RequireBeatAccessAsync(Beat beat, NovelPermission permission, CancellationToken ct) =>
RequireChapterAccessAsync(beat.ChapterId, permission, ct); RequireChapterAccessAsync(beat.ChapterId, permission, ct);
private IQueryable<Beat> Query() => private IQueryable<Beat> Query() =>
+4 -4
View File
@@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Projects; using Novelly.Api.Novels;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Chapters; namespace Novelly.Api.Chapters;
@@ -10,8 +10,8 @@ namespace Novelly.Api.Chapters;
public class Chapter public class Chapter
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; } public Guid NovelId { get; set; }
public Project? Project { get; set; } public Novel? Novel { get; set; }
public int Number { get; set; } public int Number { get; set; }
@@ -43,6 +43,6 @@ public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter>
{ {
entity.Property(c => c.Title).IsRequired().HasMaxLength(300); entity.Property(c => c.Title).IsRequired().HasMaxLength(300);
entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32); entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(c => new { c.ProjectId, c.Number }); entity.HasIndex(c => new { c.NovelId, c.Number });
} }
} }
+4 -4
View File
@@ -7,7 +7,7 @@ namespace Novelly.Api.Chapters;
public record ChapterSummaryResponse( public record ChapterSummaryResponse(
Guid Id, Guid Id,
Guid ProjectId, Guid NovelId,
int Number, int Number,
string Title, string Title,
string? Summary, string? Summary,
@@ -21,7 +21,7 @@ public record ChapterSummaryResponse(
public record ChapterResponse( public record ChapterResponse(
Guid Id, Guid Id,
Guid ProjectId, Guid NovelId,
int Number, int Number,
string Title, string Title,
string? Summary, string? Summary,
@@ -115,7 +115,7 @@ file static class ChapterValidation
public static class ChapterMapping public static class ChapterMapping
{ {
public static ChapterResponse ToResponse(this Chapter c) => new( public static ChapterResponse ToResponse(this Chapter c) => new(
c.Id, c.ProjectId, c.Number, c.Title, c.Summary, c.Id, c.NovelId, c.Number, c.Title, c.Summary,
c.Setting, c.Notes, c.Setting, c.Notes,
c.Status, c.TargetWordCount, c.Status, c.TargetWordCount,
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())], [.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
@@ -124,7 +124,7 @@ public static class ChapterMapping
c.UpdatedAt); c.UpdatedAt);
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new( public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
c.Id, c.ProjectId, c.Number, c.Title, c.Summary, c.Id, c.NovelId, c.Number, c.Title, c.Summary,
c.Setting, c.Status, c.TargetWordCount, c.Setting, c.Status, c.TargetWordCount,
c.Beats.Count, c.WordCount, c.Beats.Count, c.WordCount,
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
+7 -7
View File
@@ -7,18 +7,18 @@ public static class ChapterEndpoints
{ {
public static IEndpointRouteBuilder MapChapterEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapChapterEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/chapters").WithTags("Chapters") var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/chapters").WithTags("Chapters")
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) => novelScoped.MapGet("/", async (Guid novelId, ChapterService service, CancellationToken ct) =>
Results.Ok((await service.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse()))) Results.Ok((await service.ListAsync(novelId, ct)).Select(c => c.ToSummaryResponse())))
.WithSummary("List a project's chapters in manuscript order."); .WithSummary("List a novel's chapters in manuscript order.");
projectScoped.MapPost("/", async ( novelScoped.MapPost("/", async (
Guid projectId, CreateChapterRequest request, ChapterService service, CancellationToken ct) => Guid novelId, CreateChapterRequest request, ChapterService service, CancellationToken ct) =>
{ {
var chapter = await service.CreateAsync(projectId, request, ct); var chapter = await service.CreateAsync(novelId, request, ct);
if (chapter is null) if (chapter is null)
{ {
return Results.NotFound(); return Results.NotFound();
+23 -23
View File
@@ -9,24 +9,24 @@ namespace Novelly.Api.Chapters;
public class ChapterService( public class ChapterService(
INovelDbContext db, INovelDbContext db,
ProjectAccessService access, NovelAccessService access,
TagService tags, TagService tags,
ILogger<ChapterService> logger, ILogger<ChapterService> logger,
IModelValidator<CreateChapterRequest> createValidator, IModelValidator<CreateChapterRequest> createValidator,
IModelValidator<UpdateChapterRequest> updateValidator) IModelValidator<UpdateChapterRequest> updateValidator)
{ {
public async Task<IReadOnlyList<Chapter>> ListAsync(Guid projectId, CancellationToken ct = default) public async Task<IReadOnlyList<Chapter>> ListAsync(Guid novelId, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Listing chapters for project {ProjectId}", projectId); logger.LogInformation("Listing chapters for novel {NovelId}", novelId);
await access.RequireAsync(projectId, ProjectPermission.Read, ct); await access.RequireAsync(novelId, NovelPermission.Read, ct);
return await db.Chapters return await db.Chapters
.Include(c => c.Beats) .Include(c => c.Beats)
.Include(c => c.Tags) .Include(c => c.Tags)
.Where(c => c.ProjectId == projectId) .Where(c => c.NovelId == novelId)
.OrderBy(c => c.Number) .OrderBy(c => c.Number)
.ToListAsync(ct); .ToListAsync(ct);
} }
@@ -43,31 +43,31 @@ public class ChapterService(
return null; return null;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Read, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.Read, ct);
return chapter; return chapter;
} }
public async Task<Chapter?> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default) public async Task<Chapter?> CreateAsync(Guid novelId, CreateChapterRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger); createValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Creating chapter {Title} for project {ProjectId}", request.Title, projectId); logger.LogInformation("Creating chapter {Title} for novel {NovelId}", request.Title, novelId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{ {
logger.LogWarning("Rejected chapter creation: project {ProjectId} not found", projectId); logger.LogWarning("Rejected chapter creation: novel {NovelId} not found", novelId);
return null; return null;
} }
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct); await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
var chapter = new Chapter var chapter = new Chapter
{ {
ProjectId = projectId, NovelId = novelId,
Title = request.Title, Title = request.Title,
Number = request.Number ?? await NextChapterNumberAsync(projectId, ct), Number = request.Number ?? await NextChapterNumberAsync(novelId, ct),
Summary = request.Summary, Summary = request.Summary,
Setting = request.Setting, Setting = request.Setting,
Notes = request.Notes, Notes = request.Notes,
@@ -79,7 +79,7 @@ public class ChapterService(
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
chapter.Tags = await tags.ResolveAsync(projectId, names, ct); chapter.Tags = await tags.ResolveAsync(novelId, names, ct);
} }
db.Chapters.Add(chapter); db.Chapters.Add(chapter);
@@ -102,7 +102,7 @@ public class ChapterService(
return null; return null;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title; chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
chapter.Number = request.Number ?? chapter.Number; chapter.Number = request.Number ?? chapter.Number;
@@ -122,7 +122,7 @@ public class ChapterService(
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
chapter.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct); chapter.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
} }
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -141,23 +141,23 @@ public class ChapterService(
return false; return false;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.DeleteContent, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct);
db.Chapters.Remove(chapter); db.Chapters.Remove(chapter);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true; return true;
} }
private async Task<int> NextChapterNumberAsync(Guid projectId, CancellationToken ct) private async Task<int> NextChapterNumberAsync(Guid novelId, CancellationToken ct)
{ {
logger.LogDebug("Computing next chapter number for project {ProjectId}", projectId); logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId);
var max = await db.Chapters var max = await db.Chapters
.Where(c => c.ProjectId == projectId) .Where(c => c.NovelId == novelId)
.MaxAsync(c => (int?)c.Number, ct); .MaxAsync(c => (int?)c.Number, ct);
var next = (max ?? 0) + 1; var next = (max ?? 0) + 1;
logger.LogDebug("Next chapter number for project {ProjectId} is {Number}", projectId, next); logger.LogDebug("Next chapter number for novel {NovelId} is {Number}", novelId, next);
return next; return next;
} }
+4 -4
View File
@@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Projects; using Novelly.Api.Novels;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Characters; namespace Novelly.Api.Characters;
@@ -10,8 +10,8 @@ namespace Novelly.Api.Characters;
public class Character public class Character
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; } public Guid NovelId { get; set; }
public Project? Project { get; set; } public Novel? Novel { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public CharacterRole Role { get; set; } = CharacterRole.Supporting; public CharacterRole Role { get; set; } = CharacterRole.Supporting;
@@ -82,7 +82,7 @@ public class CharacterEntityTypeConfiguration : IEntityTypeConfiguration<Charact
entity.Property(c => c.Name).IsRequired().HasMaxLength(200); entity.Property(c => c.Name).IsRequired().HasMaxLength(200);
entity.Property(c => c.Role).HasConversion<string>().HasMaxLength(32); entity.Property(c => c.Role).HasConversion<string>().HasMaxLength(32);
entity.Property(c => c.Importance).HasConversion<string>().HasMaxLength(32); entity.Property(c => c.Importance).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(c => c.ProjectId); entity.HasIndex(c => c.NovelId);
entity.HasIndex(c => c.SameCharacterAsId); entity.HasIndex(c => c.SameCharacterAsId);
entity.HasMany(c => c.Relationships).WithOne(r => r.Character!) entity.HasMany(c => c.Relationships).WithOne(r => r.Character!)
@@ -8,7 +8,7 @@ namespace Novelly.Api.Characters;
public class CharacterArcService( public class CharacterArcService(
INovelDbContext db, INovelDbContext db,
ProjectAccessService access, NovelAccessService access,
ILogger<CharacterArcService> logger, ILogger<CharacterArcService> logger,
IModelValidator<CreateArcStageRequest> createValidator, IModelValidator<CreateArcStageRequest> createValidator,
IModelValidator<UpdateArcStageRequest> updateValidator, IModelValidator<UpdateArcStageRequest> updateValidator,
@@ -21,7 +21,7 @@ public class CharacterArcService(
logger.LogInformation("Listing arc stages for character {CharacterId}", characterId); logger.LogInformation("Listing arc stages for character {CharacterId}", characterId);
await RequireCharacterAccessAsync(characterId, ProjectPermission.Read, ct); await RequireCharacterAccessAsync(characterId, NovelPermission.Read, ct);
var stages = await Query() var stages = await Query()
.Where(s => s.CharacterId == characterId) .Where(s => s.CharacterId == characterId)
@@ -43,7 +43,7 @@ public class CharacterArcService(
return null; return null;
} }
await RequireCharacterAccessAsync(stage.CharacterId, ProjectPermission.Read, ct); await RequireCharacterAccessAsync(stage.CharacterId, NovelPermission.Read, ct);
return stage; return stage;
} }
@@ -63,8 +63,8 @@ public class CharacterArcService(
return null; return null;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.CreateContent, ct); await access.RequireAsync(character.NovelId, NovelPermission.CreateContent, ct);
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct); await EnsureChapterIsInSameNovelAsync(character, request.ChapterId, ct);
var stage = new CharacterArcStage var stage = new CharacterArcStage
{ {
@@ -103,8 +103,8 @@ public class CharacterArcService(
return null; return null;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct); await EnsureChapterIsInSameNovelAsync(character, request.ChapterId, ct);
stage.Title = Patch.Apply(stage.Title, request.Title) ?? stage.Title; stage.Title = Patch.Apply(stage.Title, request.Title) ?? stage.Title;
stage.SortOrder = request.SortOrder ?? stage.SortOrder; stage.SortOrder = request.SortOrder ?? stage.SortOrder;
@@ -128,7 +128,7 @@ public class CharacterArcService(
return false; return false;
} }
await RequireCharacterAccessAsync(stage.CharacterId, ProjectPermission.DeleteContent, ct); await RequireCharacterAccessAsync(stage.CharacterId, NovelPermission.DeleteContent, ct);
db.CharacterArcStages.Remove(stage); db.CharacterArcStages.Remove(stage);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -144,7 +144,7 @@ public class CharacterArcService(
logger.LogInformation("Reordering {Count} arc stages for character {CharacterId}", request.StageIds.Count, characterId); logger.LogInformation("Reordering {Count} arc stages for character {CharacterId}", request.StageIds.Count, characterId);
await RequireCharacterAccessAsync(characterId, ProjectPermission.Write, ct); await RequireCharacterAccessAsync(characterId, NovelPermission.Write, ct);
var stages = await db.CharacterArcStages var stages = await db.CharacterArcStages
.Where(s => s.CharacterId == characterId) .Where(s => s.CharacterId == characterId)
@@ -194,7 +194,7 @@ public class CharacterArcService(
return null; return null;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
var beats = await db.Beats var beats = await db.Beats
.Include(b => b.Characters) .Include(b => b.Characters)
@@ -233,7 +233,7 @@ public class CharacterArcService(
return (await FindAsync(stageId, ct))!; return (await FindAsync(stageId, ct))!;
} }
private async Task EnsureChapterIsInSameProjectAsync( private async Task EnsureChapterIsInSameNovelAsync(
Character character, Guid? chapterId, CancellationToken ct) Character character, Guid? chapterId, CancellationToken ct)
{ {
if (chapterId is not { } id) if (chapterId is not { } id)
@@ -241,18 +241,18 @@ public class CharacterArcService(
return; return;
} }
logger.LogDebug("Checking chapter {ChapterId} belongs to project {ProjectId}", id, character.ProjectId); logger.LogDebug("Checking chapter {ChapterId} belongs to novel {NovelId}", id, character.NovelId);
var belongs = await db.Chapters.AnyAsync(c => c.Id == id && c.ProjectId == character.ProjectId, ct); var belongs = await db.Chapters.AnyAsync(c => c.Id == id && c.NovelId == character.NovelId, ct);
if (!belongs) if (!belongs)
{ {
logger.LogWarning("Rejected arc stage: chapter {ChapterId} does not belong to project {ProjectId}", id, character.ProjectId); logger.LogWarning("Rejected arc stage: chapter {ChapterId} does not belong to novel {NovelId}", id, character.NovelId);
throw new InvalidOperationException( throw new InvalidOperationException(
"An arc stage can only point at a chapter in the same project as its character."); "An arc stage can only point at a chapter in the same novel as its character.");
} }
logger.LogDebug("Chapter {ChapterId} belongs to project {ProjectId}", id, character.ProjectId); logger.LogDebug("Chapter {ChapterId} belongs to novel {NovelId}", id, character.NovelId);
} }
private async Task<int> NextSortOrderAsync(Guid characterId, CancellationToken ct) private async Task<int> NextSortOrderAsync(Guid characterId, CancellationToken ct)
@@ -268,10 +268,10 @@ public class CharacterArcService(
return next; return next;
} }
private async Task RequireCharacterAccessAsync(Guid characterId, ProjectPermission permission, CancellationToken ct) private async Task RequireCharacterAccessAsync(Guid characterId, NovelPermission permission, CancellationToken ct)
{ {
var projectId = await db.Characters.Where(c => c.Id == characterId).Select(c => c.ProjectId).FirstOrDefaultAsync(ct); var novelId = await db.Characters.Where(c => c.Id == characterId).Select(c => c.NovelId).FirstOrDefaultAsync(ct);
await access.RequireAsync(projectId, permission, ct); await access.RequireAsync(novelId, permission, ct);
} }
private IQueryable<CharacterArcStage> Query() => private IQueryable<CharacterArcStage> Query() =>
@@ -6,7 +6,7 @@ namespace Novelly.Api.Characters;
public record CharacterResponse( public record CharacterResponse(
Guid Id, Guid Id,
Guid ProjectId, Guid NovelId,
string Name, string Name,
CharacterRole Role, CharacterRole Role,
CharacterImportance Importance, CharacterImportance Importance,
@@ -299,7 +299,7 @@ public class SetArcStageBeatsRequestValidator : IModelValidator<SetArcStageBeats
public static class CharacterMapping public static class CharacterMapping
{ {
public static CharacterResponse ToResponse(this Character c) => new( public static CharacterResponse ToResponse(this Character c) => new(
c.Id, c.ProjectId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation, c.Id, c.NovelId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation,
c.Appearance, c.Personality, c.Backstory, c.Want, c.Need, c.Appearance, c.Personality, c.Backstory, c.Want, c.Need,
c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes, c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes,
[.. c.Aliases], [.. c.Aliases],
@@ -7,18 +7,18 @@ public static class CharacterEndpoints
{ {
public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/characters").WithTags("Characters") var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/characters").WithTags("Characters")
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, CharacterService service, CancellationToken ct) => novelScoped.MapGet("/", async (Guid novelId, CharacterService service, CancellationToken ct) =>
Results.Ok((await service.ListAsync(projectId, ct)).Select(c => c.ToResponse()))) Results.Ok((await service.ListAsync(novelId, ct)).Select(c => c.ToResponse())))
.WithSummary("List a project's character dossiers."); .WithSummary("List a novel's character dossiers.");
projectScoped.MapPost("/", async ( novelScoped.MapPost("/", async (
Guid projectId, CreateCharacterRequest request, CharacterService service, CancellationToken ct) => Guid novelId, CreateCharacterRequest request, CharacterService service, CancellationToken ct) =>
{ {
var character = await service.CreateAsync(projectId, request, ct); var character = await service.CreateAsync(novelId, request, ct);
if (character is null) if (character is null)
{ {
return Results.NotFound(); return Results.NotFound();
@@ -49,7 +49,7 @@ public static class CharacterEndpoints
characters.MapPost("/{id:guid}/relationships", async ( characters.MapPost("/{id:guid}/relationships", async (
Guid id, CreateRelationshipRequest request, CharacterService service, CancellationToken ct) => Guid id, CreateRelationshipRequest request, CharacterService service, CancellationToken ct) =>
(await service.AddRelationshipAsync(id, request, ct))?.ToResponse().ToApiResult()) (await service.AddRelationshipAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Relate this character to another in the same project."); .WithSummary("Relate this character to another in the same novel.");
characters.MapDelete("/relationships/{relationshipId:guid}", async ( characters.MapDelete("/relationships/{relationshipId:guid}", async (
Guid relationshipId, CharacterService service, CancellationToken ct) => Guid relationshipId, CharacterService service, CancellationToken ct) =>
@@ -59,7 +59,7 @@ public static class CharacterEndpoints
characters.MapPut("/{id:guid}/identity", async ( characters.MapPut("/{id:guid}/identity", async (
Guid id, LinkCharacterIdentityRequest request, CharacterService service, CancellationToken ct) => Guid id, LinkCharacterIdentityRequest request, CharacterService service, CancellationToken ct) =>
(await service.LinkIdentityAsync(id, request, ct))?.ToResponse().ToApiResult()) (await service.LinkIdentityAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Link this character as another identity of a character in the same project."); .WithSummary("Link this character as another identity of a character in the same novel.");
characters.MapDelete("/{id:guid}/identity", async ( characters.MapDelete("/{id:guid}/identity", async (
Guid id, CharacterService service, CancellationToken ct) => Guid id, CharacterService service, CancellationToken ct) =>
+32 -32
View File
@@ -9,7 +9,7 @@ namespace Novelly.Api.Characters;
public class CharacterService( public class CharacterService(
INovelDbContext db, INovelDbContext db,
ProjectAccessService access, NovelAccessService access,
TagService tags, TagService tags,
ILogger<CharacterService> logger, ILogger<CharacterService> logger,
IModelValidator<CreateCharacterRequest> createValidator, IModelValidator<CreateCharacterRequest> createValidator,
@@ -17,16 +17,16 @@ public class CharacterService(
IModelValidator<CreateRelationshipRequest> relationshipValidator, IModelValidator<CreateRelationshipRequest> relationshipValidator,
IModelValidator<LinkCharacterIdentityRequest> identityValidator) IModelValidator<LinkCharacterIdentityRequest> identityValidator)
{ {
public async Task<IReadOnlyList<Character>> ListAsync(Guid projectId, CancellationToken ct = default) public async Task<IReadOnlyList<Character>> ListAsync(Guid novelId, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Listing characters for project {ProjectId}", projectId); logger.LogInformation("Listing characters for novel {NovelId}", novelId);
await access.RequireAsync(projectId, ProjectPermission.Read, ct); await access.RequireAsync(novelId, NovelPermission.Read, ct);
var characters = await Query() var characters = await Query()
.Where(c => c.ProjectId == projectId) .Where(c => c.NovelId == novelId)
.ToListAsync(ct); .ToListAsync(ct);
return OrderedInMemoryBySignificanceThenName(characters); return OrderedInMemoryBySignificanceThenName(characters);
@@ -52,29 +52,29 @@ public class CharacterService(
return null; return null;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.Read, ct); await access.RequireAsync(character.NovelId, NovelPermission.Read, ct);
return character; return character;
} }
public async Task<Character?> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default) public async Task<Character?> CreateAsync(Guid novelId, CreateCharacterRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger); createValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Creating character {Name} for project {ProjectId}, role {Role}, importance {Importance}", request.Name, projectId, request.Role, request.Importance); logger.LogInformation("Creating character {Name} for novel {NovelId}, role {Role}, importance {Importance}", request.Name, novelId, request.Role, request.Importance);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{ {
logger.LogWarning("Rejected character creation: project {ProjectId} not found", projectId); logger.LogWarning("Rejected character creation: novel {NovelId} not found", novelId);
return null; return null;
} }
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct); await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
var character = new Character var character = new Character
{ {
ProjectId = projectId, NovelId = novelId,
Name = request.Name, Name = request.Name,
Role = request.Role, Role = request.Role,
Importance = request.Importance, Importance = request.Importance,
@@ -95,7 +95,7 @@ public class CharacterService(
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
character.Tags = await tags.ResolveAsync(projectId, names, ct); character.Tags = await tags.ResolveAsync(novelId, names, ct);
} }
if (request.Aliases is { } aliases) if (request.Aliases is { } aliases)
@@ -123,7 +123,7 @@ public class CharacterService(
return null; return null;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name; character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name;
character.Role = request.Role ?? character.Role; character.Role = request.Role ?? character.Role;
@@ -145,7 +145,7 @@ public class CharacterService(
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
character.Tags = await tags.ResolveAsync(character.ProjectId, names, ct); character.Tags = await tags.ResolveAsync(character.NovelId, names, ct);
} }
if (request.Aliases is { } aliases) if (request.Aliases is { } aliases)
@@ -169,7 +169,7 @@ public class CharacterService(
return false; return false;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.DeleteContent, ct); await access.RequireAsync(character.NovelId, NovelPermission.DeleteContent, ct);
db.Characters.Remove(character); db.Characters.Remove(character);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -191,7 +191,7 @@ public class CharacterService(
return null; return null;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
var related = await db.Characters.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct); var related = await db.Characters.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct);
if (related is null) if (related is null)
@@ -200,10 +200,10 @@ public class CharacterService(
return null; return null;
} }
if (related.ProjectId != character.ProjectId) if (related.NovelId != character.NovelId)
{ {
logger.LogWarning("Rejected relationship: character {CharacterId} and {RelatedCharacterId} belong to different projects", characterId, request.RelatedCharacterId); logger.LogWarning("Rejected relationship: character {CharacterId} and {RelatedCharacterId} belong to different novels", characterId, request.RelatedCharacterId);
throw new InvalidOperationException("Characters must belong to the same project to be related."); throw new InvalidOperationException("Characters must belong to the same novel to be related.");
} }
db.CharacterRelationships.Add(new CharacterRelationship db.CharacterRelationships.Add(new CharacterRelationship
@@ -241,7 +241,7 @@ public class CharacterService(
return false; return false;
} }
await access.RequireAsync(relationship.Character!.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(relationship.Character!.NovelId, NovelPermission.Write, ct);
var reciprocals = await db.CharacterRelationships var reciprocals = await db.CharacterRelationships
.Where(r => r.CharacterId == relationship.RelatedCharacterId && r.RelatedCharacterId == relationship.CharacterId) .Where(r => r.CharacterId == relationship.RelatedCharacterId && r.RelatedCharacterId == relationship.CharacterId)
@@ -269,7 +269,7 @@ public class CharacterService(
return null; return null;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
if (request.SameCharacterAsId == characterId) if (request.SameCharacterAsId == characterId)
{ {
@@ -286,12 +286,12 @@ public class CharacterService(
return null; return null;
} }
if (target.ProjectId != character.ProjectId) if (target.NovelId != character.NovelId)
{ {
logger.LogWarning( logger.LogWarning(
"Rejected identity link: character {CharacterId} and {SameCharacterAsId} belong to different projects", "Rejected identity link: character {CharacterId} and {SameCharacterAsId} belong to different novels",
characterId, request.SameCharacterAsId); characterId, request.SameCharacterAsId);
throw new InvalidOperationException("Characters must belong to the same project to be linked."); throw new InvalidOperationException("Characters must belong to the same novel to be linked.");
} }
if (await db.Characters.AnyAsync(c => c.SameCharacterAsId == characterId, ct)) if (await db.Characters.AnyAsync(c => c.SameCharacterAsId == characterId, ct))
@@ -304,11 +304,11 @@ public class CharacterService(
if (request.RevealedInChapterId is { } chapterId) if (request.RevealedInChapterId is { } chapterId)
{ {
var chapterInProject = await db.Chapters.AnyAsync(c => c.Id == chapterId && c.ProjectId == character.ProjectId, ct); var chapterInNovel = await db.Chapters.AnyAsync(c => c.Id == chapterId && c.NovelId == character.NovelId, ct);
if (!chapterInProject) if (!chapterInNovel)
{ {
logger.LogWarning("Rejected identity link: chapter {ChapterId} not in project {ProjectId}", chapterId, character.ProjectId); logger.LogWarning("Rejected identity link: chapter {ChapterId} not in novel {NovelId}", chapterId, character.NovelId);
throw new InvalidOperationException("The reveal chapter must belong to the same project."); throw new InvalidOperationException("The reveal chapter must belong to the same novel.");
} }
} }
@@ -333,7 +333,7 @@ public class CharacterService(
return false; return false;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
character.SameCharacterAsId = null; character.SameCharacterAsId = null;
character.RevealedInChapterId = null; character.RevealedInChapterId = null;
@@ -13,7 +13,7 @@ using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Genres; using Novelly.Api.Genres;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Projects; using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Novelly.Api.Users; using Novelly.Api.Users;
@@ -48,7 +48,7 @@ public static class NovellyServiceRegistration
services.AddScoped<IUserClaimsPrincipalFactory<NovellyUser>, NovellyUserClaimsPrincipalFactory>(); services.AddScoped<IUserClaimsPrincipalFactory<NovellyUser>, NovellyUserClaimsPrincipalFactory>();
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddScoped<INovelUserContext, NovelUserContext>(); services.AddScoped<INovelUserContext, NovelUserContext>();
services.AddScoped<ProjectAccessService>(); services.AddScoped<NovelAccessService>();
services.ConfigureApplicationCookie(options => services.ConfigureApplicationCookie(options =>
{ {
@@ -70,9 +70,9 @@ public static class NovellyServiceRegistration
}); });
services.AddScoped<UserAccountService>(); services.AddScoped<UserAccountService>();
services.AddScoped<ProjectMemberService>(); services.AddScoped<NovelMemberService>();
services.AddScoped<ProjectService>(); services.AddScoped<NovelService>();
services.AddScoped<CharacterService>(); services.AddScoped<CharacterService>();
services.AddScoped<CharacterArcService>(); services.AddScoped<CharacterArcService>();
services.AddScoped<BeatService>(); services.AddScoped<BeatService>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,363 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class RenameProjectToNovel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Chapters_Projects_ProjectId",
table: "Chapters");
migrationBuilder.DropForeignKey(
name: "FK_Characters_Projects_ProjectId",
table: "Characters");
migrationBuilder.DropForeignKey(
name: "FK_Conversations_Projects_ProjectId",
table: "Conversations");
migrationBuilder.DropForeignKey(
name: "FK_OpenQuestions_Projects_ProjectId",
table: "OpenQuestions");
migrationBuilder.DropForeignKey(
name: "FK_Tags_Projects_ProjectId",
table: "Tags");
migrationBuilder.DropForeignKey(
name: "FK_ProjectMembers_Projects_ProjectId",
table: "ProjectMembers");
migrationBuilder.RenameTable(
name: "Projects",
newName: "Novels");
migrationBuilder.RenameTable(
name: "ProjectMembers",
newName: "NovelMembers");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "Tags",
newName: "NovelId");
migrationBuilder.RenameIndex(
name: "IX_Tags_ProjectId_Name",
table: "Tags",
newName: "IX_Tags_NovelId_Name");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "OpenQuestions",
newName: "NovelId");
migrationBuilder.RenameIndex(
name: "IX_OpenQuestions_ProjectId",
table: "OpenQuestions",
newName: "IX_OpenQuestions_NovelId");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "ImportJobs",
newName: "NovelId");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "Conversations",
newName: "NovelId");
migrationBuilder.RenameIndex(
name: "IX_Conversations_ProjectId",
table: "Conversations",
newName: "IX_Conversations_NovelId");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "Characters",
newName: "NovelId");
migrationBuilder.RenameIndex(
name: "IX_Characters_ProjectId",
table: "Characters",
newName: "IX_Characters_NovelId");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "Chapters",
newName: "NovelId");
migrationBuilder.RenameIndex(
name: "IX_Chapters_ProjectId_Number",
table: "Chapters",
newName: "IX_Chapters_NovelId_Number");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "NovelMembers",
newName: "NovelId");
migrationBuilder.RenameColumn(
name: "ProjectRole",
table: "NovelMembers",
newName: "NovelRole");
migrationBuilder.RenameIndex(
name: "IX_ProjectMembers_ProjectId_UserId",
table: "NovelMembers",
newName: "IX_NovelMembers_NovelId_UserId");
migrationBuilder.RenameIndex(
name: "IX_ProjectMembers_UserId",
table: "NovelMembers",
newName: "IX_NovelMembers_UserId");
migrationBuilder.RenameIndex(
name: "IX_Projects_OwnerId",
table: "Novels",
newName: "IX_Novels_OwnerId");
migrationBuilder.DropForeignKey(
name: "FK_ProjectMembers_AspNetUsers_UserId",
table: "NovelMembers");
migrationBuilder.AddForeignKey(
name: "FK_NovelMembers_AspNetUsers_UserId",
table: "NovelMembers",
column: "UserId",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_NovelMembers_Novels_NovelId",
table: "NovelMembers",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Chapters_Novels_NovelId",
table: "Chapters",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Characters_Novels_NovelId",
table: "Characters",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Conversations_Novels_NovelId",
table: "Conversations",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_OpenQuestions_Novels_NovelId",
table: "OpenQuestions",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Tags_Novels_NovelId",
table: "Tags",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Chapters_Novels_NovelId",
table: "Chapters");
migrationBuilder.DropForeignKey(
name: "FK_Characters_Novels_NovelId",
table: "Characters");
migrationBuilder.DropForeignKey(
name: "FK_Conversations_Novels_NovelId",
table: "Conversations");
migrationBuilder.DropForeignKey(
name: "FK_OpenQuestions_Novels_NovelId",
table: "OpenQuestions");
migrationBuilder.DropForeignKey(
name: "FK_Tags_Novels_NovelId",
table: "Tags");
migrationBuilder.DropForeignKey(
name: "FK_NovelMembers_Novels_NovelId",
table: "NovelMembers");
migrationBuilder.DropForeignKey(
name: "FK_NovelMembers_AspNetUsers_UserId",
table: "NovelMembers");
migrationBuilder.AddForeignKey(
name: "FK_ProjectMembers_AspNetUsers_UserId",
table: "NovelMembers",
column: "UserId",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.RenameIndex(
name: "IX_Novels_OwnerId",
table: "Novels",
newName: "IX_Projects_OwnerId");
migrationBuilder.RenameIndex(
name: "IX_NovelMembers_UserId",
table: "NovelMembers",
newName: "IX_ProjectMembers_UserId");
migrationBuilder.RenameIndex(
name: "IX_NovelMembers_NovelId_UserId",
table: "NovelMembers",
newName: "IX_ProjectMembers_ProjectId_UserId");
migrationBuilder.RenameColumn(
name: "NovelRole",
table: "NovelMembers",
newName: "ProjectRole");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "NovelMembers",
newName: "ProjectId");
migrationBuilder.RenameIndex(
name: "IX_Chapters_NovelId_Number",
table: "Chapters",
newName: "IX_Chapters_ProjectId_Number");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "Chapters",
newName: "ProjectId");
migrationBuilder.RenameIndex(
name: "IX_Characters_NovelId",
table: "Characters",
newName: "IX_Characters_ProjectId");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "Characters",
newName: "ProjectId");
migrationBuilder.RenameIndex(
name: "IX_Conversations_NovelId",
table: "Conversations",
newName: "IX_Conversations_ProjectId");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "Conversations",
newName: "ProjectId");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "ImportJobs",
newName: "ProjectId");
migrationBuilder.RenameIndex(
name: "IX_OpenQuestions_NovelId",
table: "OpenQuestions",
newName: "IX_OpenQuestions_ProjectId");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "OpenQuestions",
newName: "ProjectId");
migrationBuilder.RenameIndex(
name: "IX_Tags_NovelId_Name",
table: "Tags",
newName: "IX_Tags_ProjectId_Name");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "Tags",
newName: "ProjectId");
migrationBuilder.RenameTable(
name: "NovelMembers",
newName: "ProjectMembers");
migrationBuilder.RenameTable(
name: "Novels",
newName: "Projects");
migrationBuilder.AddForeignKey(
name: "FK_ProjectMembers_Projects_ProjectId",
table: "ProjectMembers",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Chapters_Projects_ProjectId",
table: "Chapters",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Characters_Projects_ProjectId",
table: "Characters",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Conversations_Projects_ProjectId",
table: "Conversations",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_OpenQuestions_Projects_ProjectId",
table: "OpenQuestions",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Tags_Projects_ProjectId",
table: "Tags",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}
@@ -163,7 +163,7 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<Guid>("ProjectId") b.Property<Guid>("NovelId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Title") b.Property<string>("Title")
@@ -176,7 +176,7 @@ namespace Novelly.Api.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ProjectId"); b.HasIndex("NovelId");
b.ToTable("Conversations"); b.ToTable("Conversations");
}); });
@@ -264,12 +264,12 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("NovelId")
.HasColumnType("TEXT");
b.Property<int>("Number") b.Property<int>("Number")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Prose") b.Property<string>("Prose")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -300,7 +300,7 @@ namespace Novelly.Api.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ProjectId", "Number"); b.HasIndex("NovelId", "Number");
b.ToTable("Chapters"); b.ToTable("Chapters");
}); });
@@ -355,15 +355,15 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("NovelId")
.HasColumnType("TEXT");
b.Property<string>("Occupation") b.Property<string>("Occupation")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Personality") b.Property<string>("Personality")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Pronouns") b.Property<string>("Pronouns")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -389,7 +389,7 @@ namespace Novelly.Api.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ProjectId"); b.HasIndex("NovelId");
b.HasIndex("RevealedInChapterId"); b.HasIndex("RevealedInChapterId");
@@ -591,7 +591,7 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<Guid?>("ProjectId") b.Property<Guid?>("NovelId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid?>("RequestedByUserId") b.Property<Guid?>("RequestedByUserId")
@@ -620,7 +620,7 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("ImportJobs"); b.ToTable("ImportJobs");
}); });
modelBuilder.Entity("Novelly.Api.Projects.Project", b => modelBuilder.Entity("Novelly.Api.Novels.Novel", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -667,7 +667,7 @@ namespace Novelly.Api.Data.Migrations
b.HasIndex("OwnerId"); b.HasIndex("OwnerId");
b.ToTable("Projects"); b.ToTable("Novels");
}); });
modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b =>
@@ -688,7 +688,7 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Detail") b.Property<string>("Detail")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("ProjectId") b.Property<Guid>("NovelId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Question") b.Property<string>("Question")
@@ -711,7 +711,7 @@ namespace Novelly.Api.Data.Migrations
b.HasIndex("CharacterId"); b.HasIndex("CharacterId");
b.HasIndex("ProjectId"); b.HasIndex("NovelId");
b.ToTable("OpenQuestions"); b.ToTable("OpenQuestions");
}); });
@@ -734,17 +734,50 @@ namespace Novelly.Api.Data.Migrations
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("ProjectId") b.Property<Guid>("NovelId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ProjectId", "Name") b.HasIndex("NovelId", "Name")
.IsUnique(); .IsUnique();
b.ToTable("Tags"); b.ToTable("Tags");
}); });
modelBuilder.Entity("Novelly.Api.Users.NovelMember", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("GrantedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("GrantedByUserId")
.HasColumnType("TEXT");
b.Property<Guid>("NovelId")
.HasColumnType("TEXT");
b.Property<string>("NovelRole")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("NovelId", "UserId")
.IsUnique();
b.ToTable("NovelMembers");
});
modelBuilder.Entity("Novelly.Api.Users.NovellyUser", b => modelBuilder.Entity("Novelly.Api.Users.NovellyUser", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -823,39 +856,6 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("AspNetUsers", (string)null); b.ToTable("AspNetUsers", (string)null);
}); });
modelBuilder.Entity("Novelly.Api.Users.ProjectMember", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("GrantedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("GrantedByUserId")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("ProjectRole")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("ProjectId", "UserId")
.IsUnique();
b.ToTable("ProjectMembers");
});
modelBuilder.Entity("BeatCharacter", b => modelBuilder.Entity("BeatCharacter", b =>
{ {
b.HasOne("Novelly.Api.Beats.Beat", null) b.HasOne("Novelly.Api.Beats.Beat", null)
@@ -960,13 +960,13 @@ namespace Novelly.Api.Data.Migrations
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{ {
b.HasOne("Novelly.Api.Projects.Project", "Project") b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany("Conversations") .WithMany("Conversations")
.HasForeignKey("ProjectId") .HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("Project"); b.Navigation("Novel");
}); });
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
@@ -993,20 +993,20 @@ namespace Novelly.Api.Data.Migrations
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
{ {
b.HasOne("Novelly.Api.Projects.Project", "Project") b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany("Chapters") .WithMany("Chapters")
.HasForeignKey("ProjectId") .HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("Project"); b.Navigation("Novel");
}); });
modelBuilder.Entity("Novelly.Api.Characters.Character", b => modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
{ {
b.HasOne("Novelly.Api.Projects.Project", "Project") b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany("Characters") .WithMany("Characters")
.HasForeignKey("ProjectId") .HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
@@ -1020,7 +1020,7 @@ namespace Novelly.Api.Data.Migrations
.HasForeignKey("SameCharacterAsId") .HasForeignKey("SameCharacterAsId")
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
b.Navigation("Project"); b.Navigation("Novel");
b.Navigation("RevealedInChapter"); b.Navigation("RevealedInChapter");
@@ -1064,7 +1064,7 @@ namespace Novelly.Api.Data.Migrations
b.Navigation("RelatedCharacter"); b.Navigation("RelatedCharacter");
}); });
modelBuilder.Entity("Novelly.Api.Projects.Project", b => modelBuilder.Entity("Novelly.Api.Novels.Novel", b =>
{ {
b.HasOne("Novelly.Api.Users.NovellyUser", "Owner") b.HasOne("Novelly.Api.Users.NovellyUser", "Owner")
.WithMany() .WithMany()
@@ -1086,9 +1086,9 @@ namespace Novelly.Api.Data.Migrations
.HasForeignKey("CharacterId") .HasForeignKey("CharacterId")
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
b.HasOne("Novelly.Api.Projects.Project", "Project") b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany() .WithMany()
.HasForeignKey("ProjectId") .HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
@@ -1096,25 +1096,25 @@ namespace Novelly.Api.Data.Migrations
b.Navigation("Character"); b.Navigation("Character");
b.Navigation("Project"); b.Navigation("Novel");
}); });
modelBuilder.Entity("Novelly.Api.Tags.Tag", b => modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
{ {
b.HasOne("Novelly.Api.Projects.Project", "Project") b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany("Tags") .WithMany("Tags")
.HasForeignKey("ProjectId") .HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("Project"); b.Navigation("Novel");
}); });
modelBuilder.Entity("Novelly.Api.Users.ProjectMember", b => modelBuilder.Entity("Novelly.Api.Users.NovelMember", b =>
{ {
b.HasOne("Novelly.Api.Projects.Project", "Project") b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany("Members") .WithMany("Members")
.HasForeignKey("ProjectId") .HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
@@ -1124,7 +1124,7 @@ namespace Novelly.Api.Data.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("Project"); b.Navigation("Novel");
b.Navigation("User"); b.Navigation("User");
}); });
@@ -1148,7 +1148,7 @@ namespace Novelly.Api.Data.Migrations
b.Navigation("Relationships"); b.Navigation("Relationships");
}); });
modelBuilder.Entity("Novelly.Api.Projects.Project", b => modelBuilder.Entity("Novelly.Api.Novels.Novel", b =>
{ {
b.Navigation("Chapters"); b.Navigation("Chapters");
+5 -5
View File
@@ -7,7 +7,7 @@ using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Genres; using Novelly.Api.Genres;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Projects; using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Novelly.Api.Users; using Novelly.Api.Users;
@@ -18,7 +18,7 @@ internal class UtcTicksConverter() : ValueConverter<DateTimeOffset, long>(value
public class NovelDbContext(DbContextOptions<NovelDbContext> options) : IdentityUserContext<NovellyUser, Guid>(options), INovelDbContext public class NovelDbContext(DbContextOptions<NovelDbContext> options) : IdentityUserContext<NovellyUser, Guid>(options), INovelDbContext
{ {
public DbSet<Project> Projects => Set<Project>(); public DbSet<Novel> Novels => Set<Novel>();
public DbSet<Character> Characters => Set<Character>(); public DbSet<Character> Characters => Set<Character>();
public DbSet<CharacterRelationship> CharacterRelationships => Set<CharacterRelationship>(); public DbSet<CharacterRelationship> CharacterRelationships => Set<CharacterRelationship>();
public DbSet<CharacterArcStage> CharacterArcStages => Set<CharacterArcStage>(); public DbSet<CharacterArcStage> CharacterArcStages => Set<CharacterArcStage>();
@@ -30,7 +30,7 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options) : Identity
public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>(); public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>();
public DbSet<ImportJob> ImportJobs => Set<ImportJob>(); public DbSet<ImportJob> ImportJobs => Set<ImportJob>();
public DbSet<Genre> Genres => Set<Genre>(); public DbSet<Genre> Genres => Set<Genre>();
public DbSet<ProjectMember> ProjectMembers => Set<ProjectMember>(); public DbSet<NovelMember> NovelMembers => Set<NovelMember>();
Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) => base.SaveChangesAsync(cancellationToken); Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) => base.SaveChangesAsync(cancellationToken);
@@ -45,7 +45,7 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options) : Identity
public interface INovelDbContext public interface INovelDbContext
{ {
DbSet<Project> Projects { get; } DbSet<Novel> Novels { get; }
DbSet<Character> Characters { get; } DbSet<Character> Characters { get; }
DbSet<CharacterRelationship> CharacterRelationships { get; } DbSet<CharacterRelationship> CharacterRelationships { get; }
DbSet<CharacterArcStage> CharacterArcStages { get; } DbSet<CharacterArcStage> CharacterArcStages { get; }
@@ -58,7 +58,7 @@ public interface INovelDbContext
DbSet<ImportJob> ImportJobs { get; } DbSet<ImportJob> ImportJobs { get; }
DbSet<Genre> Genres { get; } DbSet<Genre> Genres { get; }
DbSet<NovellyUser> Users { get; } DbSet<NovellyUser> Users { get; }
DbSet<ProjectMember> ProjectMembers { get; } DbSet<NovelMember> NovelMembers { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
} }
+13 -13
View File
@@ -3,7 +3,7 @@ using Novelly.Api.Agent;
namespace Novelly.Api.Imports; namespace Novelly.Api.Imports;
public record ImportRunResult(bool Completed, Guid? ProjectId, int ChaptersCompleted, string? Message); public record ImportRunResult(bool Completed, Guid? NovelId, int ChaptersCompleted, string? Message);
public class ImportAgentService( public class ImportAgentService(
IAgentModelClient model, IAgentModelClient model,
@@ -14,13 +14,13 @@ public class ImportAgentService(
private readonly AgentOptions _options = options.Value; private readonly AgentOptions _options = options.Value;
public async Task<ImportRunResult> RunAsync( public async Task<ImportRunResult> RunAsync(
string sourceRoot, Guid? existingProjectId, int chaptersTotal, CancellationToken ct = default) string sourceRoot, Guid? existingNovelId, int chaptersTotal, CancellationToken ct = default)
{ {
logger.LogInformation( logger.LogInformation(
"Running import for {SourceRoot}, existing project {ExistingProjectId}, {ChaptersTotal} chapters total", "Running import for {SourceRoot}, existing novel {ExistingNovelId}, {ChaptersTotal} chapters total",
sourceRoot, existingProjectId, chaptersTotal); sourceRoot, existingNovelId, chaptersTotal);
toolset.Initialize(sourceRoot, existingProjectId); toolset.Initialize(sourceRoot, existingNovelId);
var startingLedger = toolset.ReadLedgerOrNull(); var startingLedger = toolset.ReadLedgerOrNull();
var systemPrompt = BuildSystemPrompt(sourceRoot); var systemPrompt = BuildSystemPrompt(sourceRoot);
@@ -46,7 +46,7 @@ public class ImportAgentService(
logger.LogInformation("Import for {SourceRoot} completed after {Turns} turns", sourceRoot, turn + 1); logger.LogInformation("Import for {SourceRoot} completed after {Turns} turns", sourceRoot, turn + 1);
return new ImportRunResult( return new ImportRunResult(
Completed: true, Completed: true,
toolset.ProjectId, toolset.NovelId,
ledger?.CompletedChapters?.Count ?? 0, ledger?.CompletedChapters?.Count ?? 0,
null); null);
} }
@@ -60,7 +60,7 @@ public class ImportAgentService(
return new ImportRunResult( return new ImportRunResult(
Completed: false, Completed: false,
toolset.ProjectId, toolset.NovelId,
finalLedger?.CompletedChapters?.Count ?? 0, finalLedger?.CompletedChapters?.Count ?? 0,
"Reached the safety limit for this run without finishing. Starting the import " "Reached the safety limit for this run without finishing. Starting the import "
+ "again for the same folder will resume from the ledger."); + "again for the same folder will resume from the ledger.");
@@ -107,12 +107,12 @@ public class ImportAgentService(
private const string SystemPromptTemplate = """ private const string SystemPromptTemplate = """
You import a novel outline that already exists as markdown files on disk into this You import a novel outline that already exists as markdown files on disk into this
app's project data. You are running unattended nobody will read your replies or app's novel data. You are running unattended nobody will read your replies or
answer questions mid-run, so make the judgment calls the instructions below call answer questions mid-run, so make the judgment calls the instructions below call
for yourself and record anything genuinely ambiguous rather than stalling on it. for yourself and record anything genuinely ambiguous rather than stalling on it.
Your tools give you exactly two things: read-only access to files under the import Your tools give you exactly two things: read-only access to files under the import
source folder, and application tools that create the project's chapters, characters, source folder, and application tools that create the novel's chapters, characters,
beats and arcs the same ones the writer's own UI uses. You cannot write or edit beats and arcs the same ones the writer's own UI uses. You cannot write or edit
anything on disk except the resume ledger, and you cannot read anything outside the anything on disk except the resume ledger, and you cannot read anything outside the
source folder. source folder.
@@ -143,10 +143,10 @@ public class ImportAgentService(
```json ```json
{{ {{
"projectId": "guid", "novelId": "guid",
"characters": {{ "Name": "guid", "Alias": "guid" }}, "characters": {{ "Name": "guid", "Alias": "guid" }},
"chapters": {{ "1": "guid" }}, "chapters": {{ "1": "guid" }},
"completedPasses": ["project", "characters"], "completedPasses": ["novel", "characters"],
"completedChapters": [1, 2, 3] "completedChapters": [1, 2, 3]
}} }}
``` ```
@@ -160,9 +160,9 @@ public class ImportAgentService(
Skip a pass whose completion is already recorded. Jump straight to the first Skip a pass whose completion is already recorded. Jump straight to the first
incomplete one. incomplete one.
1. **Project** skip if `completedPasses` has "project". Parse title and author from 1. **Novel** skip if `completedPasses` has "novel". Parse title and author from
`outline.md`'s heading. The paragraph(s) before the chapter table are the blurb `outline.md`'s heading. The paragraph(s) before the chapter table are the blurb
pass them as `notes` to create_project. Record `projectId`, mark "project" done. pass them as `notes` to create_novel. Record `novelId`, mark "novel" done.
2. **Characters (dossiers)** skip if "characters" is complete. For each 2. **Characters (dossiers)** skip if "characters" is complete. For each
`characters/*.md` not already in the ledger's `characters` map: name from the `#` `characters/*.md` not already in the ledger's `characters` map: name from the `#`
heading, occupation from the tagline, appearance/backstory/want from heading, occupation from the tagline, appearance/backstory/want from
+23 -23
View File
@@ -4,7 +4,7 @@ using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Imports; namespace Novelly.Api.Imports;
@@ -20,7 +20,7 @@ internal record ImportAgentTool(
Func<JsonElement, CancellationToken, Task<object?>> Handler); Func<JsonElement, CancellationToken, Task<object?>> Handler);
public class ImportAgentToolset( public class ImportAgentToolset(
ProjectService projects, NovelService novels,
CharacterService characters, CharacterService characters,
CharacterArcService arcs, CharacterArcService arcs,
ChapterService chapters, ChapterService chapters,
@@ -36,15 +36,15 @@ public class ImportAgentToolset(
private string _sourceRoot = string.Empty; private string _sourceRoot = string.Empty;
private Dictionary<string, ImportAgentTool>? _byName; private Dictionary<string, ImportAgentTool>? _byName;
public Guid? ProjectId { get; private set; } public Guid? NovelId { get; private set; }
public IReadOnlyList<AgentToolDefinition> Definitions => public IReadOnlyList<AgentToolDefinition> Definitions =>
[.. ByName.Values.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))]; [.. ByName.Values.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
public void Initialize(string sourceRoot, Guid? existingProjectId) public void Initialize(string sourceRoot, Guid? existingNovelId)
{ {
_sourceRoot = sourceRoot; _sourceRoot = sourceRoot;
ProjectId = existingProjectId; NovelId = existingNovelId;
} }
public ImportLedger? ReadLedgerOrNull() => ImportPaths.ReadLedger(_sourceRoot); public ImportLedger? ReadLedgerOrNull() => ImportPaths.ReadLedger(_sourceRoot);
@@ -99,9 +99,9 @@ public class ImportAgentToolset(
} }
} }
private Guid RequireProjectId() => private Guid RequireNovelId() =>
ProjectId ?? throw new InvalidOperationException( NovelId ?? throw new InvalidOperationException(
"No project exists yet for this import — call create_project first."); "No novel exists yet for this import — call create_novel first.");
private Dictionary<string, ImportAgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name); private Dictionary<string, ImportAgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name);
@@ -187,8 +187,8 @@ public class ImportAgentToolset(
}); });
yield return new ImportAgentTool( yield return new ImportAgentTool(
"create_project", "create_novel",
"Create the novel project this import populates. Call once, in the first pass.", "Create the novel this import populates. Call once, in the first pass.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("title", "The book's title.", required: true) .Str("title", "The book's title.", required: true)
.Str("author", "Author name, if known.") .Str("author", "Author name, if known.")
@@ -196,18 +196,18 @@ public class ImportAgentToolset(
.Build(), .Build(),
async (input, ct) => async (input, ct) =>
{ {
var created = await projects.CreateAsync(new CreateProjectRequest( var created = await novels.CreateAsync(new CreateNovelRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.String(input, "author"), JsonInput.String(input, "author"),
Notes: JsonInput.String(input, "notes")), ct); Notes: JsonInput.String(input, "notes")), ct);
ProjectId = created.Id; NovelId = created.Id;
return created.ToResponse(null); return created.ToResponse(null);
}); });
yield return new ImportAgentTool( yield return new ImportAgentTool(
"update_project_brief", "update_novel_brief",
"Revise the project's top-level fields. Only the fields you supply change.", "Revise the novel's top-level fields. Only the fields you supply change.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("title", "New title.") .Str("title", "New title.")
.Str("author", "Author name.") .Str("author", "Author name.")
@@ -216,15 +216,15 @@ public class ImportAgentToolset(
.Build(), .Build(),
async (input, ct) => async (input, ct) =>
{ {
var projectId = RequireProjectId(); var novelId = RequireNovelId();
var updated = await projects.UpdateAsync(projectId, new UpdateProjectRequest( var updated = await novels.UpdateAsync(novelId, new UpdateNovelRequest(
JsonInput.String(input, "title"), JsonInput.String(input, "title"),
JsonInput.String(input, "author"), JsonInput.String(input, "author"),
JsonInput.String(input, "genre"), JsonInput.String(input, "genre"),
Notes: JsonInput.String(input, "notes")), ct); Notes: JsonInput.String(input, "notes")), ct);
return updated is null return updated is null
? new ImportToolNotFound("Project", projectId) ? new ImportToolNotFound("Novel", novelId)
: updated.ToResponse(null); : updated.ToResponse(null);
}); });
@@ -234,8 +234,8 @@ public class ImportAgentToolset(
CharacterSchema(nameRequired: true).Build(), CharacterSchema(nameRequired: true).Build(),
async (input, ct) => async (input, ct) =>
{ {
var projectId = RequireProjectId(); var novelId = RequireNovelId();
var created = await characters.CreateAsync(projectId, new CreateCharacterRequest( var created = await characters.CreateAsync(novelId, new CreateCharacterRequest(
JsonInput.RequiredString(input, "name"), JsonInput.RequiredString(input, "name"),
Importance: JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting, Importance: JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
Occupation: JsonInput.String(input, "occupation"), Occupation: JsonInput.String(input, "occupation"),
@@ -245,7 +245,7 @@ public class ImportAgentToolset(
Notes: JsonInput.String(input, "notes")), ct); Notes: JsonInput.String(input, "notes")), ct);
return created is null return created is null
? new ImportToolNotFound("Project", projectId) ? new ImportToolNotFound("Novel", novelId)
: created.ToResponse(); : created.ToResponse();
}); });
@@ -284,8 +284,8 @@ public class ImportAgentToolset(
.Build(), .Build(),
async (input, ct) => async (input, ct) =>
{ {
var projectId = RequireProjectId(); var novelId = RequireNovelId();
var created = await chapters.CreateAsync(projectId, new CreateChapterRequest( var created = await chapters.CreateAsync(novelId, new CreateChapterRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"), JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"), JsonInput.String(input, "summary"),
@@ -293,7 +293,7 @@ public class ImportAgentToolset(
Tags: JsonInput.Strings(input, "tags")), ct); Tags: JsonInput.Strings(input, "tags")), ct);
return created is null return created is null
? new ImportToolNotFound("Project", projectId) ? new ImportToolNotFound("Novel", novelId)
: created.ToResponse(); : created.ToResponse();
}); });
+3 -3
View File
@@ -5,7 +5,7 @@ namespace Novelly.Api.Imports;
public record ImportJobResponse( public record ImportJobResponse(
Guid Id, Guid Id,
string SourceRoot, string SourceRoot,
Guid? ProjectId, Guid? NovelId,
ImportJobStatus Status, ImportJobStatus Status,
string? StatusMessage, string? StatusMessage,
int ChaptersCompleted, int ChaptersCompleted,
@@ -22,7 +22,7 @@ public enum ImportReadiness
public record ImportInspectionResponse( public record ImportInspectionResponse(
ImportReadiness Readiness, ImportReadiness Readiness,
Guid? ProjectId, Guid? NovelId,
int ChaptersCompleted, int ChaptersCompleted,
int ChaptersTotal, int ChaptersTotal,
IReadOnlyList<string> CompletedPasses); IReadOnlyList<string> CompletedPasses);
@@ -62,7 +62,7 @@ public static class ImportMapping
public static ImportJobResponse ToResponse(this ImportJob job) => new( public static ImportJobResponse ToResponse(this ImportJob job) => new(
job.Id, job.Id,
job.SourceRoot, job.SourceRoot,
job.ProjectId, job.NovelId,
job.Status, job.Status,
job.StatusMessage, job.StatusMessage,
job.ChaptersCompleted, job.ChaptersCompleted,
+1 -1
View File
@@ -18,7 +18,7 @@ public class ImportJob
public string SourceRoot { get; init; } = string.Empty; public string SourceRoot { get; init; } = string.Empty;
public Guid? ProjectId { get; set; } public Guid? NovelId { get; set; }
public Guid? RequestedByUserId { get; init; } public Guid? RequestedByUserId { get; init; }
+3 -3
View File
@@ -53,11 +53,11 @@ public class ImportJobRunner(
try try
{ {
var existingProjectId = ImportPaths.ReadLedger(job.SourceRoot)?.ProjectId; var existingNovelId = ImportPaths.ReadLedger(job.SourceRoot)?.NovelId;
var result = await agent.RunAsync(job.SourceRoot, existingProjectId, job.ChaptersTotal, ct); var result = await agent.RunAsync(job.SourceRoot, existingNovelId, job.ChaptersTotal, ct);
job.ProjectId = result.ProjectId; job.NovelId = result.NovelId;
job.ChaptersCompleted = result.ChaptersCompleted; job.ChaptersCompleted = result.ChaptersCompleted;
job.Status = result.Completed ? ImportJobStatus.Completed : ImportJobStatus.Paused; job.Status = result.Completed ? ImportJobStatus.Completed : ImportJobStatus.Paused;
job.StatusMessage = result.Message; job.StatusMessage = result.Message;
+2 -2
View File
@@ -4,7 +4,7 @@ using System.Text.Json.Serialization;
namespace Novelly.Api.Imports; namespace Novelly.Api.Imports;
public record ImportLedger( public record ImportLedger(
Guid? ProjectId, Guid? NovelId,
Dictionary<string, Guid>? Characters, Dictionary<string, Guid>? Characters,
Dictionary<string, Guid>? Chapters, Dictionary<string, Guid>? Chapters,
List<string>? CompletedPasses, List<string>? CompletedPasses,
@@ -100,7 +100,7 @@ internal static class ImportPaths
} }
var passes = ledger.CompletedPasses ?? []; var passes = ledger.CompletedPasses ?? [];
var requiredPasses = new[] { "project", "characters", "chapters", "arcs" }; var requiredPasses = new[] { "novel", "characters", "chapters", "arcs" };
var chaptersDone = ledger.CompletedChapters?.Count ?? 0; var chaptersDone = ledger.CompletedChapters?.Count ?? 0;
return requiredPasses.All(passes.Contains) && (chaptersTotal == 0 || chaptersDone >= chaptersTotal); return requiredPasses.All(passes.Contains) && (chaptersTotal == 0 || chaptersDone >= chaptersTotal);
+6 -6
View File
@@ -3,14 +3,14 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Projects; using Novelly.Api.Novels;
using Novelly.Api.Users; using Novelly.Api.Users;
namespace Novelly.Api.Imports; namespace Novelly.Api.Imports;
public class ImportService( public class ImportService(
INovelDbContext db, INovelDbContext db,
ProjectService projects, NovelService novels,
Channel<Guid> queue, Channel<Guid> queue,
INovelUserContext userContext, INovelUserContext userContext,
ILogger<ImportService> logger, ILogger<ImportService> logger,
@@ -37,7 +37,7 @@ public class ImportService(
var readiness = ImportPaths.IsComplete(ledger, total) ? ImportReadiness.Complete : ImportReadiness.Resumable; var readiness = ImportPaths.IsComplete(ledger, total) ? ImportReadiness.Complete : ImportReadiness.Resumable;
return Task.FromResult(new ImportInspectionResponse( return Task.FromResult(new ImportInspectionResponse(
readiness, ledger.ProjectId, chaptersDone, total, ledger.CompletedPasses ?? [])); readiness, ledger.NovelId, chaptersDone, total, ledger.CompletedPasses ?? []));
} }
public async Task<ImportJob> StartOrResumeAsync(StartImportRequest request, CancellationToken ct = default) public async Task<ImportJob> StartOrResumeAsync(StartImportRequest request, CancellationToken ct = default)
@@ -53,11 +53,11 @@ public class ImportService(
if (request.ForceRestart) if (request.ForceRestart)
{ {
var ledger = ImportPaths.ReadLedger(root); var ledger = ImportPaths.ReadLedger(root);
if (ledger?.ProjectId is { } existingProjectId) if (ledger?.NovelId is { } existingNovelId)
{ {
logger.LogWarning( logger.LogWarning(
"Force-restarting import for {SourceRoot}: deleting project {ProjectId}", root, existingProjectId); "Force-restarting import for {SourceRoot}: deleting novel {NovelId}", root, existingNovelId);
await projects.DeleteAsync(existingProjectId, ct); await novels.DeleteAsync(existingNovelId, ct);
} }
ImportPaths.DeleteLedger(root); ImportPaths.DeleteLedger(root);
@@ -6,9 +6,9 @@ using Novelly.Api.Characters;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Novelly.Api.Users; using Novelly.Api.Users;
namespace Novelly.Api.Projects; namespace Novelly.Api.Novels;
public class Project public class Novel
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -24,7 +24,7 @@ public class Project
public int? TargetWordCount { get; set; } public int? TargetWordCount { get; set; }
public ProjectPhase Phase { get; set; } = ProjectPhase.Brainstorming; public NovelPhase Phase { get; set; } = NovelPhase.Brainstorming;
public Guid? OwnerId { get; set; } public Guid? OwnerId { get; set; }
public NovellyUser? Owner { get; set; } public NovellyUser? Owner { get; set; }
@@ -36,25 +36,25 @@ public class Project
public List<Chapter> Chapters { get; set; } = []; public List<Chapter> Chapters { get; set; } = [];
public List<Tag> Tags { get; set; } = []; public List<Tag> Tags { get; set; } = [];
public List<AgentConversation> Conversations { get; set; } = []; public List<AgentConversation> Conversations { get; set; } = [];
public List<ProjectMember> Members { get; set; } = []; public List<NovelMember> Members { get; set; } = [];
} }
public class ProjectEntityTypeConfiguration : IEntityTypeConfiguration<Project> public class NovelEntityTypeConfiguration : IEntityTypeConfiguration<Novel>
{ {
public void Configure(EntityTypeBuilder<Project> entity) public void Configure(EntityTypeBuilder<Novel> entity)
{ {
entity.Property(p => p.Title).IsRequired().HasMaxLength(300); entity.Property(p => p.Title).IsRequired().HasMaxLength(300);
entity.Property(p => p.Phase).HasConversion<string>().HasMaxLength(32); entity.Property(p => p.Phase).HasConversion<string>().HasMaxLength(32);
entity.HasMany(p => p.Characters).WithOne(c => c.Project!) entity.HasMany(p => p.Characters).WithOne(c => c.Novel!)
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(c => c.NovelId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Chapters).WithOne(c => c.Project!) entity.HasMany(p => p.Chapters).WithOne(c => c.Novel!)
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(c => c.NovelId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Tags).WithOne(t => t.Project!) entity.HasMany(p => p.Tags).WithOne(t => t.Novel!)
.HasForeignKey(t => t.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(t => t.NovelId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Conversations).WithOne(c => c.Project!) entity.HasMany(p => p.Conversations).WithOne(c => c.Novel!)
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(c => c.NovelId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Members).WithOne(m => m.Project!) entity.HasMany(p => p.Members).WithOne(m => m.Novel!)
.HasForeignKey(m => m.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(m => m.NovelId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.Owner).WithMany() entity.HasOne(p => p.Owner).WithMany()
.HasForeignKey(p => p.OwnerId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(p => p.OwnerId).OnDelete(DeleteBehavior.Restrict);
} }
@@ -1,21 +1,21 @@
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
namespace Novelly.Api.Projects; namespace Novelly.Api.Novels;
public record ProjectSummaryResponse( public record NovelSummaryResponse(
Guid Id, Guid Id,
string Title, string Title,
string? Author, string? Author,
string? Genre, string? Genre,
string? Logline, string? Logline,
int? TargetWordCount, int? TargetWordCount,
ProjectPhase Phase, NovelPhase Phase,
int CharacterCount, int CharacterCount,
int ChapterCount, int ChapterCount,
int WordCount, int WordCount,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
public record ProjectResponse( public record NovelResponse(
Guid Id, Guid Id,
string Title, string Title,
string? Author, string? Author,
@@ -24,13 +24,13 @@ public record ProjectResponse(
string? Synopsis, string? Synopsis,
string? Notes, string? Notes,
int? TargetWordCount, int? TargetWordCount,
ProjectPhase Phase, NovelPhase Phase,
Guid? OwnerId, Guid? OwnerId,
string? MyRole, string? MyRole,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
public record CreateProjectRequest( public record CreateNovelRequest(
string Title, string Title,
string? Author = null, string? Author = null,
string? Genre = null, string? Genre = null,
@@ -39,20 +39,20 @@ public record CreateProjectRequest(
string? Notes = null, string? Notes = null,
int? TargetWordCount = null); int? TargetWordCount = null);
public class CreateProjectRequestValidator : IModelValidator<CreateProjectRequest> public class CreateNovelRequestValidator : IModelValidator<CreateNovelRequest>
{ {
public ValidationResult Validate(CreateProjectRequest model) public ValidationResult Validate(CreateNovelRequest model)
{ {
var result = new ValidationResult(); var result = new ValidationResult();
ProjectValidation.Title(model.Title, result); NovelValidation.Title(model.Title, result);
ProjectValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result); NovelValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
return result; return result;
} }
} }
public record UpdateProjectRequest( public record UpdateNovelRequest(
string? Title = null, string? Title = null,
string? Author = null, string? Author = null,
string? Genre = null, string? Genre = null,
@@ -60,22 +60,22 @@ public record UpdateProjectRequest(
string? Synopsis = null, string? Synopsis = null,
string? Notes = null, string? Notes = null,
int? TargetWordCount = null, int? TargetWordCount = null,
ProjectPhase? Phase = null); NovelPhase? Phase = null);
public class UpdateProjectRequestValidator : IModelValidator<UpdateProjectRequest> public class UpdateNovelRequestValidator : IModelValidator<UpdateNovelRequest>
{ {
public ValidationResult Validate(UpdateProjectRequest model) public ValidationResult Validate(UpdateNovelRequest model)
{ {
var result = new ValidationResult(); var result = new ValidationResult();
result.AddUnclearableTextErrors("Title", "Title", model.Title, "a project", 200); result.AddUnclearableTextErrors("Title", "Title", model.Title, "a novel", 200);
ProjectValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result); NovelValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
return result; return result;
} }
} }
file static class ProjectValidation file static class NovelValidation
{ {
public static void Title(string title, ValidationResult result) => result.AddRequiredTextErrors("Title", "Title", title, 200); public static void Title(string title, ValidationResult result) => result.AddRequiredTextErrors("Title", "Title", title, 200);
@@ -101,9 +101,9 @@ file static class ProjectValidation
} }
} }
public static class ProjectMapping public static class NovelMapping
{ {
public static ProjectResponse ToResponse(this Project p, string? myRole) => new( public static NovelResponse ToResponse(this Novel p, string? myRole) => new(
p.Id, p.Title, p.Author, p.Genre, p.Logline, p.Synopsis, p.Notes, p.Id, p.Title, p.Author, p.Genre, p.Logline, p.Synopsis, p.Notes,
p.TargetWordCount, p.Phase, p.OwnerId, myRole, p.CreatedAt, p.UpdatedAt); p.TargetWordCount, p.Phase, p.OwnerId, myRole, p.CreatedAt, p.UpdatedAt);
} }
+57
View File
@@ -0,0 +1,57 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Users;
namespace Novelly.Api.Novels;
public static class NovelEndpoints
{
public static IEndpointRouteBuilder MapNovelEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/novels").WithTags("Novels")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
group.MapGet("/", async (NovelService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(ct)))
.WithSummary("List all novels.");
group.MapGet("/{id:guid}", async (Guid id, NovelService service, NovelAccessService access, CancellationToken ct) =>
{
var novel = await service.GetAsync(id, ct);
if (novel is null)
return Results.NotFound();
var myRole = await access.GetMyRoleAsync(novel, ct);
return Results.Ok(novel.ToResponse(myRole));
})
.WithSummary("Read a novel's brief.");
group.MapPost("/", async (CreateNovelRequest request, NovelService service, NovelAccessService access, CancellationToken ct) =>
{
var novel = await service.CreateAsync(request, ct);
var myRole = await access.GetMyRoleAsync(novel, ct);
var created = novel.ToResponse(myRole);
return Results.Created($"/api/novels/{created.Id}", created);
})
.WithSummary("Create a novel.");
group.MapPatch("/{id:guid}", async (
Guid id, UpdateNovelRequest request, NovelService service, NovelAccessService access, CancellationToken ct) =>
{
var novel = await service.UpdateAsync(id, request, ct);
if (novel is null)
return Results.NotFound();
var myRole = await access.GetMyRoleAsync(novel, ct);
return Results.Ok(novel.ToResponse(myRole));
})
.WithSummary("Update a novel's brief.");
group.MapDelete("/{id:guid}", async (Guid id, NovelService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a novel and everything in it.");
return app;
}
}
@@ -1,6 +1,6 @@
namespace Novelly.Api.Projects; namespace Novelly.Api.Novels;
public enum ProjectPhase public enum NovelPhase
{ {
Brainstorming, Brainstorming,
Outlining, Outlining,
+133
View File
@@ -0,0 +1,133 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Novels;
public class NovelService(
INovelDbContext db,
NovelAccessService access,
INovelUserContext userContext,
ILogger<NovelService> logger,
IModelValidator<CreateNovelRequest> createValidator,
IModelValidator<UpdateNovelRequest> updateValidator)
{
public async Task<IReadOnlyList<NovelSummaryResponse>> ListAsync(CancellationToken ct = default)
{
logger.LogInformation("Listing novels");
return await access.VisibleNovels()
.OrderByDescending(p => p.UpdatedAt)
.Select(p => new NovelSummaryResponse(
p.Id,
p.Title,
p.Author,
p.Genre,
p.Logline,
p.TargetWordCount,
p.Phase,
p.Characters.Count,
p.Chapters.Count,
p.Chapters.Sum(c => (int?)c.WordCount) ?? 0,
p.UpdatedAt))
.ToListAsync(ct);
}
public async Task<Novel?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting novel {NovelId}", id);
var novel = await FindAsync(id, ct);
if (novel is null) return null;
await access.RequireAsync(id, NovelPermission.Read, ct);
return novel;
}
public async Task<Novel> CreateAsync(CreateNovelRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger);
access.RequireCanCreateNovel();
logger.LogInformation("Creating novel {Title}", request.Title);
var novel = new Novel
{
Title = request.Title,
Author = request.Author,
Genre = request.Genre,
Logline = request.Logline,
Synopsis = request.Synopsis,
Notes = request.Notes,
TargetWordCount = request.TargetWordCount,
OwnerId = userContext.UserId
};
db.Novels.Add(novel);
await db.SaveChangesAsync(ct);
return novel;
}
public async Task<Novel?> UpdateAsync(Guid id, UpdateNovelRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Updating novel {NovelId}", id);
var novel = await FindAsync(id, ct);
if (novel is null) return null;
await access.RequireAsync(id, NovelPermission.Write, ct);
novel.Title = Patch.Apply(novel.Title, request.Title) ?? novel.Title;
novel.Author = Patch.Apply(novel.Author, request.Author);
novel.Genre = Patch.Apply(novel.Genre, request.Genre);
novel.Logline = Patch.Apply(novel.Logline, request.Logline);
novel.Synopsis = Patch.Apply(novel.Synopsis, request.Synopsis);
novel.Notes = Patch.Apply(novel.Notes, request.Notes);
novel.TargetWordCount = request.TargetWordCount ?? novel.TargetWordCount;
novel.Phase = request.Phase ?? novel.Phase;
novel.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return novel;
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Deleting novel {NovelId}", id);
var novel = await FindAsync(id, ct);
if (novel is null) return false;
await access.RequireAsync(id, NovelPermission.DeleteContent, ct);
db.Novels.Remove(novel);
await db.SaveChangesAsync(ct);
return true;
}
private async Task<Novel?> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding novel {NovelId}", id);
var novel = await db.Novels.FirstOrDefaultAsync(p => p.Id == id, ct);
if (novel is null)
{
logger.LogWarning("Novel {NovelId} not found", id);
return novel;
}
logger.LogDebug("Found novel {NovelId}", id);
return novel;
}
}
+3 -3
View File
@@ -10,7 +10,7 @@ using Novelly.Api.Common;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Genres; using Novelly.Api.Genres;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Projects; using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Novelly.Api.Users; using Novelly.Api.Users;
@@ -88,9 +88,9 @@ app.MapDefaultEndpoints();
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous(); app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous();
app.MapUserEndpoints(); app.MapUserEndpoints();
app.MapProjectMemberEndpoints(); app.MapNovelMemberEndpoints();
app.MapProjectEndpoints() app.MapNovelEndpoints()
.MapCharacterEndpoints() .MapCharacterEndpoints()
.MapChapterEndpoints() .MapChapterEndpoints()
.MapBeatEndpoints() .MapBeatEndpoints()
@@ -1,57 +0,0 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Users;
namespace Novelly.Api.Projects;
public static class ProjectEndpoints
{
public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/projects").WithTags("Projects")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
group.MapGet("/", async (ProjectService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(ct)))
.WithSummary("List all novel projects.");
group.MapGet("/{id:guid}", async (Guid id, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
{
var project = await service.GetAsync(id, ct);
if (project is null)
return Results.NotFound();
var myRole = await access.GetMyRoleAsync(project, ct);
return Results.Ok(project.ToResponse(myRole));
})
.WithSummary("Read a project's brief.");
group.MapPost("/", async (CreateProjectRequest request, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
{
var project = await service.CreateAsync(request, ct);
var myRole = await access.GetMyRoleAsync(project, ct);
var created = project.ToResponse(myRole);
return Results.Created($"/api/projects/{created.Id}", created);
})
.WithSummary("Create a novel project.");
group.MapPatch("/{id:guid}", async (
Guid id, UpdateProjectRequest request, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
{
var project = await service.UpdateAsync(id, request, ct);
if (project is null)
return Results.NotFound();
var myRole = await access.GetMyRoleAsync(project, ct);
return Results.Ok(project.ToResponse(myRole));
})
.WithSummary("Update a project's brief.");
group.MapDelete("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a project and everything in it.");
return app;
}
}
-133
View File
@@ -1,133 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Projects;
public class ProjectService(
INovelDbContext db,
ProjectAccessService access,
INovelUserContext userContext,
ILogger<ProjectService> logger,
IModelValidator<CreateProjectRequest> createValidator,
IModelValidator<UpdateProjectRequest> updateValidator)
{
public async Task<IReadOnlyList<ProjectSummaryResponse>> ListAsync(CancellationToken ct = default)
{
logger.LogInformation("Listing projects");
return await access.VisibleProjects()
.OrderByDescending(p => p.UpdatedAt)
.Select(p => new ProjectSummaryResponse(
p.Id,
p.Title,
p.Author,
p.Genre,
p.Logline,
p.TargetWordCount,
p.Phase,
p.Characters.Count,
p.Chapters.Count,
p.Chapters.Sum(c => (int?)c.WordCount) ?? 0,
p.UpdatedAt))
.ToListAsync(ct);
}
public async Task<Project?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting project {ProjectId}", id);
var project = await FindAsync(id, ct);
if (project is null) return null;
await access.RequireAsync(id, ProjectPermission.Read, ct);
return project;
}
public async Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger);
access.RequireCanCreateProject();
logger.LogInformation("Creating project {Title}", request.Title);
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,
OwnerId = userContext.UserId
};
db.Projects.Add(project);
await db.SaveChangesAsync(ct);
return project;
}
public async Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Updating project {ProjectId}", id);
var project = await FindAsync(id, ct);
if (project is null) return null;
await access.RequireAsync(id, ProjectPermission.Write, 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.Phase = request.Phase ?? project.Phase;
project.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return project;
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Deleting project {ProjectId}", id);
var project = await FindAsync(id, ct);
if (project is null) return false;
await access.RequireAsync(id, ProjectPermission.DeleteContent, ct);
db.Projects.Remove(project);
await db.SaveChangesAsync(ct);
return true;
}
private async Task<Project?> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding project {ProjectId}", id);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct);
if (project is null)
{
logger.LogWarning("Project {ProjectId} not found", id);
return project;
}
logger.LogDebug("Found project {ProjectId}", id);
return project;
}
}
+6 -6
View File
@@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Questions; namespace Novelly.Api.Questions;
@@ -10,8 +10,8 @@ public class OpenQuestion
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; } public Guid NovelId { get; set; }
public Project? Project { get; set; } public Novel? Novel { get; set; }
public string Question { get; set; } = string.Empty; public string Question { get; set; } = string.Empty;
@@ -40,10 +40,10 @@ public class OpenQuestionEntityTypeConfiguration : IEntityTypeConfiguration<Open
entity.Property(q => q.Question).IsRequired().HasMaxLength(500); entity.Property(q => q.Question).IsRequired().HasMaxLength(500);
entity.Ignore(q => q.IsResolved); entity.Ignore(q => q.IsResolved);
entity.HasIndex(q => q.ProjectId); entity.HasIndex(q => q.NovelId);
entity.HasOne(q => q.Project).WithMany() entity.HasOne(q => q.Novel).WithMany()
.HasForeignKey(q => q.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(q => q.NovelId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(q => q.Chapter).WithMany() entity.HasOne(q => q.Chapter).WithMany()
.HasForeignKey(q => q.ChapterId).OnDelete(DeleteBehavior.SetNull); .HasForeignKey(q => q.ChapterId).OnDelete(DeleteBehavior.SetNull);
@@ -4,7 +4,7 @@ namespace Novelly.Api.Questions;
public record OpenQuestionResponse( public record OpenQuestionResponse(
Guid Id, Guid Id,
Guid ProjectId, Guid NovelId,
string Question, string Question,
string? Detail, string? Detail,
Guid? ChapterId, Guid? ChapterId,
@@ -76,7 +76,7 @@ public static class OpenQuestionMapping
{ {
public static OpenQuestionResponse ToResponse(this OpenQuestion q) => new( public static OpenQuestionResponse ToResponse(this OpenQuestion q) => new(
q.Id, q.Id,
q.ProjectId, q.NovelId,
q.Question, q.Question,
q.Detail, q.Detail,
q.ChapterId, q.ChapterId,
@@ -7,24 +7,24 @@ public static class OpenQuestionEndpoints
{ {
public static IEndpointRouteBuilder MapOpenQuestionEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapOpenQuestionEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/questions").WithTags("Questions") var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/questions").WithTags("Questions")
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/", async ( novelScoped.MapGet("/", async (
Guid projectId, Guid novelId,
OpenQuestionService service, OpenQuestionService service,
CancellationToken ct, CancellationToken ct,
Guid? chapterId = null, Guid? chapterId = null,
Guid? characterId = null, Guid? characterId = null,
bool includeResolved = false) => bool includeResolved = false) =>
Results.Ok((await service.ListAsync(projectId, chapterId, characterId, includeResolved, ct)).Select(q => q.ToResponse()))) Results.Ok((await service.ListAsync(novelId, chapterId, characterId, includeResolved, ct)).Select(q => q.ToResponse())))
.WithSummary("List a project's open questions, optionally narrowed to one chapter or character."); .WithSummary("List a novel's open questions, optionally narrowed to one chapter or character.");
projectScoped.MapPost("/", async ( novelScoped.MapPost("/", async (
Guid projectId, CreateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) => Guid novelId, CreateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
{ {
var question = await service.CreateAsync(projectId, request, ct); var question = await service.CreateAsync(novelId, request, ct);
if (question is null) if (question is null)
{ {
return Results.NotFound(); return Results.NotFound();
@@ -10,28 +10,28 @@ namespace Novelly.Api.Questions;
public class OpenQuestionService( public class OpenQuestionService(
INovelDbContext db, INovelDbContext db,
ProjectAccessService access, NovelAccessService access,
ILogger<OpenQuestionService> logger, ILogger<OpenQuestionService> logger,
IModelValidator<CreateOpenQuestionRequest> createValidator, IModelValidator<CreateOpenQuestionRequest> createValidator,
IModelValidator<UpdateOpenQuestionRequest> updateValidator, IModelValidator<UpdateOpenQuestionRequest> updateValidator,
IModelValidator<ResolveOpenQuestionRequest> resolveValidator) IModelValidator<ResolveOpenQuestionRequest> resolveValidator)
{ {
public async Task<IReadOnlyList<OpenQuestion>> ListAsync( public async Task<IReadOnlyList<OpenQuestion>> ListAsync(
Guid projectId, Guid novelId,
Guid? chapterId = null, Guid? chapterId = null,
Guid? characterId = null, Guid? characterId = null,
bool includeResolved = false, bool includeResolved = false,
CancellationToken ct = default) CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
logger.LogInformation( logger.LogInformation(
"Listing open questions for project {ProjectId}, chapter {ChapterId}, character {CharacterId}, includeResolved {IncludeResolved}", "Listing open questions for novel {NovelId}, chapter {ChapterId}, character {CharacterId}, includeResolved {IncludeResolved}",
projectId, chapterId, characterId, includeResolved); novelId, chapterId, characterId, includeResolved);
await access.RequireAsync(projectId, ProjectPermission.Read, ct); await access.RequireAsync(novelId, NovelPermission.Read, ct);
var query = Query().Where(q => q.ProjectId == projectId); var query = Query().Where(q => q.NovelId == novelId);
if (chapterId is { } cid) if (chapterId is { } cid)
{ {
@@ -70,31 +70,31 @@ public class OpenQuestionService(
return null; return null;
} }
await access.RequireAsync(question.ProjectId, ProjectPermission.Read, ct); await access.RequireAsync(question.NovelId, NovelPermission.Read, ct);
return question; return question;
} }
public async Task<OpenQuestion?> CreateAsync( public async Task<OpenQuestion?> CreateAsync(
Guid projectId, CreateOpenQuestionRequest request, CancellationToken ct = default) Guid novelId, CreateOpenQuestionRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger); createValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Creating open question for project {ProjectId}", projectId); logger.LogInformation("Creating open question for novel {NovelId}", novelId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{ {
logger.LogWarning("Rejected open question creation: project {ProjectId} not found", projectId); logger.LogWarning("Rejected open question creation: novel {NovelId} not found", novelId);
return null; return null;
} }
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct); await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
await ValidateAssociationsAsync(projectId, request.ChapterId, request.CharacterId, ct); await ValidateAssociationsAsync(novelId, request.ChapterId, request.CharacterId, ct);
var question = new OpenQuestion var question = new OpenQuestion
{ {
ProjectId = projectId, NovelId = novelId,
Question = request.Question.Trim(), Question = request.Question.Trim(),
Detail = request.Detail, Detail = request.Detail,
ChapterId = request.ChapterId, ChapterId = request.ChapterId,
@@ -122,8 +122,8 @@ public class OpenQuestionService(
return null; return null;
} }
await access.RequireAsync(question.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(question.NovelId, NovelPermission.Write, ct);
await ValidateAssociationsAsync(question.ProjectId, request.ChapterId, request.CharacterId, ct); await ValidateAssociationsAsync(question.NovelId, request.ChapterId, request.CharacterId, ct);
question.Question = Patch.Apply(question.Question, request.Question) ?? question.Question; question.Question = Patch.Apply(question.Question, request.Question) ?? question.Question;
question.Detail = Patch.Apply(question.Detail, request.Detail); question.Detail = Patch.Apply(question.Detail, request.Detail);
@@ -150,7 +150,7 @@ public class OpenQuestionService(
return null; return null;
} }
await access.RequireAsync(question.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(question.NovelId, NovelPermission.Write, ct);
question.Resolution = request.Resolution.Trim(); question.Resolution = request.Resolution.Trim();
question.ResolvedAt = DateTimeOffset.UtcNow; question.ResolvedAt = DateTimeOffset.UtcNow;
@@ -201,7 +201,7 @@ public class OpenQuestionService(
return null; return null;
} }
await access.RequireAsync(question.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(question.NovelId, NovelPermission.Write, ct);
question.Resolution = null; question.Resolution = null;
question.ResolvedAt = null; question.ResolvedAt = null;
@@ -223,7 +223,7 @@ public class OpenQuestionService(
return false; return false;
} }
await access.RequireAsync(question.ProjectId, ProjectPermission.DeleteContent, ct); await access.RequireAsync(question.NovelId, NovelPermission.DeleteContent, ct);
db.OpenQuestions.Remove(question); db.OpenQuestions.Remove(question);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -234,27 +234,27 @@ public class OpenQuestionService(
string.IsNullOrWhiteSpace(existing) ? note : $"{existing.TrimEnd()}\n\n{note}"; string.IsNullOrWhiteSpace(existing) ? note : $"{existing.TrimEnd()}\n\n{note}";
private async Task ValidateAssociationsAsync( private async Task ValidateAssociationsAsync(
Guid projectId, Guid? chapterId, Guid? characterId, CancellationToken ct) Guid novelId, Guid? chapterId, Guid? characterId, CancellationToken ct)
{ {
logger.LogDebug("Validating associations for project {ProjectId}: chapter {ChapterId}, character {CharacterId}", projectId, chapterId, characterId); logger.LogDebug("Validating associations for novel {NovelId}: chapter {ChapterId}, character {CharacterId}", novelId, chapterId, characterId);
if (chapterId is { } cid if (chapterId is { } cid
&& !await db.Chapters.AnyAsync(c => c.Id == cid && c.ProjectId == projectId, ct)) && !await db.Chapters.AnyAsync(c => c.Id == cid && c.NovelId == novelId, ct))
{ {
logger.LogWarning("Rejected question association: chapter {ChapterId} does not belong to project {ProjectId}", cid, projectId); logger.LogWarning("Rejected question association: chapter {ChapterId} does not belong to novel {NovelId}", cid, novelId);
throw new InvalidOperationException( throw new InvalidOperationException(
"A question can only be attached to a chapter in the same project."); "A question can only be attached to a chapter in the same novel.");
} }
if (characterId is { } chid if (characterId is { } chid
&& !await db.Characters.AnyAsync(c => c.Id == chid && c.ProjectId == projectId, ct)) && !await db.Characters.AnyAsync(c => c.Id == chid && c.NovelId == novelId, ct))
{ {
logger.LogWarning("Rejected question association: character {CharacterId} does not belong to project {ProjectId}", chid, projectId); logger.LogWarning("Rejected question association: character {CharacterId} does not belong to novel {NovelId}", chid, novelId);
throw new InvalidOperationException( throw new InvalidOperationException(
"A question can only be attached to a character in the same project."); "A question can only be attached to a character in the same novel.");
} }
logger.LogDebug("Associations valid for project {ProjectId}: chapter {ChapterId}, character {CharacterId}", projectId, chapterId, characterId); logger.LogDebug("Associations valid for novel {NovelId}: chapter {ChapterId}, character {CharacterId}", novelId, chapterId, characterId);
} }
private IQueryable<OpenQuestion> Query() => private IQueryable<OpenQuestion> Query() =>
+4 -4
View File
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Tags; namespace Novelly.Api.Tags;
@@ -11,8 +11,8 @@ public class Tag
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; } public Guid NovelId { get; set; }
public Project? Project { get; set; } public Novel? Novel { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
@@ -32,7 +32,7 @@ public class TagEntityTypeConfiguration : IEntityTypeConfiguration<Tag>
entity.Property(t => t.Name).IsRequired().HasMaxLength(64); entity.Property(t => t.Name).IsRequired().HasMaxLength(64);
entity.Property(t => t.Color).HasMaxLength(16); entity.Property(t => t.Color).HasMaxLength(16);
entity.HasIndex(t => new { t.ProjectId, t.Name }).IsUnique(); entity.HasIndex(t => new { t.NovelId, t.Name }).IsUnique();
entity.HasMany(t => t.Characters).WithMany(c => c.Tags) entity.HasMany(t => t.Characters).WithMany(c => c.Tags)
.UsingEntity(join => join.ToTable("CharacterTags")); .UsingEntity(join => join.ToTable("CharacterTags"));
+7 -7
View File
@@ -7,18 +7,18 @@ public static class TagEndpoints
{ {
public static IEndpointRouteBuilder MapTagEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapTagEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/tags").WithTags("Tags") var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/tags").WithTags("Tags")
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, TagService service, CancellationToken ct) => novelScoped.MapGet("/", async (Guid novelId, TagService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(projectId, ct))) Results.Ok(await service.ListAsync(novelId, ct)))
.WithSummary("List a project's tags with usage counts."); .WithSummary("List a novel's tags with usage counts.");
projectScoped.MapPost("/", async ( novelScoped.MapPost("/", async (
Guid projectId, CreateTagRequest request, TagService service, CancellationToken ct) => Guid novelId, CreateTagRequest request, TagService service, CancellationToken ct) =>
{ {
var tag = await service.CreateAsync(projectId, request, ct); var tag = await service.CreateAsync(novelId, request, ct);
if (tag is null) if (tag is null)
{ {
return Results.NotFound(); return Results.NotFound();
+30 -30
View File
@@ -8,21 +8,21 @@ namespace Novelly.Api.Tags;
public class TagService( public class TagService(
INovelDbContext db, INovelDbContext db,
ProjectAccessService access, NovelAccessService access,
ILogger<TagService> logger, ILogger<TagService> logger,
IModelValidator<CreateTagRequest> createValidator, IModelValidator<CreateTagRequest> createValidator,
IModelValidator<UpdateTagRequest> updateValidator) IModelValidator<UpdateTagRequest> updateValidator)
{ {
public async Task<IReadOnlyList<TagSummaryResponse>> ListAsync(Guid projectId, CancellationToken ct = default) public async Task<IReadOnlyList<TagSummaryResponse>> ListAsync(Guid novelId, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Listing tags for project {ProjectId}", projectId); logger.LogInformation("Listing tags for novel {NovelId}", novelId);
await access.RequireAsync(projectId, ProjectPermission.Read, ct); await access.RequireAsync(novelId, NovelPermission.Read, ct);
return await db.Tags return await db.Tags
.Where(t => t.ProjectId == projectId) .Where(t => t.NovelId == novelId)
.OrderBy(t => t.Name) .OrderBy(t => t.Name)
.Select(t => new TagSummaryResponse( .Select(t => new TagSummaryResponse(
t.Id, t.Name, t.Color, t.Id, t.Name, t.Color,
@@ -49,36 +49,36 @@ public class TagService(
return tag; return tag;
} }
await access.RequireAsync(tag.ProjectId, ProjectPermission.Read, ct); await access.RequireAsync(tag.NovelId, NovelPermission.Read, ct);
return tag; return tag;
} }
public async Task<Tag?> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default) public async Task<Tag?> CreateAsync(Guid novelId, CreateTagRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger); createValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Creating tag {Name} for project {ProjectId}", request.Name, projectId); logger.LogInformation("Creating tag {Name} for novel {NovelId}", request.Name, novelId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{ {
logger.LogWarning("Rejected tag creation: project {ProjectId} not found", projectId); logger.LogWarning("Rejected tag creation: novel {NovelId} not found", novelId);
return null; return null;
} }
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct); await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
var name = TagMapping.Normalise(request.Name); var name = TagMapping.Normalise(request.Name);
var existing = await FindByNameAsync(projectId, name, ct); var existing = await FindByNameAsync(novelId, name, ct);
if (existing is not null) if (existing is not null)
{ {
logger.LogWarning("Rejected tag creation for project {ProjectId}: '{Name}' already exists", projectId, existing.Name); logger.LogWarning("Rejected tag creation for novel {NovelId}: '{Name}' already exists", novelId, existing.Name);
throw new InvalidOperationException($"The project already has a tag called '{existing.Name}'."); throw new InvalidOperationException($"The novel already has a tag called '{existing.Name}'.");
} }
var tag = new Tag { ProjectId = projectId, Name = name, Color = request.Color }; var tag = new Tag { NovelId = novelId, Name = name, Color = request.Color };
db.Tags.Add(tag); db.Tags.Add(tag);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return tag; return tag;
@@ -99,17 +99,17 @@ public class TagService(
return null; return null;
} }
await access.RequireAsync(tag.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(tag.NovelId, NovelPermission.Write, ct);
if (request.Name is not null) if (request.Name is not null)
{ {
var name = TagMapping.Normalise(request.Name); var name = TagMapping.Normalise(request.Name);
var clash = await FindByNameAsync(tag.ProjectId, name, ct); var clash = await FindByNameAsync(tag.NovelId, name, ct);
if (clash is not null && clash.Id != tag.Id) if (clash is not null && clash.Id != tag.Id)
{ {
logger.LogWarning("Rejected update for tag {TagId}: '{Name}' already exists as {ClashTagId}", tagId, clash.Name, clash.Id); logger.LogWarning("Rejected update for tag {TagId}: '{Name}' already exists as {ClashTagId}", tagId, clash.Name, clash.Id);
throw new InvalidOperationException($"The project already has a tag called '{clash.Name}'."); throw new InvalidOperationException($"The novel already has a tag called '{clash.Name}'.");
} }
tag.Name = name; tag.Name = name;
@@ -133,7 +133,7 @@ public class TagService(
return false; return false;
} }
await access.RequireAsync(tag.ProjectId, ProjectPermission.DeleteContent, ct); await access.RequireAsync(tag.NovelId, NovelPermission.DeleteContent, ct);
db.Tags.Remove(tag); db.Tags.Remove(tag);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -141,12 +141,12 @@ public class TagService(
} }
internal async Task<List<Tag>> ResolveAsync( internal async Task<List<Tag>> ResolveAsync(
Guid projectId, IReadOnlyList<string> names, CancellationToken ct) Guid novelId, IReadOnlyList<string> names, CancellationToken ct)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
Guard.Null(names, nameof(names)); Guard.Null(names, nameof(names));
logger.LogDebug("Resolving {Count} tag names for project {ProjectId}", names.Count, projectId); logger.LogDebug("Resolving {Count} tag names for novel {NovelId}", names.Count, novelId);
var wanted = names var wanted = names
.Select(TagMapping.Normalise) .Select(TagMapping.Normalise)
@@ -156,12 +156,12 @@ public class TagService(
if (wanted.Count == 0) if (wanted.Count == 0)
{ {
logger.LogDebug("No usable tag names for project {ProjectId}", projectId); logger.LogDebug("No usable tag names for novel {NovelId}", novelId);
return []; return [];
} }
var existing = await db.Tags var existing = await db.Tags
.Where(t => t.ProjectId == projectId) .Where(t => t.NovelId == novelId)
.ToListAsync(ct); .ToListAsync(ct);
var resolved = new List<Tag>(); var resolved = new List<Tag>();
@@ -172,7 +172,7 @@ public class TagService(
if (match is null) if (match is null)
{ {
match = new Tag { ProjectId = projectId, Name = name }; match = new Tag { NovelId = novelId, Name = name };
db.Tags.Add(match); db.Tags.Add(match);
existing.Add(match); existing.Add(match);
} }
@@ -180,11 +180,11 @@ public class TagService(
resolved.Add(match); resolved.Add(match);
} }
logger.LogDebug("Resolved {Count} tags for project {ProjectId}", resolved.Count, projectId); logger.LogDebug("Resolved {Count} tags for novel {NovelId}", resolved.Count, novelId);
return resolved; return resolved;
} }
private async Task<Tag?> FindByNameAsync(Guid projectId, string name, CancellationToken ct) => private async Task<Tag?> FindByNameAsync(Guid novelId, string name, CancellationToken ct) =>
await db.Tags.FirstOrDefaultAsync( await db.Tags.FirstOrDefaultAsync(
t => t.ProjectId == projectId && EF.Functions.Like(t.Name, name), ct); t => t.NovelId == novelId && EF.Functions.Like(t.Name, name), ct);
} }
@@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.Novels;
namespace Novelly.Api.Users;
public enum NovelPermission
{
Read,
Write,
CreateContent,
DeleteContent,
ManageAccess
}
public class NovelAccessService(INovelDbContext db, INovelUserContext userContext, ILogger<NovelAccessService> logger)
{
public void RequireCanCreateNovel()
{
if (userContext.GlobalRole is GlobalRole.Admin or GlobalRole.Writer)
return;
logger.LogWarning("User {UserId} denied novel creation, global role {GlobalRole}", userContext.UserId, userContext.GlobalRole);
throw new NotAuthorizedException("Only writers and admins can create novels.");
}
public async Task RequireAsync(Guid novelId, NovelPermission permission, CancellationToken ct = default)
{
if (userContext.GlobalRole == GlobalRole.Admin)
return;
var novel = await db.Novels.AsNoTracking().Select(p => new { p.Id, p.OwnerId }).FirstOrDefaultAsync(p => p.Id == novelId, ct);
if (novel is null)
{
logger.LogWarning("Access check against missing novel {NovelId}", novelId);
throw new NotAuthorizedException("Not permitted.");
}
if (novel.OwnerId is not null && novel.OwnerId == userContext.UserId)
return;
var member = userContext.UserId is null
? null
: await db.NovelMembers.AsNoTracking().FirstOrDefaultAsync(m => m.NovelId == novelId && m.UserId == userContext.UserId, ct);
if (!IsAllowed(permission, member?.NovelRole))
{
logger.LogWarning("User {UserId} denied {Permission} on novel {NovelId}", userContext.UserId, permission, novelId);
throw new NotAuthorizedException($"Not permitted to {permission} on this novel.");
}
}
public async Task<string?> GetMyRoleAsync(Novel novel, CancellationToken ct = default)
{
if (userContext.GlobalRole == GlobalRole.Admin)
return "Admin";
if (novel.OwnerId is not null && novel.OwnerId == userContext.UserId)
return "Owner";
if (userContext.UserId is null)
return null;
var member = await db.NovelMembers.AsNoTracking()
.FirstOrDefaultAsync(m => m.NovelId == novel.Id && m.UserId == userContext.UserId, ct);
return member?.NovelRole.ToString();
}
public IQueryable<Novel> VisibleNovels()
{
if (userContext.GlobalRole == GlobalRole.Admin)
return db.Novels;
var userId = userContext.UserId;
return db.Novels.Where(p => p.OwnerId == userId || p.Members.Any(m => m.UserId == userId));
}
private static bool IsAllowed(NovelPermission permission, NovelRole? role) => permission switch
{
NovelPermission.Read => role is not null,
NovelPermission.Write => role is NovelRole.Writer or NovelRole.Editor,
NovelPermission.CreateContent => role is NovelRole.Writer,
NovelPermission.DeleteContent => role is NovelRole.Writer,
NovelPermission.ManageAccess => false,
_ => false
};
}
@@ -1,31 +1,31 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Users; namespace Novelly.Api.Users;
public class ProjectMember public class NovelMember
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; } public Guid NovelId { get; set; }
public Project? Project { get; set; } public Novel? Novel { get; set; }
public Guid UserId { get; set; } public Guid UserId { get; set; }
public NovellyUser? User { get; set; } public NovellyUser? User { get; set; }
public ProjectRole ProjectRole { get; set; } public NovelRole NovelRole { get; set; }
public DateTimeOffset GrantedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset GrantedAt { get; set; } = DateTimeOffset.UtcNow;
public Guid GrantedByUserId { get; set; } public Guid GrantedByUserId { get; set; }
} }
public class ProjectMemberEntityTypeConfiguration : IEntityTypeConfiguration<ProjectMember> public class NovelMemberEntityTypeConfiguration : IEntityTypeConfiguration<NovelMember>
{ {
public void Configure(EntityTypeBuilder<ProjectMember> entity) public void Configure(EntityTypeBuilder<NovelMember> entity)
{ {
entity.Property(m => m.ProjectRole).HasConversion<string>().HasMaxLength(32); entity.Property(m => m.NovelRole).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(m => new { m.ProjectId, m.UserId }).IsUnique(); entity.HasIndex(m => new { m.NovelId, m.UserId }).IsUnique();
entity.HasOne(m => m.User).WithMany() entity.HasOne(m => m.User).WithMany()
.HasForeignKey(m => m.UserId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(m => m.UserId).OnDelete(DeleteBehavior.Cascade);
@@ -2,9 +2,9 @@ using Novelly.Api.Common.Validation;
namespace Novelly.Api.Users; namespace Novelly.Api.Users;
public record GrantAccessRequest(string Email, ProjectRole ProjectRole); public record GrantAccessRequest(string Email, NovelRole NovelRole);
public record ProjectMemberResponse(Guid UserId, string Email, string DisplayName, ProjectRole ProjectRole, DateTimeOffset GrantedAt); public record NovelMemberResponse(Guid UserId, string Email, string DisplayName, NovelRole NovelRole, DateTimeOffset GrantedAt);
public class GrantAccessRequestValidator : IModelValidator<GrantAccessRequest> public class GrantAccessRequestValidator : IModelValidator<GrantAccessRequest>
{ {
@@ -16,8 +16,8 @@ public class GrantAccessRequestValidator : IModelValidator<GrantAccessRequest>
} }
} }
public static class ProjectMemberMapping public static class NovelMemberMapping
{ {
public static ProjectMemberResponse ToResponse(this ProjectMember m) => public static NovelMemberResponse ToResponse(this NovelMember m) =>
new(m.UserId, m.User!.Email ?? string.Empty, m.User.DisplayName, m.ProjectRole, m.GrantedAt); new(m.UserId, m.User!.Email ?? string.Empty, m.User.DisplayName, m.NovelRole, m.GrantedAt);
} }
@@ -0,0 +1,28 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Users;
public static class NovelMemberEndpoints
{
public static IEndpointRouteBuilder MapNovelMemberEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/novels/{novelId:guid}/members").WithTags("NovelMembers")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
group.MapGet("/", async (Guid novelId, NovelMemberService service, CancellationToken ct) =>
(await service.ListAsync(novelId, ct))?.ToApiResult())
.WithSummary("List everyone granted access to a novel.");
group.MapPost("/", async (Guid novelId, GrantAccessRequest request, NovelMemberService service, CancellationToken ct) =>
(await service.GrantAsync(novelId, request, ct))?.ToApiResult())
.WithSummary("Grant a role on a novel to another account.");
group.MapDelete("/{userId:guid}", async (Guid novelId, Guid userId, NovelMemberService service, CancellationToken ct) =>
await service.RevokeAsync(novelId, userId, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Revoke an account's access to a novel.");
return app;
}
}
+107
View File
@@ -0,0 +1,107 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
namespace Novelly.Api.Users;
public class NovelMemberService(
INovelDbContext db,
NovelAccessService access,
UserManager<NovellyUser> userManager,
INovelUserContext userContext,
ILogger<NovelMemberService> logger,
IModelValidator<GrantAccessRequest> grantValidator)
{
public async Task<IReadOnlyList<NovelMemberResponse>?> ListAsync(Guid novelId, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Listing members for novel {NovelId}", novelId);
if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{
logger.LogWarning("Rejected member listing: novel {NovelId} not found", novelId);
return null;
}
await access.RequireAsync(novelId, NovelPermission.ManageAccess, ct);
var members = await db.NovelMembers
.Include(m => m.User)
.Where(m => m.NovelId == novelId)
.ToListAsync(ct);
return [.. members.Select(m => m.ToResponse())];
}
public async Task<NovelMemberResponse?> GrantAsync(Guid novelId, GrantAccessRequest request, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request));
grantValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Granting {NovelRole} on novel {NovelId}", request.NovelRole, novelId);
if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{
logger.LogWarning("Rejected access grant: novel {NovelId} not found", novelId);
return null;
}
await access.RequireAsync(novelId, NovelPermission.ManageAccess, ct);
var user = await userManager.FindByEmailAsync(request.Email);
if (user is null)
{
logger.LogWarning("Rejected access grant: no account for the given email");
throw new ArgumentException("No account exists with that email.");
}
var member = await db.NovelMembers.FirstOrDefaultAsync(m => m.NovelId == novelId && m.UserId == user.Id, ct);
if (member is null)
{
member = new NovelMember
{
NovelId = novelId,
UserId = user.Id,
NovelRole = request.NovelRole,
GrantedByUserId = userContext.UserId ?? Guid.Empty
};
db.NovelMembers.Add(member);
}
else
{
member.NovelRole = request.NovelRole;
member.GrantedByUserId = userContext.UserId ?? Guid.Empty;
member.GrantedAt = DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync(ct);
member.User = user;
return member.ToResponse();
}
public async Task<bool> RevokeAsync(Guid novelId, Guid userId, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
Guard.Default(userId, nameof(userId));
logger.LogInformation("Revoking access on novel {NovelId} for user {UserId}", novelId, userId);
var member = await db.NovelMembers.FirstOrDefaultAsync(m => m.NovelId == novelId && m.UserId == userId, ct);
if (member is null)
{
logger.LogWarning("No membership found for user {UserId} on novel {NovelId}", userId, novelId);
return false;
}
await access.RequireAsync(novelId, NovelPermission.ManageAccess, ct);
db.NovelMembers.Remove(member);
await db.SaveChangesAsync(ct);
return true;
}
}
@@ -1,6 +1,6 @@
namespace Novelly.Api.Users; namespace Novelly.Api.Users;
public enum ProjectRole public enum NovelRole
{ {
Writer, Writer,
Editor, Editor,
@@ -1,89 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.Projects;
namespace Novelly.Api.Users;
public enum ProjectPermission
{
Read,
Write,
CreateContent,
DeleteContent,
ManageAccess
}
public class ProjectAccessService(INovelDbContext db, INovelUserContext userContext, ILogger<ProjectAccessService> logger)
{
public void RequireCanCreateProject()
{
if (userContext.GlobalRole is GlobalRole.Admin or GlobalRole.Writer)
return;
logger.LogWarning("User {UserId} denied novel creation, global role {GlobalRole}", userContext.UserId, userContext.GlobalRole);
throw new NotAuthorizedException("Only writers and admins can create novels.");
}
public async Task RequireAsync(Guid projectId, ProjectPermission permission, CancellationToken ct = default)
{
if (userContext.GlobalRole == GlobalRole.Admin)
return;
var project = await db.Projects.AsNoTracking().Select(p => new { p.Id, p.OwnerId }).FirstOrDefaultAsync(p => p.Id == projectId, ct);
if (project is null)
{
logger.LogWarning("Access check against missing project {ProjectId}", projectId);
throw new NotAuthorizedException("Not permitted.");
}
if (project.OwnerId is not null && project.OwnerId == userContext.UserId)
return;
var member = userContext.UserId is null
? null
: await db.ProjectMembers.AsNoTracking().FirstOrDefaultAsync(m => m.ProjectId == projectId && m.UserId == userContext.UserId, ct);
if (!IsAllowed(permission, member?.ProjectRole))
{
logger.LogWarning("User {UserId} denied {Permission} on project {ProjectId}", userContext.UserId, permission, projectId);
throw new NotAuthorizedException($"Not permitted to {permission} on this novel.");
}
}
public async Task<string?> GetMyRoleAsync(Project project, CancellationToken ct = default)
{
if (userContext.GlobalRole == GlobalRole.Admin)
return "Admin";
if (project.OwnerId is not null && project.OwnerId == userContext.UserId)
return "Owner";
if (userContext.UserId is null)
return null;
var member = await db.ProjectMembers.AsNoTracking()
.FirstOrDefaultAsync(m => m.ProjectId == project.Id && m.UserId == userContext.UserId, ct);
return member?.ProjectRole.ToString();
}
public IQueryable<Project> VisibleProjects()
{
if (userContext.GlobalRole == GlobalRole.Admin)
return db.Projects;
var userId = userContext.UserId;
return db.Projects.Where(p => p.OwnerId == userId || p.Members.Any(m => m.UserId == userId));
}
private static bool IsAllowed(ProjectPermission permission, ProjectRole? role) => permission switch
{
ProjectPermission.Read => role is not null,
ProjectPermission.Write => role is ProjectRole.Writer or ProjectRole.Editor,
ProjectPermission.CreateContent => role is ProjectRole.Writer,
ProjectPermission.DeleteContent => role is ProjectRole.Writer,
ProjectPermission.ManageAccess => false,
_ => false
};
}
@@ -1,28 +0,0 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Users;
public static class ProjectMemberEndpoints
{
public static IEndpointRouteBuilder MapProjectMemberEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/projects/{projectId:guid}/members").WithTags("ProjectMembers")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
group.MapGet("/", async (Guid projectId, ProjectMemberService service, CancellationToken ct) =>
(await service.ListAsync(projectId, ct))?.ToApiResult())
.WithSummary("List everyone granted access to a novel.");
group.MapPost("/", async (Guid projectId, GrantAccessRequest request, ProjectMemberService service, CancellationToken ct) =>
(await service.GrantAsync(projectId, request, ct))?.ToApiResult())
.WithSummary("Grant a role on a novel to another account.");
group.MapDelete("/{userId:guid}", async (Guid projectId, Guid userId, ProjectMemberService service, CancellationToken ct) =>
await service.RevokeAsync(projectId, userId, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Revoke an account's access to a novel.");
return app;
}
}
@@ -1,107 +0,0 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
namespace Novelly.Api.Users;
public class ProjectMemberService(
INovelDbContext db,
ProjectAccessService access,
UserManager<NovellyUser> userManager,
INovelUserContext userContext,
ILogger<ProjectMemberService> logger,
IModelValidator<GrantAccessRequest> grantValidator)
{
public async Task<IReadOnlyList<ProjectMemberResponse>?> ListAsync(Guid projectId, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
logger.LogInformation("Listing members for project {ProjectId}", projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
logger.LogWarning("Rejected member listing: project {ProjectId} not found", projectId);
return null;
}
await access.RequireAsync(projectId, ProjectPermission.ManageAccess, ct);
var members = await db.ProjectMembers
.Include(m => m.User)
.Where(m => m.ProjectId == projectId)
.ToListAsync(ct);
return [.. members.Select(m => m.ToResponse())];
}
public async Task<ProjectMemberResponse?> GrantAsync(Guid projectId, GrantAccessRequest request, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request));
grantValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Granting {ProjectRole} on project {ProjectId}", request.ProjectRole, projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
logger.LogWarning("Rejected access grant: project {ProjectId} not found", projectId);
return null;
}
await access.RequireAsync(projectId, ProjectPermission.ManageAccess, ct);
var user = await userManager.FindByEmailAsync(request.Email);
if (user is null)
{
logger.LogWarning("Rejected access grant: no account for the given email");
throw new ArgumentException("No account exists with that email.");
}
var member = await db.ProjectMembers.FirstOrDefaultAsync(m => m.ProjectId == projectId && m.UserId == user.Id, ct);
if (member is null)
{
member = new ProjectMember
{
ProjectId = projectId,
UserId = user.Id,
ProjectRole = request.ProjectRole,
GrantedByUserId = userContext.UserId ?? Guid.Empty
};
db.ProjectMembers.Add(member);
}
else
{
member.ProjectRole = request.ProjectRole;
member.GrantedByUserId = userContext.UserId ?? Guid.Empty;
member.GrantedAt = DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync(ct);
member.User = user;
return member.ToResponse();
}
public async Task<bool> RevokeAsync(Guid projectId, Guid userId, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
Guard.Default(userId, nameof(userId));
logger.LogInformation("Revoking access on project {ProjectId} for user {UserId}", projectId, userId);
var member = await db.ProjectMembers.FirstOrDefaultAsync(m => m.ProjectId == projectId && m.UserId == userId, ct);
if (member is null)
{
logger.LogWarning("No membership found for user {UserId} on project {ProjectId}", userId, projectId);
return false;
}
await access.RequireAsync(projectId, ProjectPermission.ManageAccess, ct);
db.ProjectMembers.Remove(member);
await db.SaveChangesAsync(ct);
return true;
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ public class UserAccountService(
if (isFirstAccount) if (isFirstAccount)
{ {
logger.LogInformation("Adopting orphaned novels under first account {UserId}", user.Id); logger.LogInformation("Adopting orphaned novels under first account {UserId}", user.Id);
await db.Projects.Where(p => p.OwnerId == null).ExecuteUpdateAsync(set => set.SetProperty(p => p.OwnerId, user.Id), ct); await db.Novels.Where(p => p.OwnerId == null).ExecuteUpdateAsync(set => set.SetProperty(p => p.OwnerId, user.Id), ct);
} }
await signInManager.SignInAsync(user, isPersistent: true); await signInManager.SignInAsync(user, isPersistent: true);
+8 -8
View File
@@ -8,12 +8,12 @@ namespace Novelly.Mcp.Tools;
public static class CharacterTools public static class CharacterTools
{ {
[McpServerTool(Name = "list_characters")] [McpServerTool(Name = "list_characters")]
[Description("List a project's character dossiers in full, including their relationships.")] [Description("List a novel's character dossiers in full, including their relationships.")]
public static Task<CallToolResult> ListCharacters( public static Task<CallToolResult> ListCharacters(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
CancellationToken ct) => CancellationToken ct) =>
api.GetAsync($"/api/projects/{projectId}/characters", ct); api.GetAsync($"/api/novels/{novelId}/characters", ct);
[McpServerTool(Name = "get_character")] [McpServerTool(Name = "get_character")]
[Description("Read one character's dossier.")] [Description("Read one character's dossier.")]
@@ -24,11 +24,11 @@ public static class CharacterTools
api.GetAsync($"/api/characters/{characterId}", ct); api.GetAsync($"/api/characters/{characterId}", ct);
[McpServerTool(Name = "create_character")] [McpServerTool(Name = "create_character")]
[Description("Add a character dossier to a project. Name is the only requirement — leave a field " [Description("Add a character dossier to a novel. Name is the only requirement — leave a field "
+ "blank when the writer has not decided it yet rather than inventing detail.")] + "blank when the writer has not decided it yet rather than inventing detail.")]
public static Task<CallToolResult> CreateCharacter( public static Task<CallToolResult> CreateCharacter(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
[Description("The character's name.")] string name, [Description("The character's name.")] string name,
CancellationToken ct, CancellationToken ct,
[Description("Protagonist, Antagonist, Deuteragonist, Supporting, Minor, Mentor, LoveInterest or Foil.")] [Description("Protagonist, Antagonist, Deuteragonist, Supporting, Minor, Mentor, LoveInterest or Foil.")]
@@ -50,7 +50,7 @@ public static class CharacterTools
[Description("Anything else worth recording.")] string? notes = null, [Description("Anything else worth recording.")] string? notes = null,
[Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null, [Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null,
[Description("Other names this character is known by.")] string[]? aliases = null) => [Description("Other names this character is known by.")] string[]? aliases = null) =>
api.PostAsync($"/api/projects/{projectId}/characters", new api.PostAsync($"/api/novels/{novelId}/characters", new
{ {
name, name,
role = role ?? "Supporting", role = role ?? "Supporting",
@@ -197,7 +197,7 @@ public static class CharacterTools
api.PostAsync($"/api/arc-stages/{arcStageId}/beats", new { beatIds }, ct); api.PostAsync($"/api/arc-stages/{arcStageId}/beats", new { beatIds }, ct);
[McpServerTool(Name = "relate_characters")] [McpServerTool(Name = "relate_characters")]
[Description("Record a relationship between two characters in the same project. Creates both directions " [Description("Record a relationship between two characters in the same novel. Creates both directions "
+ "at once — characterId's side and relatedCharacterId's side — so the pair always shows up " + "at once — characterId's side and relatedCharacterId's side — so the pair always shows up "
+ "on both dossiers.")] + "on both dossiers.")]
public static Task<CallToolResult> RelateCharacters( public static Task<CallToolResult> RelateCharacters(
@@ -215,7 +215,7 @@ public static class CharacterTools
[McpServerTool(Name = "link_character_identity")] [McpServerTool(Name = "link_character_identity")]
[Description("Record that this character is really another character — e.g. a character introduced " [Description("Record that this character is really another character — e.g. a character introduced "
+ "under one name who is later revealed to be a character already in the project under " + "under one name who is later revealed to be a character already in the novel under "
+ "another name. Both characters keep their own dossier and beats; the canonical identity " + "another name. Both characters keep their own dossier and beats; the canonical identity "
+ "is whichever character you link to.")] + "is whichever character you link to.")]
public static Task<CallToolResult> LinkCharacterIdentity( public static Task<CallToolResult> LinkCharacterIdentity(
+6 -6
View File
@@ -8,12 +8,12 @@ namespace Novelly.Mcp.Tools;
public static class ManuscriptTools public static class ManuscriptTools
{ {
[McpServerTool(Name = "list_chapters")] [McpServerTool(Name = "list_chapters")]
[Description("List a project's chapters in manuscript order, with beat and word counts.")] [Description("List a novel's chapters in manuscript order, with beat and word counts.")]
public static Task<CallToolResult> ListChapters( public static Task<CallToolResult> ListChapters(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
CancellationToken ct) => CancellationToken ct) =>
api.GetAsync($"/api/projects/{projectId}/chapters", ct); api.GetAsync($"/api/novels/{novelId}/chapters", ct);
[McpServerTool(Name = "get_chapter")] [McpServerTool(Name = "get_chapter")]
[Description("Read one chapter in full: its outline (beats) and its drafted prose.")] [Description("Read one chapter in full: its outline (beats) and its drafted prose.")]
@@ -24,10 +24,10 @@ public static class ManuscriptTools
api.GetAsync($"/api/chapters/{chapterId}", ct); api.GetAsync($"/api/chapters/{chapterId}", ct);
[McpServerTool(Name = "create_chapter")] [McpServerTool(Name = "create_chapter")]
[Description("Add a chapter to a project. It goes at the end of the manuscript unless you supply a number.")] [Description("Add a chapter to a novel. It goes at the end of the manuscript unless you supply a number.")]
public static Task<CallToolResult> CreateChapter( public static Task<CallToolResult> CreateChapter(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
[Description("Chapter title.")] string title, [Description("Chapter title.")] string title,
CancellationToken ct, CancellationToken ct,
[Description("Position in the manuscript, 1-based.")] int? number = null, [Description("Position in the manuscript, 1-based.")] int? number = null,
@@ -37,7 +37,7 @@ public static class ManuscriptTools
[Description("Target length in words.")] int? targetWordCount = null, [Description("Target length in words.")] int? targetWordCount = null,
[Description("The chapter's drafted text, in markdown, if you are writing it now.")] string? prose = null, [Description("The chapter's drafted text, in markdown, if you are writing it now.")] string? prose = null,
[Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) => [Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) =>
api.PostAsync($"/api/projects/{projectId}/chapters", new api.PostAsync($"/api/novels/{novelId}/chapters", new
{ {
title, title,
number, number,
@@ -5,25 +5,25 @@ using ModelContextProtocol.Server;
namespace Novelly.Mcp.Tools; namespace Novelly.Mcp.Tools;
[McpServerToolType] [McpServerToolType]
public static class ProjectTools public static class NovelTools
{ {
[McpServerTool(Name = "list_projects")] [McpServerTool(Name = "list_novels")]
[Description("List every novel project, with counts of characters, chapters and drafted words. " [Description("List every novel, with counts of characters, chapters and drafted words. "
+ "Start here to find the project id everything else needs.")] + "Start here to find the novel id everything else needs.")]
public static Task<CallToolResult> ListProjects(NovelApiClient api, CancellationToken ct) => public static Task<CallToolResult> ListNovels(NovelApiClient api, CancellationToken ct) =>
api.GetAsync("/api/projects", ct); api.GetAsync("/api/novels", ct);
[McpServerTool(Name = "get_project_brief")] [McpServerTool(Name = "get_novel_brief")]
[Description("Read a project's title, author, genre, logline, synopsis, notes and word-count target.")] [Description("Read a novel's title, author, genre, logline, synopsis, notes and word-count target.")]
public static Task<CallToolResult> GetProject( public static Task<CallToolResult> GetNovel(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
CancellationToken ct) => CancellationToken ct) =>
api.GetAsync($"/api/projects/{projectId}", ct); api.GetAsync($"/api/novels/{novelId}", ct);
[McpServerTool(Name = "create_project")] [McpServerTool(Name = "create_novel")]
[Description("Create a new novel project.")] [Description("Create a new novel.")]
public static Task<CallToolResult> CreateProject( public static Task<CallToolResult> CreateNovel(
NovelApiClient api, NovelApiClient api,
[Description("Working title.")] string title, [Description("Working title.")] string title,
CancellationToken ct, CancellationToken ct,
@@ -33,14 +33,14 @@ public static class ProjectTools
[Description("Paragraph-length summary of the whole book.")] string? synopsis = null, [Description("Paragraph-length summary of the whole book.")] string? synopsis = null,
[Description("Free-form notes on theme, tone, comparable titles.")] string? notes = null, [Description("Free-form notes on theme, tone, comparable titles.")] string? notes = null,
[Description("Target manuscript length in words.")] int? targetWordCount = null) => [Description("Target manuscript length in words.")] int? targetWordCount = null) =>
api.PostAsync("/api/projects", new { title, author, genre, logline, synopsis, notes, targetWordCount }, ct); api.PostAsync("/api/novels", new { title, author, genre, logline, synopsis, notes, targetWordCount }, ct);
[McpServerTool(Name = "update_project_brief")] [McpServerTool(Name = "update_novel_brief")]
[Description("Revise a project's top-level fields. Only the fields you supply change; " [Description("Revise a novel's top-level fields. Only the fields you supply change; "
+ "pass an empty string to clear one.")] + "pass an empty string to clear one.")]
public static Task<CallToolResult> UpdateProject( public static Task<CallToolResult> UpdateNovel(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
CancellationToken ct, CancellationToken ct,
[Description("New title.")] string? title = null, [Description("New title.")] string? title = null,
[Description("Author name.")] string? author = null, [Description("Author name.")] string? author = null,
@@ -49,6 +49,6 @@ public static class ProjectTools
[Description("Paragraph-length summary of the whole book.")] string? synopsis = null, [Description("Paragraph-length summary of the whole book.")] string? synopsis = null,
[Description("Free-form notes on theme, tone, comparable titles.")] string? notes = null, [Description("Free-form notes on theme, tone, comparable titles.")] string? notes = null,
[Description("Target manuscript length in words.")] int? targetWordCount = null) => [Description("Target manuscript length in words.")] int? targetWordCount = null) =>
api.PatchAsync($"/api/projects/{projectId}", api.PatchAsync($"/api/novels/{novelId}",
new { title, author, genre, logline, synopsis, notes, targetWordCount }, ct); new { title, author, genre, logline, synopsis, notes, targetWordCount }, ct);
} }
+4 -4
View File
@@ -13,7 +13,7 @@ public static class QuestionTools
+ "thinking, not a gap to fill in for them.")] + "thinking, not a gap to fill in for them.")]
public static Task<CallToolResult> ListOpenQuestions( public static Task<CallToolResult> ListOpenQuestions(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
CancellationToken ct, CancellationToken ct,
[Description("Narrow to questions about one chapter outline.")] Guid? chapterId = null, [Description("Narrow to questions about one chapter outline.")] Guid? chapterId = null,
[Description("Narrow to questions about one character.")] Guid? characterId = null, [Description("Narrow to questions about one character.")] Guid? characterId = null,
@@ -31,7 +31,7 @@ public static class QuestionTools
query.Add($"characterId={character}"); query.Add($"characterId={character}");
} }
return api.GetAsync($"/api/projects/{projectId}/questions?{string.Join('&', query)}", ct); return api.GetAsync($"/api/novels/{novelId}/questions?{string.Join('&', query)}", ct);
} }
[McpServerTool(Name = "raise_open_question")] [McpServerTool(Name = "raise_open_question")]
@@ -39,13 +39,13 @@ public static class QuestionTools
+ "and/or the character it is about. Prefer raising a question over guessing.")] + "and/or the character it is about. Prefer raising a question over guessing.")]
public static Task<CallToolResult> RaiseOpenQuestion( public static Task<CallToolResult> RaiseOpenQuestion(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
[Description("The question, in one line.")] string question, [Description("The question, in one line.")] string question,
CancellationToken ct, CancellationToken ct,
[Description("The thinking around it — options considered, and what each costs.")] string? detail = null, [Description("The thinking around it — options considered, and what each costs.")] string? detail = null,
[Description("Id of the chapter outline this is about, if any.")] Guid? chapterId = null, [Description("Id of the chapter outline this is about, if any.")] Guid? chapterId = null,
[Description("Id of the character this is about, if any.")] Guid? characterId = null) => [Description("Id of the character this is about, if any.")] Guid? characterId = null) =>
api.PostAsync($"/api/projects/{projectId}/questions", api.PostAsync($"/api/novels/{novelId}/questions",
new { question, detail, chapterId, characterId }, ct); new { question, detail, chapterId, characterId }, ct);
[McpServerTool(Name = "update_open_question")] [McpServerTool(Name = "update_open_question")]
+6 -6
View File
@@ -8,13 +8,13 @@ namespace Novelly.Mcp.Tools;
public static class TagTools public static class TagTools
{ {
[McpServerTool(Name = "list_tags")] [McpServerTool(Name = "list_tags")]
[Description("List a project's tags with how many characters, chapters and beats carry each. " [Description("List a novel'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.")] + "Read this before inventing a new tag so you reuse the writer's vocabulary.")]
public static Task<CallToolResult> ListTags( public static Task<CallToolResult> ListTags(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
CancellationToken ct) => CancellationToken ct) =>
api.GetAsync($"/api/projects/{projectId}/tags", ct); api.GetAsync($"/api/novels/{novelId}/tags", ct);
[McpServerTool(Name = "get_tag_references")] [McpServerTool(Name = "get_tag_references")]
[Description("Cross-reference a tag: every character, chapter and beat carrying it. Use this " [Description("Cross-reference a tag: every character, chapter and beat carrying it. Use this "
@@ -30,11 +30,11 @@ public static class TagTools
+ "chapter or beat also creates it, so this is only needed to set a colour up front.")] + "chapter or beat also creates it, so this is only needed to set a colour up front.")]
public static Task<CallToolResult> CreateTag( public static Task<CallToolResult> CreateTag(
NovelApiClient api, NovelApiClient api,
[Description("The project's id.")] Guid projectId, [Description("The novel's id.")] Guid novelId,
[Description("The tag's name. Unique within the project, matched case-insensitively.")] string name, [Description("The tag's name. Unique within the novel, matched case-insensitively.")] string name,
CancellationToken ct, CancellationToken ct,
[Description("Optional hex colour for the UI, e.g. \"#9a4a2f\".")] string? color = null) => [Description("Optional hex colour for the UI, e.g. \"#9a4a2f\".")] string? color = null) =>
api.PostAsync($"/api/projects/{projectId}/tags", new { name, color }, ct); api.PostAsync($"/api/novels/{novelId}/tags", new { name, color }, ct);
[McpServerTool(Name = "update_tag")] [McpServerTool(Name = "update_tag")]
[Description("Rename or recolour a tag. Renaming updates it everywhere it is applied.")] [Description("Rename or recolour a tag. Renaming updates it everywhere it is applied.")]
+2 -2
View File
@@ -11,8 +11,8 @@ using OpenTelemetry.Trace;
namespace Microsoft.Extensions.Hosting; namespace Microsoft.Extensions.Hosting;
// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. // Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
// This project should be referenced by each service project in your solution. // This novel should be referenced by each service novel in your solution.
// To learn more about using this project, see https://aka.ms/aspire/service-defaults // To learn more about using this novel, see https://aka.ms/aspire/service-defaults
public static class Extensions public static class Extensions
{ {
private const string HealthEndpointPath = "/health"; private const string HealthEndpointPath = "/health";
+5 -5
View File
@@ -1,6 +1,6 @@
import { Navigate, Outlet, Route, Routes } from 'react-router-dom' import { Navigate, Outlet, Route, Routes } from 'react-router-dom'
import ProjectsPage from './pages/ProjectsPage' import NovelsPage from './pages/NovelsPage'
import ProjectLayout from './pages/ProjectLayout' import NovelLayout from './pages/NovelLayout'
import DashboardPage from './pages/DashboardPage' import DashboardPage from './pages/DashboardPage'
import CharactersPage from './pages/CharactersPage' import CharactersPage from './pages/CharactersPage'
import CharacterDetailPage from './pages/CharacterDetailPage' import CharacterDetailPage from './pages/CharacterDetailPage'
@@ -34,8 +34,8 @@ export default function App() {
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}> <Route element={<RequireAuth />}>
<Route path="/" element={<ProjectsPage />} /> <Route path="/" element={<NovelsPage />} />
<Route path="/projects/:projectId" element={<ProjectLayout />}> <Route path="/novels/:novelId" element={<NovelLayout />}>
<Route index element={<DashboardPage />} /> <Route index element={<DashboardPage />} />
<Route path="characters" element={<CharactersPage />} /> <Route path="characters" element={<CharactersPage />} />
<Route path="characters/:characterId" element={<CharacterDetailPage />} /> <Route path="characters/:characterId" element={<CharacterDetailPage />} />
@@ -45,7 +45,7 @@ export default function App() {
<Route path="agent" element={<AgentPage />} /> <Route path="agent" element={<AgentPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
</Route> </Route>
<Route path="*" element={<ProjectsPage />} /> <Route path="*" element={<NovelsPage />} />
</Route> </Route>
</Routes> </Routes>
</HelpOverlayProvider> </HelpOverlayProvider>
+112 -112
View File
@@ -15,10 +15,10 @@ import type {
ImportJob, ImportJob,
ImportJobStatus, ImportJobStatus,
OpenQuestion, OpenQuestion,
Project, Novel,
ProjectMember, NovelMember,
ProjectRole, NovelRole,
ProjectSummary, NovelSummary,
TagReferences, TagReferences,
TagSummary, TagSummary,
User, User,
@@ -26,18 +26,18 @@ import type {
export const keys = { export const keys = {
me: ['me'] as const, me: ['me'] as const,
members: (projectId: string) => ['projects', projectId, 'members'] as const, members: (novelId: string) => ['novels', novelId, 'members'] as const,
projects: ['projects'] as const, novels: ['novels'] as const,
genres: ['genres'] as const, genres: ['genres'] as const,
project: (id: string) => ['projects', id] as const, novel: (id: string) => ['novels', id] as const,
characters: (projectId: string) => ['projects', projectId, 'characters'] as const, characters: (novelId: string) => ['novels', novelId, 'characters'] as const,
tags: (projectId: string) => ['projects', projectId, 'tags'] as const, tags: (novelId: string) => ['novels', novelId, 'tags'] as const,
tagRefs: (tagId: string) => ['tags', tagId, 'references'] as const, tagRefs: (tagId: string) => ['tags', tagId, 'references'] as const,
characterBeats: (characterId: string) => ['characters', characterId, 'beats'] as const, characterBeats: (characterId: string) => ['characters', characterId, 'beats'] as const,
chapters: (projectId: string) => ['projects', projectId, 'chapters'] as const, chapters: (novelId: string) => ['novels', novelId, 'chapters'] as const,
questions: (projectId: string) => ['projects', projectId, 'questions'] as const, questions: (novelId: string) => ['novels', novelId, 'questions'] as const,
chapter: (id: string) => ['chapters', id] as const, chapter: (id: string) => ['chapters', id] as const,
conversations: (projectId: string) => ['projects', projectId, 'conversations'] as const, conversations: (novelId: string) => ['novels', novelId, 'conversations'] as const,
conversation: (id: string) => ['conversations', id] as const, conversation: (id: string) => ['conversations', id] as const,
importJob: (id: string) => ['imports', id] as const, importJob: (id: string) => ['imports', id] as const,
} }
@@ -82,103 +82,103 @@ export function useLogout() {
}) })
} }
export const useProjectMembers = (projectId: string) => export const useNovelMembers = (novelId: string) =>
useQuery({ useQuery({
queryKey: keys.members(projectId), queryKey: keys.members(novelId),
queryFn: () => api.get<ProjectMember[]>(`/api/projects/${projectId}/members`), queryFn: () => api.get<NovelMember[]>(`/api/novels/${novelId}/members`),
retry: false, retry: false,
}) })
export function useGrantAccess(projectId: string) { export function useGrantAccess(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (body: { email: string; projectRole: ProjectRole }) => mutationFn: (body: { email: string; novelRole: NovelRole }) =>
api.post<ProjectMember>(`/api/projects/${projectId}/members`, body), api.post<NovelMember>(`/api/novels/${novelId}/members`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(novelId) }),
}) })
} }
export function useRevokeAccess(projectId: string) { export function useRevokeAccess(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (userId: string) => api.delete(`/api/projects/${projectId}/members/${userId}`), mutationFn: (userId: string) => api.delete(`/api/novels/${novelId}/members/${userId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(novelId) }),
}) })
} }
export const useProjects = () => export const useNovels = () =>
useQuery({ queryKey: keys.projects, queryFn: () => api.get<ProjectSummary[]>('/api/projects') }) useQuery({ queryKey: keys.novels, queryFn: () => api.get<NovelSummary[]>('/api/novels') })
export const useProject = (id: string) => export const useNovel = (id: string) =>
useQuery({ queryKey: keys.project(id), queryFn: () => api.get<Project>(`/api/projects/${id}`) }) useQuery({ queryKey: keys.novel(id), queryFn: () => api.get<Novel>(`/api/novels/${id}`) })
export function useCreateProject() { export function useCreateNovel() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (body: { title: string; author?: string; genre?: string; logline?: string }) => mutationFn: (body: { title: string; author?: string; genre?: string; logline?: string }) =>
api.post<Project>('/api/projects', body), api.post<Novel>('/api/novels', body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.projects }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.novels }),
}) })
} }
export function useUpdateProject(id: string) { export function useUpdateNovel(id: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (body: Partial<Project>) => api.patch<Project>(`/api/projects/${id}`, body), mutationFn: (body: Partial<Novel>) => api.patch<Novel>(`/api/novels/${id}`, body),
onSuccess: (updated) => { onSuccess: (updated) => {
qc.setQueryData(keys.project(id), updated) qc.setQueryData(keys.novel(id), updated)
qc.invalidateQueries({ queryKey: keys.projects }) qc.invalidateQueries({ queryKey: keys.novels })
}, },
}) })
} }
export function useDeleteProject() { export function useDeleteNovel() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (id: string) => api.delete(`/api/projects/${id}`), mutationFn: (id: string) => api.delete(`/api/novels/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.projects }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.novels }),
}) })
} }
export const useCharacters = (projectId: string) => export const useCharacters = (novelId: string) =>
useQuery({ useQuery({
queryKey: keys.characters(projectId), queryKey: keys.characters(novelId),
queryFn: () => api.get<Character[]>(`/api/projects/${projectId}/characters`), queryFn: () => api.get<Character[]>(`/api/novels/${novelId}/characters`),
}) })
export function useCreateCharacter(projectId: string) { export function useCreateCharacter(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (body: Partial<Character> & { name: string }) => mutationFn: (body: Partial<Character> & { name: string }) =>
api.post<Character>(`/api/projects/${projectId}/characters`, body), api.post<Character>(`/api/novels/${novelId}/characters`, body),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.characters(projectId) }) qc.invalidateQueries({ queryKey: keys.characters(novelId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) }) qc.invalidateQueries({ queryKey: keys.tags(novelId) })
}, },
}) })
} }
export function useUpdateCharacter(projectId: string) { export function useUpdateCharacter(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: Partial<Omit<Character, 'tags'>> & { id: string; tags?: string[] }) => mutationFn: ({ id, ...body }: Partial<Omit<Character, 'tags'>> & { id: string; tags?: string[] }) =>
api.patch<Character>(`/api/characters/${id}`, body), api.patch<Character>(`/api/characters/${id}`, body),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.characters(projectId) }) qc.invalidateQueries({ queryKey: keys.characters(novelId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) }) qc.invalidateQueries({ queryKey: keys.tags(novelId) })
}, },
}) })
} }
export function useDeleteCharacter(projectId: string) { export function useDeleteCharacter(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (id: string) => api.delete(`/api/characters/${id}`), mutationFn: (id: string) => api.delete(`/api/characters/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
}) })
} }
export function useLinkCharacterIdentity(projectId: string) { export function useLinkCharacterIdentity(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ mutationFn: ({
@@ -192,19 +192,19 @@ export function useLinkCharacterIdentity(projectId: string) {
revealedInChapterId?: string | null revealedInChapterId?: string | null
note?: string | null note?: string | null
}) => api.put<Character>(`/api/characters/${id}/identity`, { sameCharacterAsId, revealedInChapterId, note }), }) => api.put<Character>(`/api/characters/${id}/identity`, { sameCharacterAsId, revealedInChapterId, note }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
}) })
} }
export function useUnlinkCharacterIdentity(projectId: string) { export function useUnlinkCharacterIdentity(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (id: string) => api.delete(`/api/characters/${id}/identity`), mutationFn: (id: string) => api.delete(`/api/characters/${id}/identity`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
}) })
} }
export function useAddRelationship(projectId: string) { export function useAddRelationship(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ mutationFn: ({
@@ -226,15 +226,15 @@ export function useAddRelationship(projectId: string) {
reciprocalRelationshipType, reciprocalRelationshipType,
description, description,
}), }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
}) })
} }
export function useRemoveRelationship(projectId: string) { export function useRemoveRelationship(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (relationshipId: string) => api.delete(`/api/characters/relationships/${relationshipId}`), mutationFn: (relationshipId: string) => api.delete(`/api/characters/relationships/${relationshipId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
}) })
} }
@@ -245,55 +245,55 @@ export const useCharacterBeats = (characterId: string | undefined) =>
enabled: Boolean(characterId), enabled: Boolean(characterId),
}) })
export function useCreateArcStage(projectId: string) { export function useCreateArcStage(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ characterId, ...body }: { characterId: string; title: string; result?: string; chapterId?: string }) => mutationFn: ({ characterId, ...body }: { characterId: string; title: string; result?: string; chapterId?: string }) =>
api.post<ArcStage>(`/api/characters/${characterId}/arc`, body), api.post<ArcStage>(`/api/characters/${characterId}/arc`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
}) })
} }
export function useUpdateArcStage(projectId: string) { export function useUpdateArcStage(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: { id: string; title?: string; result?: string; chapterId?: string }) => mutationFn: ({ id, ...body }: { id: string; title?: string; result?: string; chapterId?: string }) =>
api.patch<ArcStage>(`/api/arc-stages/${id}`, body), api.patch<ArcStage>(`/api/arc-stages/${id}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
}) })
} }
export function useSetArcStageBeats(projectId: string, characterId: string | undefined) { export function useSetArcStageBeats(novelId: string, characterId: string | undefined) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, beatIds }: { id: string; beatIds: string[] }) => mutationFn: ({ id, beatIds }: { id: string; beatIds: string[] }) =>
api.post<ArcStage>(`/api/arc-stages/${id}/beats`, { beatIds }), api.post<ArcStage>(`/api/arc-stages/${id}/beats`, { beatIds }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.characters(projectId) }) qc.invalidateQueries({ queryKey: keys.characters(novelId) })
qc.invalidateQueries({ queryKey: keys.characterBeats(characterId ?? '') }) qc.invalidateQueries({ queryKey: keys.characterBeats(characterId ?? '') })
}, },
}) })
} }
export function useDeleteArcStage(projectId: string) { export function useDeleteArcStage(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (id: string) => api.delete(`/api/arc-stages/${id}`), mutationFn: (id: string) => api.delete(`/api/arc-stages/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
}) })
} }
export function useReorderArcStages(projectId: string) { export function useReorderArcStages(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ characterId, stageIds }: { characterId: string; stageIds: string[] }) => mutationFn: ({ characterId, stageIds }: { characterId: string; stageIds: string[] }) =>
api.post<ArcStage[]>(`/api/characters/${characterId}/arc/reorder`, { stageIds }), api.post<ArcStage[]>(`/api/characters/${characterId}/arc/reorder`, { stageIds }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
}) })
} }
export const useOpenQuestions = ( export const useOpenQuestions = (
projectId: string, novelId: string,
filter: { chapterId?: string; characterId?: string; includeResolved?: boolean } = {}, filter: { chapterId?: string; characterId?: string; includeResolved?: boolean } = {},
) => { ) => {
const params = new URLSearchParams() const params = new URLSearchParams()
@@ -303,66 +303,66 @@ export const useOpenQuestions = (
const query = params.toString() const query = params.toString()
return useQuery({ return useQuery({
queryKey: [...keys.questions(projectId), query] as const, queryKey: [...keys.questions(novelId), query] as const,
queryFn: () => queryFn: () =>
api.get<OpenQuestion[]>(`/api/projects/${projectId}/questions${query ? `?${query}` : ''}`), api.get<OpenQuestion[]>(`/api/novels/${novelId}/questions${query ? `?${query}` : ''}`),
}) })
} }
export function useRaiseQuestion(projectId: string) { export function useRaiseQuestion(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (body: { question: string; detail?: string; chapterId?: string; characterId?: string }) => mutationFn: (body: { question: string; detail?: string; chapterId?: string; characterId?: string }) =>
api.post<OpenQuestion>(`/api/projects/${projectId}/questions`, body), api.post<OpenQuestion>(`/api/novels/${novelId}/questions`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(novelId) }),
}) })
} }
export function useUpdateQuestion(projectId: string) { export function useUpdateQuestion(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: { id: string; question?: string; detail?: string }) => mutationFn: ({ id, ...body }: { id: string; question?: string; detail?: string }) =>
api.patch<OpenQuestion>(`/api/questions/${id}`, body), api.patch<OpenQuestion>(`/api/questions/${id}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(novelId) }),
}) })
} }
export function useResolveQuestion(projectId: string) { export function useResolveQuestion(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, resolution, appendToNotes }: { id: string; resolution: string; appendToNotes: boolean }) => mutationFn: ({ id, resolution, appendToNotes }: { id: string; resolution: string; appendToNotes: boolean }) =>
api.post<OpenQuestion>(`/api/questions/${id}/resolve`, { resolution, appendToNotes }), api.post<OpenQuestion>(`/api/questions/${id}/resolve`, { resolution, appendToNotes }),
onSuccess: (question) => { onSuccess: (question) => {
qc.invalidateQueries({ queryKey: keys.questions(projectId) }) qc.invalidateQueries({ queryKey: keys.questions(novelId) })
qc.invalidateQueries({ queryKey: keys.characters(projectId) }) qc.invalidateQueries({ queryKey: keys.characters(novelId) })
if (question.chapterId) qc.invalidateQueries({ queryKey: keys.chapter(question.chapterId) }) if (question.chapterId) qc.invalidateQueries({ queryKey: keys.chapter(question.chapterId) })
}, },
}) })
} }
export function useReopenQuestion(projectId: string) { export function useReopenQuestion(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (id: string) => api.post<OpenQuestion>(`/api/questions/${id}/reopen`, {}), mutationFn: (id: string) => api.post<OpenQuestion>(`/api/questions/${id}/reopen`, {}),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(novelId) }),
}) })
} }
export function useDeleteQuestion(projectId: string) { export function useDeleteQuestion(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (id: string) => api.delete(`/api/questions/${id}`), mutationFn: (id: string) => api.delete(`/api/questions/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(novelId) }),
}) })
} }
export const useGenres = () => export const useGenres = () =>
useQuery({ queryKey: keys.genres, queryFn: () => api.get<Genre[]>('/api/genres') }) useQuery({ queryKey: keys.genres, queryFn: () => api.get<Genre[]>('/api/genres') })
export const useTags = (projectId: string) => export const useTags = (novelId: string) =>
useQuery({ useQuery({
queryKey: keys.tags(projectId), queryKey: keys.tags(novelId),
queryFn: () => api.get<TagSummary[]>(`/api/projects/${projectId}/tags`), queryFn: () => api.get<TagSummary[]>(`/api/novels/${novelId}/tags`),
}) })
export const useTagReferences = (tagId: string | undefined) => export const useTagReferences = (tagId: string | undefined) =>
@@ -372,13 +372,13 @@ export const useTagReferences = (tagId: string | undefined) =>
enabled: Boolean(tagId), enabled: Boolean(tagId),
}) })
export function useUpdateTag(projectId: string) { export function useUpdateTag(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: { id: string; name?: string; color?: string }) => mutationFn: ({ id, ...body }: { id: string; name?: string; color?: string }) =>
api.patch<TagSummary>(`/api/tags/${id}`, body), api.patch<TagSummary>(`/api/tags/${id}`, body),
onSuccess: (_, { id }) => { onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: keys.tags(projectId) }) qc.invalidateQueries({ queryKey: keys.tags(novelId) })
qc.invalidateQueries({ queryKey: keys.tagRefs(id) }) qc.invalidateQueries({ queryKey: keys.tagRefs(id) })
}, },
}) })
@@ -392,7 +392,7 @@ export function useDeleteTag() {
}) })
} }
export function useCreateBeat(chapterId: string, projectId: string) { export function useCreateBeat(chapterId: string, novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ( mutationFn: (
@@ -400,12 +400,12 @@ export function useCreateBeat(chapterId: string, projectId: string) {
) => api.post<Beat>(`/api/chapters/${chapterId}/beats`, body), ) => api.post<Beat>(`/api/chapters/${chapterId}/beats`, body),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }) qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) }) qc.invalidateQueries({ queryKey: keys.tags(novelId) })
}, },
}) })
} }
export function useUpdateBeat(chapterId: string, projectId: string) { export function useUpdateBeat(chapterId: string, novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ mutationFn: ({
@@ -415,7 +415,7 @@ export function useUpdateBeat(chapterId: string, projectId: string) {
api.patch<Beat>(`/api/beats/${id}`, body), api.patch<Beat>(`/api/beats/${id}`, body),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }) qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) }) qc.invalidateQueries({ queryKey: keys.tags(novelId) })
}, },
}) })
} }
@@ -458,10 +458,10 @@ export function useMoveBeats(chapterId: string) {
}) })
} }
export const useChapters = (projectId: string) => export const useChapters = (novelId: string) =>
useQuery({ useQuery({
queryKey: keys.chapters(projectId), queryKey: keys.chapters(novelId),
queryFn: () => api.get<ChapterSummary[]>(`/api/projects/${projectId}/chapters`), queryFn: () => api.get<ChapterSummary[]>(`/api/novels/${novelId}/chapters`),
}) })
export const useChapter = (id: string | undefined) => export const useChapter = (id: string | undefined) =>
@@ -471,40 +471,40 @@ export const useChapter = (id: string | undefined) =>
enabled: Boolean(id), enabled: Boolean(id),
}) })
export function useCreateChapter(projectId: string) { export function useCreateChapter(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (body: Partial<Chapter> & { title: string }) => mutationFn: (body: Partial<Chapter> & { title: string }) =>
api.post<Chapter>(`/api/projects/${projectId}/chapters`, body), api.post<Chapter>(`/api/novels/${novelId}/chapters`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(novelId) }),
}) })
} }
export function useUpdateChapter(projectId: string) { export function useUpdateChapter(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: Partial<Omit<Chapter, 'tags'>> & { id: string; tags?: string[] }) => mutationFn: ({ id, ...body }: Partial<Omit<Chapter, 'tags'>> & { id: string; tags?: string[] }) =>
api.patch<Chapter>(`/api/chapters/${id}`, body), api.patch<Chapter>(`/api/chapters/${id}`, body),
onSuccess: (updated) => { onSuccess: (updated) => {
qc.setQueryData(keys.chapter(updated.id), updated) qc.setQueryData(keys.chapter(updated.id), updated)
qc.invalidateQueries({ queryKey: keys.chapters(projectId) }) qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) }) qc.invalidateQueries({ queryKey: keys.tags(novelId) })
}, },
}) })
} }
export function useDeleteChapter(projectId: string) { export function useDeleteChapter(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (id: string) => api.delete(`/api/chapters/${id}`), mutationFn: (id: string) => api.delete(`/api/chapters/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(novelId) }),
}) })
} }
export const useConversations = (projectId: string) => export const useConversations = (novelId: string) =>
useQuery({ useQuery({
queryKey: keys.conversations(projectId), queryKey: keys.conversations(novelId),
queryFn: () => api.get<ConversationSummary[]>(`/api/projects/${projectId}/agent/conversations`), queryFn: () => api.get<ConversationSummary[]>(`/api/novels/${novelId}/agent/conversations`),
}) })
export const useConversation = (id: string | undefined) => export const useConversation = (id: string | undefined) =>
@@ -514,19 +514,19 @@ export const useConversation = (id: string | undefined) =>
enabled: Boolean(id), enabled: Boolean(id),
}) })
export function useSendAgentMessage(projectId: string) { export function useSendAgentMessage(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (body: { message: string; conversationId?: string }) => mutationFn: (body: { message: string; conversationId?: string }) =>
api.post<AgentTurn>(`/api/projects/${projectId}/agent/messages`, body), api.post<AgentTurn>(`/api/novels/${novelId}/agent/messages`, body),
onSuccess: (turn) => { onSuccess: (turn) => {
qc.invalidateQueries({ queryKey: keys.conversations(projectId) }) qc.invalidateQueries({ queryKey: keys.conversations(novelId) })
qc.invalidateQueries({ queryKey: keys.conversation(turn.conversationId) }) qc.invalidateQueries({ queryKey: keys.conversation(turn.conversationId) })
qc.invalidateQueries({ queryKey: keys.characters(projectId) }) qc.invalidateQueries({ queryKey: keys.characters(novelId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) }) qc.invalidateQueries({ queryKey: keys.tags(novelId) })
qc.invalidateQueries({ queryKey: keys.chapters(projectId) }) qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
qc.invalidateQueries({ queryKey: keys.questions(projectId) }) qc.invalidateQueries({ queryKey: keys.questions(novelId) })
qc.invalidateQueries({ queryKey: keys.project(projectId) }) qc.invalidateQueries({ queryKey: keys.novel(novelId) })
}, },
}) })
} }
+18 -18
View File
@@ -28,19 +28,19 @@ export type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Fina
export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final'] export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final']
export type ProjectPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete' export type NovelPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete'
export const projectPhases: ProjectPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete'] export const novelPhases: NovelPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete']
export type GlobalRole = 'Admin' | 'Writer' | 'Editor' | 'Reviewer' export type GlobalRole = 'Admin' | 'Writer' | 'Editor' | 'Reviewer'
export const globalRoles: GlobalRole[] = ['Admin', 'Writer', 'Editor', 'Reviewer'] export const globalRoles: GlobalRole[] = ['Admin', 'Writer', 'Editor', 'Reviewer']
export type ProjectRole = 'Writer' | 'Editor' | 'Reviewer' export type NovelRole = 'Writer' | 'Editor' | 'Reviewer'
export const projectRoles: ProjectRole[] = ['Writer', 'Editor', 'Reviewer'] export const novelRoles: NovelRole[] = ['Writer', 'Editor', 'Reviewer']
export type ProjectMyRole = 'Admin' | 'Owner' | 'Writer' | 'Editor' | 'Reviewer' export type NovelMyRole = 'Admin' | 'Owner' | 'Writer' | 'Editor' | 'Reviewer'
export interface User { export interface User {
id: string id: string
@@ -49,11 +49,11 @@ export interface User {
globalRole: GlobalRole globalRole: GlobalRole
} }
export interface ProjectMember { export interface NovelMember {
userId: string userId: string
email: string email: string
displayName: string displayName: string
projectRole: ProjectRole novelRole: NovelRole
grantedAt: string grantedAt: string
} }
@@ -62,21 +62,21 @@ export interface Genre {
name: string name: string
} }
export interface ProjectSummary { export interface NovelSummary {
id: string id: string
title: string title: string
author: string | null author: string | null
genre: string | null genre: string | null
logline: string | null logline: string | null
targetWordCount: number | null targetWordCount: number | null
phase: ProjectPhase phase: NovelPhase
characterCount: number characterCount: number
chapterCount: number chapterCount: number
wordCount: number wordCount: number
updatedAt: string updatedAt: string
} }
export interface Project { export interface Novel {
id: string id: string
title: string title: string
author: string | null author: string | null
@@ -85,9 +85,9 @@ export interface Project {
synopsis: string | null synopsis: string | null
notes: string | null notes: string | null
targetWordCount: number | null targetWordCount: number | null
phase: ProjectPhase phase: NovelPhase
ownerId: string | null ownerId: string | null
myRole: ProjectMyRole | null myRole: NovelMyRole | null
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
@@ -173,7 +173,7 @@ export interface CharacterBeat {
export interface Character { export interface Character {
id: string id: string
projectId: string novelId: string
name: string name: string
role: CharacterRole role: CharacterRole
importance: CharacterImportance importance: CharacterImportance
@@ -210,7 +210,7 @@ export interface CharacterIdentity {
export interface ChapterSummary { export interface ChapterSummary {
id: string id: string
projectId: string novelId: string
number: number number: number
title: string title: string
summary: string | null summary: string | null
@@ -233,7 +233,7 @@ export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'wordCount'>
export interface OpenQuestion { export interface OpenQuestion {
id: string id: string
projectId: string novelId: string
question: string question: string
detail: string | null detail: string | null
chapterId: string | null chapterId: string | null
@@ -264,7 +264,7 @@ export interface AgentMessage {
export interface ConversationSummary { export interface ConversationSummary {
id: string id: string
projectId: string novelId: string
title: string title: string
messageCount: number messageCount: number
updatedAt: string updatedAt: string
@@ -284,7 +284,7 @@ export type ImportJobStatus = 'Pending' | 'Running' | 'Completed' | 'Failed' | '
export interface ImportJob { export interface ImportJob {
id: string id: string
sourceRoot: string sourceRoot: string
projectId: string | null novelId: string | null
status: ImportJobStatus status: ImportJobStatus
statusMessage: string | null statusMessage: string | null
chaptersCompleted: number chaptersCompleted: number
@@ -297,7 +297,7 @@ export type ImportReadiness = 'Fresh' | 'Resumable' | 'Complete'
export interface ImportInspection { export interface ImportInspection {
readiness: ImportReadiness readiness: ImportReadiness
projectId: string | null novelId: string | null
chaptersCompleted: number chaptersCompleted: number
chaptersTotal: number chaptersTotal: number
completedPasses: string[] completedPasses: string[]
+6 -6
View File
@@ -1,10 +1,10 @@
import { createContext, useContext, useMemo, type ReactNode } from 'react' import { createContext, useContext, useMemo, type ReactNode } from 'react'
import { useMe } from '../api/hooks' import { useMe } from '../api/hooks'
import type { Project, ProjectMyRole, User } from '../api/types' import type { Novel, NovelMyRole, User } from '../api/types'
export type AuthPermission = 'CreateNovel' | 'Write' | 'CreateContent' | 'DeleteContent' | 'ManageAccess' export type AuthPermission = 'CreateNovel' | 'Write' | 'CreateContent' | 'DeleteContent' | 'ManageAccess'
const projectPermissionsByRole: Record<ProjectMyRole, AuthPermission[]> = { const novelPermissionsByRole: Record<NovelMyRole, AuthPermission[]> = {
Admin: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'], Admin: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'],
Owner: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'], Owner: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'],
Writer: ['Write', 'CreateContent', 'DeleteContent'], Writer: ['Write', 'CreateContent', 'DeleteContent'],
@@ -15,7 +15,7 @@ const projectPermissionsByRole: Record<ProjectMyRole, AuthPermission[]> = {
interface AuthValue { interface AuthValue {
user: User | null user: User | null
isPending: boolean isPending: boolean
can: (permission: AuthPermission, project?: Pick<Project, 'myRole'> | null) => boolean can: (permission: AuthPermission, novel?: Pick<Novel, 'myRole'> | null) => boolean
} }
const AuthContext = createContext<AuthValue>({ user: null, isPending: true, can: () => false }) const AuthContext = createContext<AuthValue>({ user: null, isPending: true, can: () => false })
@@ -28,10 +28,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
() => ({ () => ({
user, user,
isPending, isPending,
can: (permission, project) => { can: (permission, novel) => {
if (permission === 'CreateNovel') return user?.globalRole === 'Admin' || user?.globalRole === 'Writer' if (permission === 'CreateNovel') return user?.globalRole === 'Admin' || user?.globalRole === 'Writer'
const myRole = project?.myRole const myRole = novel?.myRole
return myRole ? projectPermissionsByRole[myRole].includes(permission) : false return myRole ? novelPermissionsByRole[myRole].includes(permission) : false
}, },
}), }),
[user, isPending], [user, isPending],
+13 -13
View File
@@ -13,22 +13,22 @@ import type { ArcStage, Character } from '../api/types'
import { AutoField, ErrorNote } from './ui' import { AutoField, ErrorNote } from './ui'
export function CharacterArc({ export function CharacterArc({
projectId, novelId,
character, character,
canWrite, canWrite,
canCreate, canCreate,
canDelete, canDelete,
}: { }: {
projectId: string novelId: string
character: Character character: Character
canWrite: boolean canWrite: boolean
canCreate: boolean canCreate: boolean
canDelete: boolean canDelete: boolean
}) { }) {
const { data: chapters } = useChapters(projectId) const { data: chapters } = useChapters(novelId)
const { data: beats } = useCharacterBeats(character.id) const { data: beats } = useCharacterBeats(character.id)
const create = useCreateArcStage(projectId) const create = useCreateArcStage(novelId)
const reorder = useReorderArcStages(projectId) const reorder = useReorderArcStages(novelId)
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
@@ -70,7 +70,7 @@ export function CharacterArc({
{stages.map((stage, index) => ( {stages.map((stage, index) => (
<ArcStageRow <ArcStageRow
key={stage.id} key={stage.id}
projectId={projectId} novelId={novelId}
stage={stage} stage={stage}
chapters={chapters ?? []} chapters={chapters ?? []}
unassignedBeats={unassignedBeats} unassignedBeats={unassignedBeats}
@@ -115,7 +115,7 @@ export function CharacterArc({
} }
function ArcStageRow({ function ArcStageRow({
projectId, novelId,
stage, stage,
chapters, chapters,
unassignedBeats, unassignedBeats,
@@ -125,7 +125,7 @@ function ArcStageRow({
canWrite, canWrite,
canDelete, canDelete,
}: { }: {
projectId: string novelId: string
stage: ArcStage stage: ArcStage
chapters: { id: string; number: number; title: string }[] chapters: { id: string; number: number; title: string }[]
unassignedBeats: { id: string; chapterNumber: number; sortOrder: number; title: string }[] unassignedBeats: { id: string; chapterNumber: number; sortOrder: number; title: string }[]
@@ -135,9 +135,9 @@ function ArcStageRow({
canWrite: boolean canWrite: boolean
canDelete: boolean canDelete: boolean
}) { }) {
const update = useUpdateArcStage(projectId) const update = useUpdateArcStage(novelId)
const remove = useDeleteArcStage(projectId) const remove = useDeleteArcStage(novelId)
const setBeats = useSetArcStageBeats(projectId, stage.characterId) const setBeats = useSetArcStageBeats(novelId, stage.characterId)
const addBeat = (beatId: string) => { const addBeat = (beatId: string) => {
if (!beatId) return if (!beatId) return
@@ -182,7 +182,7 @@ function ArcStageRow({
<Link <Link
className="shrink-0 tabular-nums underline" className="shrink-0 tabular-nums underline"
style={{ color: 'var(--accent)' }} style={{ color: 'var(--accent)' }}
to={`/projects/${projectId}/chapters/${beat.chapterId}#beat-${beat.id}`} to={`/novels/${novelId}/chapters/${beat.chapterId}#beat-${beat.id}`}
> >
{beat.chapterNumber}.{beat.sortOrder} {beat.chapterNumber}.{beat.sortOrder}
</Link> </Link>
@@ -235,7 +235,7 @@ function ArcStageRow({
<Link <Link
className="text-xs underline" className="text-xs underline"
style={{ color: 'var(--accent)' }} style={{ color: 'var(--accent)' }}
to={`/projects/${projectId}/chapters/${stage.chapterId}`} to={`/novels/${novelId}/chapters/${stage.chapterId}`}
> >
Open outline Open outline
</Link> </Link>
@@ -4,12 +4,12 @@ import type { ArcStage } from '../api/types'
import { ErrorNote, Spinner } from './ui' import { ErrorNote, Spinner } from './ui'
export function CharacterBeats({ export function CharacterBeats({
projectId, novelId,
characterId, characterId,
characterName, characterName,
arcStages, arcStages,
}: { }: {
projectId: string novelId: string
characterId: string characterId: string
characterName: string characterName: string
arcStages: ArcStage[] arcStages: ArcStage[]
@@ -49,7 +49,7 @@ export function CharacterBeats({
<Link <Link
className="underline" className="underline"
style={{ color: 'var(--accent)' }} style={{ color: 'var(--accent)' }}
to={`/projects/${projectId}/chapters/${beat.chapterId}#beat-${beat.id}`} to={`/novels/${novelId}/chapters/${beat.chapterId}#beat-${beat.id}`}
> >
{beat.chapterNumber}.{beat.sortOrder} {beat.chapterNumber}.{beat.sortOrder}
</Link> </Link>
@@ -8,9 +8,9 @@ type MenuState = {
onCreated: (characterId: string) => void onCreated: (characterId: string) => void
} }
export function useCharacterContextMenu(projectId: string) { export function useCharacterContextMenu(novelId: string) {
const [menu, setMenu] = useState<MenuState | null>(null) const [menu, setMenu] = useState<MenuState | null>(null)
const createCharacter = useCreateCharacter(projectId) const createCharacter = useCreateCharacter(novelId)
const handleContextMenu = ( const handleContextMenu = (
e: MouseEvent<HTMLTextAreaElement>, e: MouseEvent<HTMLTextAreaElement>,
@@ -5,11 +5,11 @@ import type { BeatCharacter } from '../api/types'
export function CharacterChip({ export function CharacterChip({
character, character,
projectId, novelId,
onRemove, onRemove,
}: { }: {
character: BeatCharacter character: BeatCharacter
projectId?: string novelId?: string
onRemove?: () => void onRemove?: () => void
}) { }) {
return ( return (
@@ -17,9 +17,9 @@ export function CharacterChip({
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium" className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{ color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 14%, transparent)' }} style={{ color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 14%, transparent)' }}
> >
{projectId ? ( {novelId ? (
<Link <Link
to={`/projects/${projectId}/characters/${character.id}`} to={`/novels/${novelId}/characters/${character.id}`}
className="hover:underline" className="hover:underline"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@@ -43,19 +43,19 @@ export function CharacterChip({
} }
export function CharacterMultiSelect({ export function CharacterMultiSelect({
projectId, novelId,
selected, selected,
options, options,
onChange, onChange,
}: { }: {
projectId: string novelId: string
selected: BeatCharacter[] selected: BeatCharacter[]
options: { id: string; name: string }[] options: { id: string; name: string }[]
onChange: (ids: string[]) => void onChange: (ids: string[]) => void
}) { }) {
const [draft, setDraft] = useState('') const [draft, setDraft] = useState('')
const listId = 'character-multiselect-options' const listId = 'character-multiselect-options'
const createCharacter = useCreateCharacter(projectId) const createCharacter = useCreateCharacter(novelId)
const add = () => { const add = () => {
const name = draft.trim() const name = draft.trim()
@@ -83,7 +83,7 @@ export function CharacterMultiSelect({
return ( return (
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{selected.map((character) => ( {selected.map((character) => (
<CharacterChip key={character.id} character={character} projectId={projectId} onRemove={() => remove(character.id)} /> <CharacterChip key={character.id} character={character} novelId={novelId} onRemove={() => remove(character.id)} />
))} ))}
<input <input
className="input w-28 flex-1 px-2 py-0.5 text-xs" className="input w-28 flex-1 px-2 py-0.5 text-xs"
@@ -9,7 +9,7 @@ export function ImportDialog({
onImported, onImported,
}: { }: {
onClose: () => void onClose: () => void
onImported?: (projectId: string) => void onImported?: (novelId: string) => void
}) { }) {
const [sourceRoot, setSourceRoot] = useState('') const [sourceRoot, setSourceRoot] = useState('')
const [inspection, setInspection] = useState<ImportInspection | null>(null) const [inspection, setInspection] = useState<ImportInspection | null>(null)
@@ -24,8 +24,8 @@ export function ImportDialog({
useEffect(() => { useEffect(() => {
if (job.data?.status !== 'Completed') return if (job.data?.status !== 'Completed') return
qc.invalidateQueries() qc.invalidateQueries()
if (job.data.projectId) onImported?.(job.data.projectId) if (job.data.novelId) onImported?.(job.data.novelId)
}, [job.data?.status, job.data?.projectId, qc, onImported]) }, [job.data?.status, job.data?.novelId, qc, onImported])
const check = (e: FormEvent) => { const check = (e: FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -173,7 +173,7 @@ function ImportReadinessSummary({
) : ( ) : (
<div className="mt-2"> <div className="mt-2">
<p className="mb-2" style={{ color: 'var(--accent)' }}> <p className="mb-2" style={{ color: 'var(--accent)' }}>
This permanently deletes the project this import created its chapters, This permanently deletes the novel this import created its chapters,
characters, everything then starts over. This cannot be undone. characters, everything then starts over. This cannot be undone.
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -10,13 +10,13 @@ import type { OpenQuestion } from '../api/types'
import { ErrorNote, Spinner } from './ui' import { ErrorNote, Spinner } from './ui'
export function OpenQuestions({ export function OpenQuestions({
projectId, novelId,
scope, scope,
canCreate, canCreate,
canWrite, canWrite,
canDelete, canDelete,
}: { }: {
projectId: string novelId: string
scope: { chapterId?: string; characterId?: string } scope: { chapterId?: string; characterId?: string }
canCreate: boolean canCreate: boolean
canWrite: boolean canWrite: boolean
@@ -25,11 +25,11 @@ export function OpenQuestions({
const [showResolved, setShowResolved] = useState(false) const [showResolved, setShowResolved] = useState(false)
const [asking, setAsking] = useState(false) const [asking, setAsking] = useState(false)
const { data: questions, isPending, error } = useOpenQuestions(projectId, { const { data: questions, isPending, error } = useOpenQuestions(novelId, {
...scope, ...scope,
includeResolved: showResolved, includeResolved: showResolved,
}) })
const raise = useRaiseQuestion(projectId) const raise = useRaiseQuestion(novelId)
const [question, setQuestion] = useState('') const [question, setQuestion] = useState('')
const [detail, setDetail] = useState('') const [detail, setDetail] = useState('')
@@ -109,7 +109,7 @@ export function OpenQuestions({
{questions.map((q) => ( {questions.map((q) => (
<QuestionRow <QuestionRow
key={q.id} key={q.id}
projectId={projectId} novelId={novelId}
question={q} question={q}
scope={scope} scope={scope}
canWrite={canWrite} canWrite={canWrite}
@@ -128,21 +128,21 @@ export function OpenQuestions({
} }
function QuestionRow({ function QuestionRow({
projectId, novelId,
question, question,
scope, scope,
canWrite, canWrite,
canDelete, canDelete,
}: { }: {
projectId: string novelId: string
question: OpenQuestion question: OpenQuestion
scope: { chapterId?: string; characterId?: string } scope: { chapterId?: string; characterId?: string }
canWrite: boolean canWrite: boolean
canDelete: boolean canDelete: boolean
}) { }) {
const resolve = useResolveQuestion(projectId) const resolve = useResolveQuestion(novelId)
const reopen = useReopenQuestion(projectId) const reopen = useReopenQuestion(novelId)
const remove = useDeleteQuestion(projectId) const remove = useDeleteQuestion(novelId)
const [resolving, setResolving] = useState(false) const [resolving, setResolving] = useState(false)
const [resolution, setResolution] = useState('') const [resolution, setResolution] = useState('')
+3 -3
View File
@@ -12,11 +12,11 @@ const starters = [
] ]
export default function AgentPage() { export default function AgentPage() {
const { projectId = '' } = useParams() const { novelId = '' } = useParams()
const { data: conversations } = useConversations(projectId) const { data: conversations } = useConversations(novelId)
const [conversationId, setConversationId] = useState<string | undefined>() const [conversationId, setConversationId] = useState<string | undefined>()
const { data: conversation } = useConversation(conversationId) const { data: conversation } = useConversation(conversationId)
const send = useSendAgentMessage(projectId) const send = useSendAgentMessage(novelId)
const [draft, setDraft] = useState('') const [draft, setDraft] = useState('')
const endRef = useRef<HTMLDivElement>(null) const endRef = useRef<HTMLDivElement>(null)
+27 -27
View File
@@ -10,7 +10,7 @@ import {
useDeleteBeat, useDeleteBeat,
useDeleteChapter, useDeleteChapter,
useMoveBeats, useMoveBeats,
useProject, useNovel,
useReorderBeats, useReorderBeats,
useTags, useTags,
useUpdateBeat, useUpdateBeat,
@@ -30,24 +30,24 @@ import { useHotkey } from '../keyboard/HotkeysContext'
type ChapterTab = 'outline' | 'prose' type ChapterTab = 'outline' | 'prose'
export default function ChapterPage() { export default function ChapterPage() {
const { projectId = '', chapterId = '' } = useParams() const { novelId = '', chapterId = '' } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const { data: chapter, isPending, error } = useChapter(chapterId) const { data: chapter, isPending, error } = useChapter(chapterId)
const { data: project } = useProject(projectId) const { data: novel } = useNovel(novelId)
const { data: characters } = useCharacters(projectId) const { data: characters } = useCharacters(novelId)
const { data: allTags } = useTags(projectId) const { data: allTags } = useTags(novelId)
const { data: chapters } = useChapters(projectId) const { data: chapters } = useChapters(novelId)
const createChapter = useCreateChapter(projectId) const createChapter = useCreateChapter(novelId)
const update = useUpdateChapter(projectId) const update = useUpdateChapter(novelId)
const remove = useDeleteChapter(projectId) const remove = useDeleteChapter(novelId)
const createBeat = useCreateBeat(chapterId, projectId) const createBeat = useCreateBeat(chapterId, novelId)
const [tab, setTab] = useState<ChapterTab>('outline') const [tab, setTab] = useState<ChapterTab>('outline')
const [confirmingDelete, setConfirmingDelete] = useState(false) const [confirmingDelete, setConfirmingDelete] = useState(false)
const { handleContextMenu, menuElement } = useCharacterContextMenu(projectId) const { handleContextMenu, menuElement } = useCharacterContextMenu(novelId)
const { can } = useAuth() const { can } = useAuth()
const canWrite = can('Write', project) const canWrite = can('Write', novel)
const canCreate = can('CreateContent', project) const canCreate = can('CreateContent', novel)
const canDelete = can('DeleteContent', project) const canDelete = can('DeleteContent', novel)
useHotkey('b', 'Add beat', () => canCreate && createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' }) useHotkey('b', 'Add beat', () => canCreate && createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
@@ -59,13 +59,13 @@ export default function ChapterPage() {
useHotkey( useHotkey(
'[', '[',
'Previous chapter', 'Previous chapter',
() => prevChapter && navigate(`/projects/${projectId}/chapters/${prevChapter.id}`), () => prevChapter && navigate(`/novels/${novelId}/chapters/${prevChapter.id}`),
{ group: 'Chapter', enabled: Boolean(prevChapter) }, { group: 'Chapter', enabled: Boolean(prevChapter) },
) )
useHotkey( useHotkey(
']', ']',
'Next chapter', 'Next chapter',
() => nextChapter && navigate(`/projects/${projectId}/chapters/${nextChapter.id}`), () => nextChapter && navigate(`/novels/${novelId}/chapters/${nextChapter.id}`),
{ group: 'Chapter', enabled: Boolean(nextChapter) }, { group: 'Chapter', enabled: Boolean(nextChapter) },
) )
@@ -84,13 +84,13 @@ export default function ChapterPage() {
return ( return (
<div> <div>
<div className="mb-4 flex items-center justify-between gap-4"> <div className="mb-4 flex items-center justify-between gap-4">
<Link to={`/projects/${projectId}/chapters`} className="text-sm muted hover:underline"> <Link to={`/novels/${novelId}/chapters`} className="text-sm muted hover:underline">
All chapters All chapters
</Link> </Link>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
{prevChapter ? ( {prevChapter ? (
<Link <Link
to={`/projects/${projectId}/chapters/${prevChapter.id}`} to={`/novels/${novelId}/chapters/${prevChapter.id}`}
className="muted hover:underline" className="muted hover:underline"
title={`Chapter ${prevChapter.number}: ${prevChapter.title}`} title={`Chapter ${prevChapter.number}: ${prevChapter.title}`}
> >
@@ -103,7 +103,7 @@ export default function ChapterPage() {
)} )}
{nextChapter ? ( {nextChapter ? (
<Link <Link
to={`/projects/${projectId}/chapters/${nextChapter.id}`} to={`/novels/${novelId}/chapters/${nextChapter.id}`}
className="muted hover:underline" className="muted hover:underline"
title={`Chapter ${nextChapter.number}: ${nextChapter.title}`} title={`Chapter ${nextChapter.number}: ${nextChapter.title}`}
> >
@@ -208,7 +208,7 @@ export default function ChapterPage() {
message={`Delete chapter "${chapter.title}" and everything in it? This cannot be undone.`} message={`Delete chapter "${chapter.title}" and everything in it? This cannot be undone.`}
onConfirm={() => onConfirm={() =>
remove.mutate(chapter.id, { remove.mutate(chapter.id, {
onSuccess: () => navigate(`/projects/${projectId}/chapters`), onSuccess: () => navigate(`/novels/${novelId}/chapters`),
}) })
} }
onClose={() => setConfirmingDelete(false)} onClose={() => setConfirmingDelete(false)}
@@ -236,7 +236,7 @@ export default function ChapterPage() {
<BeatTable <BeatTable
chapter={chapter} chapter={chapter}
projectId={projectId} novelId={novelId}
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []} characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
otherChapters={chapters?.filter((c) => c.id !== chapter.id) ?? []} otherChapters={chapters?.filter((c) => c.id !== chapter.id) ?? []}
createChapter={createChapter} createChapter={createChapter}
@@ -278,7 +278,7 @@ export default function ChapterPage() {
</div> </div>
<OpenQuestions <OpenQuestions
projectId={projectId} novelId={novelId}
scope={{ chapterId: chapter.id }} scope={{ chapterId: chapter.id }}
canCreate={canCreate} canCreate={canCreate}
canWrite={canWrite} canWrite={canWrite}
@@ -307,7 +307,7 @@ const MOVE_TO_NEW_CHAPTER = '__new__'
function BeatTable({ function BeatTable({
chapter, chapter,
projectId, novelId,
characters, characters,
otherChapters, otherChapters,
createChapter, createChapter,
@@ -317,7 +317,7 @@ function BeatTable({
canDelete, canDelete,
}: { }: {
chapter: Chapter chapter: Chapter
projectId: string novelId: string
characters: { id: string; name: string }[] characters: { id: string; name: string }[]
otherChapters: ChapterSummary[] otherChapters: ChapterSummary[]
createChapter: ReturnType<typeof useCreateChapter> createChapter: ReturnType<typeof useCreateChapter>
@@ -329,7 +329,7 @@ function BeatTable({
canWrite: boolean canWrite: boolean
canDelete: boolean canDelete: boolean
}) { }) {
const update = useUpdateBeat(chapter.id, projectId) const update = useUpdateBeat(chapter.id, novelId)
const remove = useDeleteBeat(chapter.id) const remove = useDeleteBeat(chapter.id)
const reorder = useReorderBeats(chapter.id) const reorder = useReorderBeats(chapter.id)
const assignCharacter = useAssignCharacterToBeats(chapter.id) const assignCharacter = useAssignCharacterToBeats(chapter.id)
@@ -579,7 +579,7 @@ function BeatTable({
<td className="px-2 py-2 align-top"> <td className="px-2 py-2 align-top">
<CharacterMultiSelect <CharacterMultiSelect
projectId={projectId} novelId={novelId}
selected={beat.characters} selected={beat.characters}
options={characters} options={characters}
onChange={(characterIds) => patch(beat.id, { characterIds })} onChange={(characterIds) => patch(beat.id, { characterIds })}
@@ -726,7 +726,7 @@ function BeatTable({
{beat.characters.length > 0 ? ( {beat.characters.length > 0 ? (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{beat.characters.map((character) => ( {beat.characters.map((character) => (
<CharacterChip key={character.id} character={character} projectId={projectId} /> <CharacterChip key={character.id} character={character} novelId={novelId} />
))} ))}
</div> </div>
) : ( ) : (
+7 -7
View File
@@ -1,17 +1,17 @@
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { useChapters, useCreateChapter, useProject } from '../api/hooks' import { useChapters, useCreateChapter, useNovel } from '../api/hooks'
import { EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui' import { EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
import { TagChip } from '../components/TagEditor' import { TagChip } from '../components/TagEditor'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { useHotkey } from '../keyboard/HotkeysContext' import { useHotkey } from '../keyboard/HotkeysContext'
export default function ChaptersPage() { export default function ChaptersPage() {
const { projectId = '' } = useParams() const { novelId = '' } = useParams()
const { data: chapters, isPending, error } = useChapters(projectId) const { data: chapters, isPending, error } = useChapters(novelId)
const { data: project } = useProject(projectId) const { data: novel } = useNovel(novelId)
const { can } = useAuth() const { can } = useAuth()
const canCreate = can('CreateContent', project) const canCreate = can('CreateContent', novel)
const create = useCreateChapter(projectId) const create = useCreateChapter(novelId)
useHotkey('n', 'Add chapter', () => canCreate && create.mutate({ title: 'Untitled chapter' }), { group: 'Chapters' }) useHotkey('n', 'Add chapter', () => canCreate && create.mutate({ title: 'Untitled chapter' }), { group: 'Chapters' })
@@ -45,7 +45,7 @@ export default function ChaptersPage() {
{chapters?.map((chapter) => ( {chapters?.map((chapter) => (
<li key={chapter.id}> <li key={chapter.id}>
<Link <Link
to={`/projects/${projectId}/chapters/${chapter.id}`} to={`/novels/${novelId}/chapters/${chapter.id}`}
className="card flex items-center gap-4 px-5 py-3 transition hover:shadow-md" className="card flex items-center gap-4 px-5 py-3 transition hover:shadow-md"
> >
<span className="w-8 shrink-0 text-right text-sm font-semibold muted"> <span className="w-8 shrink-0 text-right text-sm font-semibold muted">
@@ -6,7 +6,7 @@ import {
useCharacters, useCharacters,
useDeleteCharacter, useDeleteCharacter,
useLinkCharacterIdentity, useLinkCharacterIdentity,
useProject, useNovel,
useRemoveRelationship, useRemoveRelationship,
useTags, useTags,
useUnlinkCharacterIdentity, useUnlinkCharacterIdentity,
@@ -23,13 +23,13 @@ import { CharacterBeats } from '../components/CharacterBeats'
import { OpenQuestions } from '../components/OpenQuestions' import { OpenQuestions } from '../components/OpenQuestions'
export default function CharacterDetailPage() { export default function CharacterDetailPage() {
const { projectId = '', characterId = '' } = useParams() const { novelId = '', characterId = '' } = useParams()
const { data: characters, isPending, error } = useCharacters(projectId) const { data: characters, isPending, error } = useCharacters(novelId)
const { data: project } = useProject(projectId) const { data: novel } = useNovel(novelId)
const { can } = useAuth() const { can } = useAuth()
const canWrite = can('Write', project) const canWrite = can('Write', novel)
const canCreate = can('CreateContent', project) const canCreate = can('CreateContent', novel)
const canDelete = can('DeleteContent', project) const canDelete = can('DeleteContent', novel)
if (isPending) return <Spinner label="Loading character" /> if (isPending) return <Spinner label="Loading character" />
if (error) return <ErrorNote error={error} /> if (error) return <ErrorNote error={error} />
@@ -39,7 +39,7 @@ export default function CharacterDetailPage() {
if (!character) { if (!character) {
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<Link to={`/projects/${projectId}/characters`} className="text-sm muted hover:underline"> <Link to={`/novels/${novelId}/characters`} className="text-sm muted hover:underline">
All characters All characters
</Link> </Link>
<EmptyState title="Character not found" hint="It may have been deleted." /> <EmptyState title="Character not found" hint="It may have been deleted." />
@@ -49,13 +49,13 @@ export default function CharacterDetailPage() {
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<Link to={`/projects/${projectId}/characters`} className="text-sm muted hover:underline"> <Link to={`/novels/${novelId}/characters`} className="text-sm muted hover:underline">
All characters All characters
</Link> </Link>
<CharacterSheet <CharacterSheet
key={character.id} key={character.id}
projectId={projectId} novelId={novelId}
character={character} character={character}
canWrite={canWrite} canWrite={canWrite}
canCreate={canCreate} canCreate={canCreate}
@@ -66,28 +66,28 @@ export default function CharacterDetailPage() {
} }
function CharacterSheet({ function CharacterSheet({
projectId, novelId,
character, character,
canWrite, canWrite,
canCreate, canCreate,
canDelete, canDelete,
}: { }: {
projectId: string novelId: string
character: Character character: Character
canWrite: boolean canWrite: boolean
canCreate: boolean canCreate: boolean
canDelete: boolean canDelete: boolean
}) { }) {
const navigate = useNavigate() const navigate = useNavigate()
const { data: allTags } = useTags(projectId) const { data: allTags } = useTags(novelId)
const { data: allCharacters } = useCharacters(projectId) const { data: allCharacters } = useCharacters(novelId)
const { data: chapters } = useChapters(projectId) const { data: chapters } = useChapters(novelId)
const update = useUpdateCharacter(projectId) const update = useUpdateCharacter(novelId)
const remove = useDeleteCharacter(projectId) const remove = useDeleteCharacter(novelId)
const linkIdentity = useLinkCharacterIdentity(projectId) const linkIdentity = useLinkCharacterIdentity(novelId)
const unlinkIdentity = useUnlinkCharacterIdentity(projectId) const unlinkIdentity = useUnlinkCharacterIdentity(novelId)
const addRelationship = useAddRelationship(projectId) const addRelationship = useAddRelationship(novelId)
const removeRelationship = useRemoveRelationship(projectId) const removeRelationship = useRemoveRelationship(novelId)
const [confirmingDelete, setConfirmingDelete] = useState(false) const [confirmingDelete, setConfirmingDelete] = useState(false)
const patch = (body: Partial<Omit<Character, 'tags' | 'aliases'>> & { tags?: string[]; aliases?: string[] }) => const patch = (body: Partial<Omit<Character, 'tags' | 'aliases'>> & { tags?: string[]; aliases?: string[] }) =>
update.mutate({ id: character.id, ...body }) update.mutate({ id: character.id, ...body })
@@ -281,7 +281,7 @@ function CharacterSheet({
{(character.importance === 'Main' || character.arcStages.length > 0) && ( {(character.importance === 'Main' || character.arcStages.length > 0) && (
<CharacterArc <CharacterArc
projectId={projectId} novelId={novelId}
character={character} character={character}
canWrite={canWrite} canWrite={canWrite}
canCreate={canCreate} canCreate={canCreate}
@@ -290,14 +290,14 @@ function CharacterSheet({
)} )}
<CharacterBeats <CharacterBeats
projectId={projectId} novelId={novelId}
characterId={character.id} characterId={character.id}
characterName={character.name} characterName={character.name}
arcStages={character.arcStages} arcStages={character.arcStages}
/> />
<OpenQuestions <OpenQuestions
projectId={projectId} novelId={novelId}
scope={{ characterId: character.id }} scope={{ characterId: character.id }}
canCreate={canCreate} canCreate={canCreate}
canWrite={canWrite} canWrite={canWrite}
@@ -309,7 +309,7 @@ function CharacterSheet({
title="Delete character" title="Delete character"
message={`Delete ${character.name}? This cannot be undone.`} message={`Delete ${character.name}? This cannot be undone.`}
onConfirm={() => onConfirm={() =>
remove.mutate(character.id, { onSuccess: () => navigate(`/projects/${projectId}/characters`) }) remove.mutate(character.id, { onSuccess: () => navigate(`/novels/${novelId}/characters`) })
} }
onClose={() => setConfirmingDelete(false)} onClose={() => setConfirmingDelete(false)}
/> />
+16 -16
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { useCharacters, useCreateCharacter, useProject, useTags } from '../api/hooks' import { useCharacters, useCreateCharacter, useNovel, useTags } from '../api/hooks'
import { characterImportances, characterRoles, type Character, type CharacterImportance, type CharacterRole } from '../api/types' import { characterImportances, characterRoles, type Character, type CharacterImportance, type CharacterRole } from '../api/types'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui' import { EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
@@ -10,13 +10,13 @@ import { useHotkey } from '../keyboard/HotkeysContext'
type SortKey = 'name' | 'updatedAt' type SortKey = 'name' | 'updatedAt'
export default function CharactersPage() { export default function CharactersPage() {
const { projectId = '' } = useParams() const { novelId = '' } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const { data: characters, isPending, error } = useCharacters(projectId) const { data: characters, isPending, error } = useCharacters(novelId)
const { data: project } = useProject(projectId) const { data: novel } = useNovel(novelId)
const { data: allTags } = useTags(projectId) const { data: allTags } = useTags(novelId)
const { can } = useAuth() const { can } = useAuth()
const canCreate = can('CreateContent', project) const canCreate = can('CreateContent', novel)
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@@ -76,9 +76,9 @@ export default function CharactersPage() {
/> />
{adding && ( {adding && (
<AddCharacterModal <AddCharacterModal
projectId={projectId} novelId={novelId}
onClose={() => setAdding(false)} onClose={() => setAdding(false)}
onCreated={(id) => navigate(`/projects/${projectId}/characters/${id}`)} onCreated={(id) => navigate(`/novels/${novelId}/characters/${id}`)}
/> />
)} )}
</div> </div>
@@ -115,13 +115,13 @@ export default function CharactersPage() {
onSortDir={setSortDir} onSortDir={setSortDir}
/> />
<CharacterTable characters={sorted} projectId={projectId} /> <CharacterTable characters={sorted} novelId={novelId} />
{adding && ( {adding && (
<AddCharacterModal <AddCharacterModal
projectId={projectId} novelId={novelId}
onClose={() => setAdding(false)} onClose={() => setAdding(false)}
onCreated={(id) => navigate(`/projects/${projectId}/characters/${id}`)} onCreated={(id) => navigate(`/novels/${novelId}/characters/${id}`)}
/> />
)} )}
</div> </div>
@@ -271,7 +271,7 @@ function CharacterFilterBar({
) )
} }
function CharacterTable({ characters, projectId }: { characters: Character[]; projectId: string }) { function CharacterTable({ characters, novelId }: { characters: Character[]; novelId: string }) {
if (characters.length === 0) { if (characters.length === 0) {
return ( return (
<div className="card p-5 text-sm muted" id="character-table-empty"> <div className="card p-5 text-sm muted" id="character-table-empty">
@@ -297,7 +297,7 @@ function CharacterTable({ characters, projectId }: { characters: Character[]; pr
<tr key={character.id} className="align-top" style={{ borderTop: '1px solid var(--line)' }}> <tr key={character.id} className="align-top" style={{ borderTop: '1px solid var(--line)' }}>
<td className="p-0"> <td className="p-0">
<Link <Link
to={`/projects/${projectId}/characters/${character.id}`} to={`/novels/${novelId}/characters/${character.id}`}
className="block px-3 py-2 transition hover:bg-[var(--surface-sunken)]" className="block px-3 py-2 transition hover:bg-[var(--surface-sunken)]"
> >
<div className="font-medium">{character.name}</div> <div className="font-medium">{character.name}</div>
@@ -325,15 +325,15 @@ function CharacterTable({ characters, projectId }: { characters: Character[]; pr
} }
function AddCharacterModal({ function AddCharacterModal({
projectId, novelId,
onClose, onClose,
onCreated, onCreated,
}: { }: {
projectId: string novelId: string
onClose: () => void onClose: () => void
onCreated: (id: string) => void onCreated: (id: string) => void
}) { }) {
const create = useCreateCharacter(projectId) const create = useCreateCharacter(novelId)
const [name, setName] = useState('') const [name, setName] = useState('')
const [role, setRole] = useState<Character['role']>('Supporting') const [role, setRole] = useState<Character['role']>('Supporting')
const [importance, setImportance] = useState<Character['importance']>('Supporting') const [importance, setImportance] = useState<Character['importance']>('Supporting')
+19 -19
View File
@@ -1,6 +1,6 @@
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { useChapters, useCharacters, useProject, useTags, useUpdateProject } from '../api/hooks' import { useChapters, useCharacters, useNovel, useTags, useUpdateNovel } from '../api/hooks'
import type { Project, TagSummary } from '../api/types' import type { Novel, TagSummary } from '../api/types'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui' import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
@@ -8,21 +8,21 @@ const RECENT_COUNT = 5
const RECENT_CHAPTERS_COUNT = 10 const RECENT_CHAPTERS_COUNT = 10
export default function DashboardPage() { export default function DashboardPage() {
const { projectId = '' } = useParams() const { novelId = '' } = useParams()
const { data: project, isPending, error } = useProject(projectId) const { data: novel, isPending, error } = useNovel(novelId)
if (error) return <ErrorNote error={error} /> if (error) return <ErrorNote error={error} />
if (isPending || !project) return <Spinner label="Loading novel" /> if (isPending || !novel) return <Spinner label="Loading novel" />
return project.phase === 'Brainstorming' ? ( return novel.phase === 'Brainstorming' ? (
<BrainstormingDashboard project={project} /> <BrainstormingDashboard novel={novel} />
) : ( ) : (
<OutliningDashboard projectId={projectId} /> <OutliningDashboard novelId={novelId} />
) )
} }
function BrainstormingDashboard({ project }: { project: Project }) { function BrainstormingDashboard({ novel }: { novel: Novel }) {
const update = useUpdateProject(project.id) const update = useUpdateNovel(novel.id)
const { can } = useAuth() const { can } = useAuth()
return ( return (
@@ -33,22 +33,22 @@ function BrainstormingDashboard({ project }: { project: Project }) {
there's a shape to work from. there's a shape to work from.
</p> </p>
<AutoField <AutoField
value={project.notes} value={novel.notes}
multiline multiline
rows={20} rows={20}
serif serif
placeholder="Start anywhere." placeholder="Start anywhere."
onCommit={(notes) => update.mutate({ notes })} onCommit={(notes) => update.mutate({ notes })}
readOnly={!can('Write', project)} readOnly={!can('Write', novel)}
/> />
</div> </div>
) )
} }
function OutliningDashboard({ projectId }: { projectId: string }) { function OutliningDashboard({ novelId }: { novelId: string }) {
const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(projectId) const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(novelId)
const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(projectId) const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(novelId)
const { data: tags, isPending: tagsPending, error: tagsError } = useTags(projectId) const { data: tags, isPending: tagsPending, error: tagsError } = useTags(novelId)
const recentCharacters = [...(characters ?? [])].sort( const recentCharacters = [...(characters ?? [])].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
@@ -150,7 +150,7 @@ function OutliningDashboard({ projectId }: { projectId: string }) {
) : !tags || tags.length === 0 ? ( ) : !tags || tags.length === 0 ? (
<EmptyState title="No tags yet" hint="Tag a character, chapter or beat and it shows up here." /> <EmptyState title="No tags yet" hint="Tag a character, chapter or beat and it shows up here." />
) : ( ) : (
<TagCloud projectId={projectId} tags={tags} /> <TagCloud novelId={novelId} tags={tags} />
)} )}
</section> </section>
</div> </div>
@@ -159,7 +159,7 @@ function OutliningDashboard({ projectId }: { projectId: string }) {
) )
} }
function TagCloud({ projectId, tags }: { projectId: string; tags: TagSummary[] }) { function TagCloud({ novelId, tags }: { novelId: string; tags: TagSummary[] }) {
const maxCount = Math.max(...tags.map((t) => t.totalCount), 1) const maxCount = Math.max(...tags.map((t) => t.totalCount), 1)
const sizeFor = (count: number) => { const sizeFor = (count: number) => {
@@ -174,7 +174,7 @@ function TagCloud({ projectId, tags }: { projectId: string; tags: TagSummary[] }
.map((tag) => ( .map((tag) => (
<Link <Link
key={tag.id} key={tag.id}
to={`/projects/${projectId}/tags?tag=${tag.id}`} to={`/novels/${novelId}/tags?tag=${tag.id}`}
className="leading-none font-medium transition hover:underline" className="leading-none font-medium transition hover:underline"
style={{ style={{
fontSize: `${sizeFor(tag.totalCount)}rem`, fontSize: `${sizeFor(tag.totalCount)}rem`,
@@ -1,6 +1,6 @@
import { Outlet, useParams, Link, NavLink, useNavigate } from 'react-router-dom' import { Outlet, useParams, Link, NavLink, useNavigate } from 'react-router-dom'
import { useLogout, useProject, useUpdateProject } from '../api/hooks' import { useLogout, useNovel, useUpdateNovel } from '../api/hooks'
import { projectPhases } from '../api/types' import { novelPhases } from '../api/types'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { ErrorNote, Spinner } from '../components/ui' import { ErrorNote, Spinner } from '../components/ui'
import { HelpButton } from '../keyboard/HelpButton' import { HelpButton } from '../keyboard/HelpButton'
@@ -15,16 +15,16 @@ const sections: { to: string; label: string; end?: boolean }[] = [
{ to: 'settings', label: 'Settings' }, { to: 'settings', label: 'Settings' },
] ]
export default function ProjectLayout() { export default function NovelLayout() {
const { projectId = '' } = useParams() const { novelId = '' } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const { data: project, isPending, error } = useProject(projectId) const { data: novel, isPending, error } = useNovel(novelId)
const update = useUpdateProject(projectId) const update = useUpdateNovel(novelId)
const { user, can } = useAuth() const { user, can } = useAuth()
const canWrite = can('Write', project) const canWrite = can('Write', novel)
const logout = useLogout() const logout = useLogout()
const goTo = (path: string) => navigate(path ? `/projects/${projectId}/${path}` : `/projects/${projectId}`) const goTo = (path: string) => navigate(path ? `/novels/${novelId}/${path}` : `/novels/${novelId}`)
useHotkey('g d', 'Go to dashboard', () => goTo(''), { group: 'Navigate' }) useHotkey('g d', 'Go to dashboard', () => goTo(''), { group: 'Navigate' })
useHotkey('g o', 'Go to outline', () => goTo('chapters'), { group: 'Navigate' }) useHotkey('g o', 'Go to outline', () => goTo('chapters'), { group: 'Navigate' })
@@ -40,19 +40,19 @@ export default function ProjectLayout() {
<Link to="/" className="text-sm muted hover:underline"> <Link to="/" className="text-sm muted hover:underline">
Novels Novels
</Link> </Link>
<Link to={`/projects/${projectId}`} className="truncate text-base font-semibold hover:underline"> <Link to={`/novels/${novelId}`} className="truncate text-base font-semibold hover:underline">
{project?.title ?? '…'} {novel?.title ?? '…'}
</Link> </Link>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
{project && ( {novel && (
<select <select
className="input w-auto" className="input w-auto"
value={project.phase} value={novel.phase}
disabled={!canWrite} disabled={!canWrite}
onChange={(e) => update.mutate({ phase: e.target.value as (typeof projectPhases)[number] })} onChange={(e) => update.mutate({ phase: e.target.value as (typeof novelPhases)[number] })}
aria-label="Novel phase" aria-label="Novel phase"
> >
{projectPhases.map((phase) => ( {novelPhases.map((phase) => (
<option key={phase} value={phase}> <option key={phase} value={phase}>
{phase} {phase}
</option> </option>
@@ -96,7 +96,7 @@ export default function ProjectLayout() {
<main className="mx-auto max-w-[100rem] px-6 py-8"> <main className="mx-auto max-w-[100rem] px-6 py-8">
{error && <ErrorNote error={error} />} {error && <ErrorNote error={error} />}
{isPending ? <Spinner label="Loading project" /> : <Outlet context={{ projectId }} />} {isPending ? <Spinner label="Loading novel" /> : <Outlet context={{ novelId }} />}
</main> </main>
</div> </div>
) )
@@ -1,14 +1,14 @@
import { useId, useState } from 'react' import { useId, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { useCreateProject, useGenres, useLogout, useProjects } from '../api/hooks' import { useCreateNovel, useGenres, useLogout, useNovels } from '../api/hooks'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { ImportDialog } from '../components/ImportDialog' import { ImportDialog } from '../components/ImportDialog'
import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui' import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui'
import { HelpButton } from '../keyboard/HelpButton' import { HelpButton } from '../keyboard/HelpButton'
import { useHotkey } from '../keyboard/HotkeysContext' import { useHotkey } from '../keyboard/HotkeysContext'
export default function ProjectsPage() { export default function NovelsPage() {
const { data: projects, isPending, error } = useProjects() const { data: novels, isPending, error } = useNovels()
const { user, can } = useAuth() const { user, can } = useAuth()
const logout = useLogout() const logout = useLogout()
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
@@ -62,9 +62,9 @@ export default function ProjectsPage() {
</p> </p>
{error && <ErrorNote error={error} />} {error && <ErrorNote error={error} />}
{isPending && <Spinner label="Loading projects" />} {isPending && <Spinner label="Loading novels" />}
{projects?.length === 0 && ( {novels?.length === 0 && (
<EmptyState <EmptyState
title="Nothing here yet" title="Nothing here yet"
hint="Start with a title and a one-sentence logline. Everything else can come later." hint="Start with a title and a one-sentence logline. Everything else can come later."
@@ -72,27 +72,27 @@ export default function ProjectsPage() {
)} )}
<div className="grid gap-3"> <div className="grid gap-3">
{projects?.map((project) => ( {novels?.map((novel) => (
<Link <Link
key={project.id} key={novel.id}
to={`/projects/${project.id}`} to={`/novels/${novel.id}`}
className="card block px-5 py-4 transition hover:shadow-md" className="card block px-5 py-4 transition hover:shadow-md"
> >
<div className="flex items-baseline justify-between gap-4"> <div className="flex items-baseline justify-between gap-4">
<h2 className="text-lg font-semibold">{project.title}</h2> <h2 className="text-lg font-semibold">{novel.title}</h2>
<span className="text-xs muted"> <span className="text-xs muted">
{project.genre ?? 'Uncategorised'} {novel.genre ?? 'Uncategorised'}
{project.author && ` · ${project.author}`} {novel.author && ` · ${novel.author}`}
</span> </span>
</div> </div>
{project.logline && <p className="mt-1 text-sm muted">{project.logline}</p>} {novel.logline && <p className="mt-1 text-sm muted">{novel.logline}</p>}
<div className="mt-3 flex gap-4 text-xs muted"> <div className="mt-3 flex gap-4 text-xs muted">
<span>{project.characterCount} characters</span> <span>{novel.characterCount} characters</span>
<span>{project.chapterCount} chapters</span> <span>{novel.chapterCount} chapters</span>
<span> <span>
{project.wordCount.toLocaleString()} {novel.wordCount.toLocaleString()}
{project.targetWordCount {novel.targetWordCount
? ` / ${project.targetWordCount.toLocaleString()} words` ? ` / ${novel.targetWordCount.toLocaleString()} words`
: ' words'} : ' words'}
</span> </span>
</div> </div>
@@ -100,11 +100,11 @@ export default function ProjectsPage() {
))} ))}
</div> </div>
{creating && <CreateProjectModal onClose={() => setCreating(false)} />} {creating && <CreateNovelModal onClose={() => setCreating(false)} />}
{importing && ( {importing && (
<ImportDialog <ImportDialog
onClose={() => setImporting(false)} onClose={() => setImporting(false)}
onImported={(projectId) => navigate(`/projects/${projectId}`)} onImported={(novelId) => navigate(`/novels/${novelId}`)}
/> />
)} )}
</main> </main>
@@ -112,8 +112,8 @@ export default function ProjectsPage() {
) )
} }
function CreateProjectModal({ onClose }: { onClose: () => void }) { function CreateNovelModal({ onClose }: { onClose: () => void }) {
const create = useCreateProject() const create = useCreateNovel()
const { data: genres } = useGenres() const { data: genres } = useGenres()
const genreListId = useId() const genreListId = useId()
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
+39 -39
View File
@@ -3,42 +3,42 @@ import { useNavigate, useParams } from 'react-router-dom'
import { import {
useChapters, useChapters,
useCharacters, useCharacters,
useDeleteProject, useDeleteNovel,
useGenres, useGenres,
useGrantAccess, useGrantAccess,
useProject, useNovel,
useProjectMembers, useNovelMembers,
useRevokeAccess, useRevokeAccess,
useUpdateProject, useUpdateNovel,
} from '../api/hooks' } from '../api/hooks'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import { projectRoles, type ProjectMember, type ProjectRole } from '../api/types' import { novelRoles, type NovelMember, type NovelRole } from '../api/types'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { ImportDialog } from '../components/ImportDialog' import { ImportDialog } from '../components/ImportDialog'
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui' import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal' import { ConfirmModal } from '../components/ConfirmModal'
export default function SettingsPage() { export default function SettingsPage() {
const { projectId = '' } = useParams() const { novelId = '' } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const { data: project, isPending } = useProject(projectId) const { data: novel, isPending } = useNovel(novelId)
const { data: characters } = useCharacters(projectId) const { data: characters } = useCharacters(novelId)
const { data: chapters } = useChapters(projectId) const { data: chapters } = useChapters(novelId)
const { data: genres } = useGenres() const { data: genres } = useGenres()
const update = useUpdateProject(projectId) const update = useUpdateNovel(novelId)
const remove = useDeleteProject() const remove = useDeleteNovel()
const [importing, setImporting] = useState(false) const [importing, setImporting] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false) const [confirmingDelete, setConfirmingDelete] = useState(false)
const { can } = useAuth() const { can } = useAuth()
if (isPending || !project) return <Spinner label="Loading brief" /> if (isPending || !novel) return <Spinner label="Loading brief" />
const canWrite = can('Write', project) const canWrite = can('Write', novel)
const canDelete = can('DeleteContent', project) const canDelete = can('DeleteContent', novel)
const canManageAccess = can('ManageAccess', project) const canManageAccess = can('ManageAccess', novel)
const drafted = chapters?.reduce((sum, c) => sum + c.wordCount, 0) ?? 0 const drafted = chapters?.reduce((sum, c) => sum + c.wordCount, 0) ?? 0
const target = project.targetWordCount ?? 0 const target = novel.targetWordCount ?? 0
const percent = target > 0 ? Math.min(100, Math.round((drafted / target) * 100)) : null const percent = target > 0 ? Math.min(100, Math.round((drafted / target) * 100)) : null
return ( return (
@@ -48,20 +48,20 @@ export default function SettingsPage() {
<div className="grid gap-4"> <div className="grid gap-4">
<AutoField <AutoField
label="Title" label="Title"
value={project.title} value={novel.title}
onCommit={(title) => title.trim() && update.mutate({ title })} onCommit={(title) => title.trim() && update.mutate({ title })}
readOnly={!canWrite} readOnly={!canWrite}
/> />
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<AutoField <AutoField
label="Author" label="Author"
value={project.author} value={novel.author}
onCommit={(author) => update.mutate({ author })} onCommit={(author) => update.mutate({ author })}
readOnly={!canWrite} readOnly={!canWrite}
/> />
<AutoField <AutoField
label="Genre" label="Genre"
value={project.genre} value={novel.genre}
placeholder="Pick one, or name your own." placeholder="Pick one, or name your own."
suggestions={genres?.map((g) => g.name)} suggestions={genres?.map((g) => g.name)}
onCommit={(genre) => update.mutate({ genre })} onCommit={(genre) => update.mutate({ genre })}
@@ -70,7 +70,7 @@ export default function SettingsPage() {
</div> </div>
<AutoField <AutoField
label="Logline" label="Logline"
value={project.logline} value={novel.logline}
multiline multiline
rows={2} rows={2}
placeholder="Who wants what, and what stands in the way." placeholder="Who wants what, and what stands in the way."
@@ -79,7 +79,7 @@ export default function SettingsPage() {
/> />
<AutoField <AutoField
label="Synopsis" label="Synopsis"
value={project.synopsis} value={novel.synopsis}
multiline multiline
rows={8} rows={8}
serif serif
@@ -89,7 +89,7 @@ export default function SettingsPage() {
/> />
<AutoField <AutoField
label="Notes" label="Notes"
value={project.notes} value={novel.notes}
multiline multiline
rows={4} rows={4}
placeholder="Theme, tone, comparable titles, research threads." placeholder="Theme, tone, comparable titles, research threads."
@@ -103,11 +103,11 @@ export default function SettingsPage() {
type="number" type="number"
min={0} min={0}
step={1000} step={1000}
defaultValue={project.targetWordCount ?? ''} defaultValue={novel.targetWordCount ?? ''}
readOnly={!canWrite} readOnly={!canWrite}
onBlur={(e) => { onBlur={(e) => {
const value = Number(e.target.value) const value = Number(e.target.value)
if (Number.isFinite(value) && value !== project.targetWordCount) { if (Number.isFinite(value) && value !== novel.targetWordCount) {
update.mutate({ targetWordCount: value || null }) update.mutate({ targetWordCount: value || null })
} }
}} }}
@@ -176,20 +176,20 @@ export default function SettingsPage() {
)} )}
</aside> </aside>
{canManageAccess && <ProjectPeople projectId={projectId} />} {canManageAccess && <NovelPeople novelId={novelId} />}
{importing && ( {importing && (
<ImportDialog <ImportDialog
onClose={() => setImporting(false)} onClose={() => setImporting(false)}
onImported={(newProjectId) => navigate(`/projects/${newProjectId}`)} onImported={(newNovelId) => navigate(`/novels/${newNovelId}`)}
/> />
)} )}
{confirmingDelete && ( {confirmingDelete && (
<ConfirmModal <ConfirmModal
title="Delete novel" title="Delete novel"
message={`Delete "${project.title}" and everything in it? This cannot be undone.`} message={`Delete "${novel.title}" and everything in it? This cannot be undone.`}
onConfirm={() => remove.mutate(projectId, { onSuccess: () => navigate('/') })} onConfirm={() => remove.mutate(novelId, { onSuccess: () => navigate('/') })}
onClose={() => setConfirmingDelete(false)} onClose={() => setConfirmingDelete(false)}
/> />
)} )}
@@ -197,13 +197,13 @@ export default function SettingsPage() {
) )
} }
function ProjectPeople({ projectId }: { projectId: string }) { function NovelPeople({ novelId }: { novelId: string }) {
const { data: members, isPending, error } = useProjectMembers(projectId) const { data: members, isPending, error } = useNovelMembers(novelId)
const grant = useGrantAccess(projectId) const grant = useGrantAccess(novelId)
const revoke = useRevokeAccess(projectId) const revoke = useRevokeAccess(novelId)
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [projectRole, setProjectRole] = useState<ProjectRole>('Reviewer') const [novelRole, setNovelRole] = useState<NovelRole>('Reviewer')
const [revoking, setRevoking] = useState<ProjectMember | null>(null) const [revoking, setRevoking] = useState<NovelMember | null>(null)
if (isPending) return null if (isPending) return null
if (error instanceof ApiError && (error.status === 403 || error.status === 401)) return null if (error instanceof ApiError && (error.status === 403 || error.status === 401)) return null
@@ -211,7 +211,7 @@ function ProjectPeople({ projectId }: { projectId: string }) {
const submit = (e: React.FormEvent) => { const submit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!email.trim()) return if (!email.trim()) return
grant.mutate({ email: email.trim(), projectRole }, { onSuccess: () => setEmail('') }) grant.mutate({ email: email.trim(), novelRole }, { onSuccess: () => setEmail('') })
} }
return ( return (
@@ -240,9 +240,9 @@ function ProjectPeople({ projectId }: { projectId: string }) {
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<Select <Select
value={member.projectRole} value={member.novelRole}
options={projectRoles} options={novelRoles}
onChange={(next) => grant.mutate({ email: member.email, projectRole: next })} onChange={(next) => grant.mutate({ email: member.email, novelRole: next })}
/> />
<button className="btn btn-danger" onClick={() => setRevoking(member)}> <button className="btn btn-danger" onClick={() => setRevoking(member)}>
Remove Remove
@@ -264,7 +264,7 @@ function ProjectPeople({ projectId }: { projectId: string }) {
placeholder="someone@example.com" placeholder="someone@example.com"
/> />
</label> </label>
<Select value={projectRole} options={projectRoles} onChange={setProjectRole} /> <Select value={novelRole} options={novelRoles} onChange={setNovelRole} />
<button type="submit" className="btn btn-primary" disabled={!email.trim() || grant.isPending}> <button type="submit" className="btn btn-primary" disabled={!email.trim() || grant.isPending}>
{grant.isPending ? 'Granting' : 'Grant'} {grant.isPending ? 'Granting' : 'Grant'}
</button> </button>
+13 -13
View File
@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom' import { Link, useParams, useSearchParams } from 'react-router-dom'
import { useDeleteTag, useProject, useTagReferences, useTags, useUpdateTag } from '../api/hooks' import { useDeleteTag, useNovel, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { EmptyState, ErrorNote, Spinner } from '../components/ui' import { EmptyState, ErrorNote, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal' import { ConfirmModal } from '../components/ConfirmModal'
@@ -8,12 +8,12 @@ import { TagChip } from '../components/TagEditor'
import { TagColorPicker } from '../components/TagColorPicker' import { TagColorPicker } from '../components/TagColorPicker'
export default function TagsPage() { export default function TagsPage() {
const { projectId = '' } = useParams() const { novelId = '' } = useParams()
const { data: tags, isPending, error } = useTags(projectId) const { data: tags, isPending, error } = useTags(novelId)
const { data: project } = useProject(projectId) const { data: novel } = useNovel(novelId)
const { can } = useAuth() const { can } = useAuth()
const canWrite = can('Write', project) const canWrite = can('Write', novel)
const canDelete = can('DeleteContent', project) const canDelete = can('DeleteContent', novel)
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const selectedId = searchParams.get('tag') ?? undefined const selectedId = searchParams.get('tag') ?? undefined
@@ -70,7 +70,7 @@ export default function TagsPage() {
) : ( ) : (
<TagReferencePanel <TagReferencePanel
key={selected.id} key={selected.id}
projectId={projectId} novelId={novelId}
tagId={selected.id} tagId={selected.id}
canWrite={canWrite} canWrite={canWrite}
canDelete={canDelete} canDelete={canDelete}
@@ -82,18 +82,18 @@ export default function TagsPage() {
} }
function TagReferencePanel({ function TagReferencePanel({
projectId, novelId,
tagId, tagId,
canWrite, canWrite,
canDelete, canDelete,
}: { }: {
projectId: string novelId: string
tagId: string tagId: string
canWrite: boolean canWrite: boolean
canDelete: boolean canDelete: boolean
}) { }) {
const { data, isPending, error } = useTagReferences(tagId) const { data, isPending, error } = useTagReferences(tagId)
const update = useUpdateTag(projectId) const update = useUpdateTag(novelId)
const remove = useDeleteTag() const remove = useDeleteTag()
const [confirmingDelete, setConfirmingDelete] = useState(false) const [confirmingDelete, setConfirmingDelete] = useState(false)
@@ -155,7 +155,7 @@ function TagReferencePanel({
<ul className="grid gap-1 text-sm"> <ul className="grid gap-1 text-sm">
{data.characters.map((c) => ( {data.characters.map((c) => (
<li key={c.id}> <li key={c.id}>
<Link to={`/projects/${projectId}/characters`} className="hover:underline"> <Link to={`/novels/${novelId}/characters`} className="hover:underline">
{c.name} {c.name}
</Link> </Link>
<span className="muted"> {c.role}</span> <span className="muted"> {c.role}</span>
@@ -172,7 +172,7 @@ function TagReferencePanel({
{data.chapters.map((c) => ( {data.chapters.map((c) => (
<li key={c.id}> <li key={c.id}>
<Link <Link
to={`/projects/${projectId}/chapters/${c.id}`} to={`/novels/${novelId}/chapters/${c.id}`}
className="font-medium hover:underline" className="font-medium hover:underline"
> >
{c.number}. {c.title} {c.number}. {c.title}
@@ -191,7 +191,7 @@ function TagReferencePanel({
{data.beats.map((b) => ( {data.beats.map((b) => (
<li key={b.id}> <li key={b.id}>
<Link <Link
to={`/projects/${projectId}/chapters/${b.chapterId}`} to={`/novels/${novelId}/chapters/${b.chapterId}`}
className="font-medium hover:underline" className="font-medium hover:underline"
> >
{b.title} {b.title}
+20 -20
View File
@@ -1,20 +1,20 @@
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
[TestFixture] [TestFixture]
public class BeatServiceTests : ServiceTestFixture public class BeatServiceTests : ServiceTestFixture
{ {
private Guid _projectId; private Guid _novelId;
private Guid _chapterId; private Guid _chapterId;
protected override void OnSetUp() protected override void OnSetUp()
{ {
_projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id; _novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id;
_chapterId = Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")).Result.Id; _chapterId = Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall")).Result.Id;
} }
[Test] [Test]
@@ -100,8 +100,8 @@ public class BeatServiceTests : ServiceTestFixture
[Test] [Test]
public async Task A_beat_can_carry_several_characters() public async Task A_beat_can_carry_several_characters()
{ {
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest( var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest(
"She burns the atlas", "She burns the atlas",
@@ -117,22 +117,22 @@ public class BeatServiceTests : ServiceTestFixture
} }
[Test] [Test]
public async Task A_beat_cannot_borrow_a_character_from_another_project() public async Task A_beat_cannot_borrow_a_character_from_another_novel()
{ {
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); var other = await Novels.CreateAsync(new CreateNovelRequest("Other Book"));
var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger")); var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger"));
Assert.That( Assert.That(
async () => await Beats.CreateAsync( async () => await Beats.CreateAsync(
_chapterId, new CreateBeatRequest("A beat", CharacterIds: [stranger.Id])), _chapterId, new CreateBeatRequest("A beat", CharacterIds: [stranger.Id])),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project")); Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same novel"));
} }
[Test] [Test]
public async Task Assigning_a_character_to_several_beats_leaves_their_other_characters_alone() public async Task Assigning_a_character_to_several_beats_leaves_their_other_characters_alone()
{ {
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara"));
var first = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First", CharacterIds: [ines.Id])); var first = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First", CharacterIds: [ines.Id]));
var second = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Second")); var second = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Second"));
@@ -153,7 +153,7 @@ public class BeatServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Assigning_a_character_already_on_a_beat_does_not_duplicate_it() public async Task Assigning_a_character_already_on_a_beat_does_not_duplicate_it()
{ {
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First", CharacterIds: [ines.Id])); var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First", CharacterIds: [ines.Id]));
var assigned = await Beats.AssignCharacterAsync( var assigned = await Beats.AssignCharacterAsync(
@@ -163,9 +163,9 @@ public class BeatServiceTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Assigning_a_character_from_another_project_returns_null() public async Task Assigning_a_character_from_another_novel_returns_null()
{ {
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); var other = await Novels.CreateAsync(new CreateNovelRequest("Other Book"));
var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger")); var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
@@ -177,7 +177,7 @@ public class BeatServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Assigning_to_an_unknown_beat_returns_null_rather_than_partially_applying() public async Task Assigning_to_an_unknown_beat_returns_null_rather_than_partially_applying()
{ {
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
var result = await Beats.AssignCharacterAsync( var result = await Beats.AssignCharacterAsync(
@@ -190,7 +190,7 @@ public class BeatServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Moving_beats_appends_them_to_the_end_of_the_target_chapter() public async Task Moving_beats_appends_them_to_the_end_of_the_target_chapter()
{ {
var other = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Second landfall")); var other = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Second landfall"));
await Beats.CreateAsync(other.Id, new CreateBeatRequest("Already there")); await Beats.CreateAsync(other.Id, new CreateBeatRequest("Already there"));
var first = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); var first = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
var second = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Second")); var second = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Second"));
@@ -216,9 +216,9 @@ public class BeatServiceTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Moving_to_a_chapter_in_another_project_returns_null() public async Task Moving_to_a_chapter_in_another_novel_returns_null()
{ {
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); var other = await Novels.CreateAsync(new CreateNovelRequest("Other Book"));
var otherChapter = await Chapters.CreateAsync(other.Id, new CreateChapterRequest("Elsewhere")); var otherChapter = await Chapters.CreateAsync(other.Id, new CreateChapterRequest("Elsewhere"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
@@ -228,7 +228,7 @@ public class BeatServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Moving_an_unknown_beat_returns_null_rather_than_partially_applying() public async Task Moving_an_unknown_beat_returns_null_rather_than_partially_applying()
{ {
var other = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Second landfall")); var other = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Second landfall"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
var result = await Beats.MoveAsync(_chapterId, new MoveBeatsRequest(other.Id, [beat.Id, Guid.NewGuid()])); var result = await Beats.MoveAsync(_chapterId, new MoveBeatsRequest(other.Id, [beat.Id, Guid.NewGuid()]));
@@ -265,7 +265,7 @@ public class BeatServiceTests : ServiceTestFixture
[Test] [Test]
public async Task An_empty_CharacterIds_list_clears_a_beats_characters_since_null_means_leave_it_alone() public async Task An_empty_CharacterIds_list_clears_a_beats_characters_since_null_means_leave_it_alone()
{ {
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest( var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest(
"She burns the atlas", CharacterIds: [ines.Id])); "She burns the atlas", CharacterIds: [ines.Id]));
@@ -2,25 +2,25 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
[TestFixture] [TestFixture]
public class ChapterServiceTests : ServiceTestFixture public class ChapterServiceTests : ServiceTestFixture
{ {
private Guid _projectId; private Guid _novelId;
protected override void OnSetUp() protected override void OnSetUp()
{ {
_projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id; _novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id;
} }
[Test] [Test]
public async Task Setting_a_number_that_already_exists_is_still_stored_as_given() public async Task Setting_a_number_that_already_exists_is_still_stored_as_given()
{ {
var first = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall", Number: 5)); var first = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall", Number: 5));
var second = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("The Harbour", Number: 5)); var second = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("The Harbour", Number: 5));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -32,7 +32,7 @@ public class ChapterServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Updating_leaves_omitted_fields_alone_and_clears_notes_on_empty_string() public async Task Updating_leaves_omitted_fields_alone_and_clears_notes_on_empty_string()
{ {
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest( var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest(
"Landfall", Summary: "The ship makes shore.", Notes: "Check the tide tables.")); "Landfall", Summary: "The ship makes shore.", Notes: "Check the tide tables."));
var renamed = (await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Title: "First Landfall")))!; var renamed = (await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Title: "First Landfall")))!;
@@ -56,7 +56,7 @@ public class ChapterServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Deleting_a_chapter_takes_its_beats_with_it() public async Task Deleting_a_chapter_takes_its_beats_with_it()
{ {
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("She finds the map")); await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("She finds the map"));
await Chapters.DeleteAsync(chapter.Id); await Chapters.DeleteAsync(chapter.Id);
@@ -66,7 +66,7 @@ public class ChapterServiceTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Creating_a_chapter_under_a_missing_project_returns_null_rather_than_throwing() => public async Task Creating_a_chapter_under_a_missing_novel_returns_null_rather_than_throwing() =>
Assert.That( Assert.That(
await Chapters.CreateAsync(Guid.NewGuid(), new CreateChapterRequest("Landfall")), await Chapters.CreateAsync(Guid.NewGuid(), new CreateChapterRequest("Landfall")),
Is.Null); Is.Null);
+25 -25
View File
@@ -2,21 +2,21 @@ using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
[TestFixture] [TestFixture]
public class CharacterArcTests : ServiceTestFixture public class CharacterArcTests : ServiceTestFixture
{ {
private Guid _projectId; private Guid _novelId;
private Guid _characterId; private Guid _characterId;
protected override void OnSetUp() protected override void OnSetUp()
{ {
_projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id; _novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id;
_characterId = Characters.CreateAsync( _characterId = Characters.CreateAsync(
_projectId, _novelId,
new CreateCharacterRequest("Ines", CharacterRole.Protagonist, CharacterImportance.Main)) new CreateCharacterRequest("Ines", CharacterRole.Protagonist, CharacterImportance.Main))
.Result.Id; .Result.Id;
} }
@@ -24,7 +24,7 @@ public class CharacterArcTests : ServiceTestFixture
[Test] [Test]
public async Task A_character_is_supporting_until_promoted() public async Task A_character_is_supporting_until_promoted()
{ {
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara"));
Assert.That(mara.Importance, Is.EqualTo(CharacterImportance.Supporting)); Assert.That(mara.Importance, Is.EqualTo(CharacterImportance.Supporting));
@@ -37,7 +37,7 @@ public class CharacterArcTests : ServiceTestFixture
[Test] [Test]
public async Task Importance_is_separate_from_the_part_a_character_plays() public async Task Importance_is_separate_from_the_part_a_character_plays()
{ {
var mentor = await Characters.CreateAsync(_projectId, new CreateCharacterRequest( var mentor = await Characters.CreateAsync(_novelId, new CreateCharacterRequest(
"Anders", CharacterRole.Mentor, CharacterImportance.Main)); "Anders", CharacterRole.Mentor, CharacterImportance.Main));
Assert.Multiple(() => Assert.Multiple(() =>
@@ -50,11 +50,11 @@ public class CharacterArcTests : ServiceTestFixture
[Test] [Test]
public async Task Main_characters_are_listed_before_supporting_ones() public async Task Main_characters_are_listed_before_supporting_ones()
{ {
await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Zeno")); await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Zeno"));
await Characters.CreateAsync( await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Mara", Importance: CharacterImportance.Main)); _novelId, new CreateCharacterRequest("Mara", Importance: CharacterImportance.Main));
var listed = await Characters.ListAsync(_projectId); var listed = await Characters.ListAsync(_novelId);
Assert.That( Assert.That(
listed.Select(c => c.Name), listed.Select(c => c.Name),
@@ -64,12 +64,12 @@ public class CharacterArcTests : ServiceTestFixture
[Test] [Test]
public async Task Within_a_group_the_lead_comes_before_the_second_lead() public async Task Within_a_group_the_lead_comes_before_the_second_lead()
{ {
await Characters.CreateAsync(_projectId, new CreateCharacterRequest( await Characters.CreateAsync(_novelId, new CreateCharacterRequest(
"Mara", CharacterRole.Deuteragonist, CharacterImportance.Main)); "Mara", CharacterRole.Deuteragonist, CharacterImportance.Main));
await Characters.CreateAsync(_projectId, new CreateCharacterRequest( await Characters.CreateAsync(_novelId, new CreateCharacterRequest(
"Anders", CharacterRole.Antagonist, CharacterImportance.Main)); "Anders", CharacterRole.Antagonist, CharacterImportance.Main));
var listed = await Characters.ListAsync(_projectId); var listed = await Characters.ListAsync(_novelId);
Assert.That( Assert.That(
listed.Select(c => c.Name), listed.Select(c => c.Name),
@@ -127,7 +127,7 @@ public class CharacterArcTests : ServiceTestFixture
[Test] [Test]
public async Task A_stage_pinned_to_a_chapter_resolves_that_chapter() public async Task A_stage_pinned_to_a_chapter_resolves_that_chapter()
{ {
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
var stage = await Arcs.CreateAsync( var stage = await Arcs.CreateAsync(
_characterId, new CreateArcStageRequest("The map is wrong", ChapterId: chapter.Id)); _characterId, new CreateArcStageRequest("The map is wrong", ChapterId: chapter.Id));
@@ -140,21 +140,21 @@ public class CharacterArcTests : ServiceTestFixture
} }
[Test] [Test]
public async Task A_stage_cannot_be_pinned_to_a_chapter_from_another_project() public async Task A_stage_cannot_be_pinned_to_a_chapter_from_another_novel()
{ {
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); var other = await Novels.CreateAsync(new CreateNovelRequest("Other Book"));
var elsewhere = await Chapters.CreateAsync(other.Id, new CreateChapterRequest("Elsewhere")); var elsewhere = await Chapters.CreateAsync(other.Id, new CreateChapterRequest("Elsewhere"));
Assert.That( Assert.That(
async () => await Arcs.CreateAsync( async () => await Arcs.CreateAsync(
_characterId, new CreateArcStageRequest("A stage", ChapterId: elsewhere.Id)), _characterId, new CreateArcStageRequest("A stage", ChapterId: elsewhere.Id)),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project")); Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same novel"));
} }
[Test] [Test]
public async Task Deleting_a_chapter_unpins_an_arc_stage_rather_than_deleting_it() public async Task Deleting_a_chapter_unpins_an_arc_stage_rather_than_deleting_it()
{ {
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
var stage = await Arcs.CreateAsync( var stage = await Arcs.CreateAsync(
_characterId, new CreateArcStageRequest("The map is wrong", ChapterId: chapter.Id)); _characterId, new CreateArcStageRequest("The map is wrong", ChapterId: chapter.Id));
@@ -188,9 +188,9 @@ public class CharacterArcTests : ServiceTestFixture
[Test] [Test]
public async Task The_character_page_sees_every_beat_they_appear_in_across_the_book() public async Task The_character_page_sees_every_beat_they_appear_in_across_the_book()
{ {
var second = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Second", Number: 2)); var second = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Second", Number: 2));
var first = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("First", Number: 1)); var first = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("First", Number: 1));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara"));
await Beats.CreateAsync(second.Id, new CreateBeatRequest("She boards anyway", CharacterIds: [_characterId])); await Beats.CreateAsync(second.Id, new CreateBeatRequest("She boards anyway", CharacterIds: [_characterId]));
await Beats.CreateAsync(first.Id, new CreateBeatRequest("She finds the map", CharacterIds: [_characterId])); await Beats.CreateAsync(first.Id, new CreateBeatRequest("She finds the map", CharacterIds: [_characterId]));
@@ -215,7 +215,7 @@ public class CharacterArcTests : ServiceTestFixture
[Test] [Test]
public async Task An_arc_stage_groups_the_beats_assigned_to_it() public async Task An_arc_stage_groups_the_beats_assigned_to_it()
{ {
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
var spoiled = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Snaps at the crew", CharacterIds: [_characterId])); var spoiled = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Snaps at the crew", CharacterIds: [_characterId]));
var humbled = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Learns to swab a deck", CharacterIds: [_characterId])); var humbled = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Learns to swab a deck", CharacterIds: [_characterId]));
var stage = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble")); var stage = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble"));
@@ -229,7 +229,7 @@ public class CharacterArcTests : ServiceTestFixture
[Test] [Test]
public async Task Assigning_a_beat_to_a_stage_moves_it_out_of_the_characters_other_stage() public async Task Assigning_a_beat_to_a_stage_moves_it_out_of_the_characters_other_stage()
{ {
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Gets hurt", CharacterIds: [_characterId])); var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Gets hurt", CharacterIds: [_characterId]));
var early = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble")); var early = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble"));
var later = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Humbled")); var later = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Humbled"));
@@ -250,8 +250,8 @@ public class CharacterArcTests : ServiceTestFixture
[Test] [Test]
public async Task A_beat_can_only_be_grouped_into_a_stage_for_a_character_who_appears_in_it() public async Task A_beat_can_only_be_grouped_into_a_stage_for_a_character_who_appears_in_it()
{ {
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara"));
var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Mara alone", CharacterIds: [mara.Id])); var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Mara alone", CharacterIds: [mara.Id]));
var stage = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble")); var stage = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble"));
@@ -269,7 +269,7 @@ public class CharacterArcTests : ServiceTestFixture
[Test] [Test]
public async Task Clearing_a_stages_beats_with_an_empty_list_ungroups_them() public async Task Clearing_a_stages_beats_with_an_empty_list_ungroups_them()
{ {
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Snaps at the crew", CharacterIds: [_characterId])); var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Snaps at the crew", CharacterIds: [_characterId]));
var stage = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble")); var stage = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble"));
await Arcs.SetBeatsAsync(stage.Id, new SetArcStageBeatsRequest([beat!.Id])); await Arcs.SetBeatsAsync(stage.Id, new SetArcStageBeatsRequest([beat!.Id]));
@@ -2,24 +2,24 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
[TestFixture] [TestFixture]
public class CharacterServiceTests : ServiceTestFixture public class CharacterServiceTests : ServiceTestFixture
{ {
private Guid _projectId; private Guid _novelId;
protected override void OnSetUp() protected override void OnSetUp()
{ {
_projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id; _novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id;
} }
[Test] [Test]
public async Task New_characters_default_to_supporting_role_and_importance() public async Task New_characters_default_to_supporting_role_and_importance()
{ {
var character = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -31,7 +31,7 @@ public class CharacterServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Promoting_a_character_to_main_sticks_until_changed_again() public async Task Promoting_a_character_to_main_sticks_until_changed_again()
{ {
var character = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
var promoted = (await Characters.UpdateAsync( var promoted = (await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Importance: CharacterImportance.Main)))!; character.Id, new UpdateCharacterRequest(Importance: CharacterImportance.Main)))!;
@@ -47,7 +47,7 @@ public class CharacterServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string() public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{ {
var character = await Characters.CreateAsync(_projectId, new CreateCharacterRequest( var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest(
"Ines", Want: "To find her sister.", Need: "To let go of the guilt.")); "Ines", Want: "To find her sister.", Need: "To let go of the guilt."));
var renamed = (await Characters.UpdateAsync(character.Id, new UpdateCharacterRequest(Name: "Ines Vell")))!; var renamed = (await Characters.UpdateAsync(character.Id, new UpdateCharacterRequest(Name: "Ines Vell")))!;
@@ -69,23 +69,23 @@ public class CharacterServiceTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Relating_characters_across_projects_is_refused() public async Task Relating_characters_across_novels_is_refused()
{ {
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); var other = await Novels.CreateAsync(new CreateNovelRequest("Other Book"));
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger")); var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger"));
Assert.That( Assert.That(
async () => await Characters.AddRelationshipAsync( async () => await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(stranger.Id, "sister")), ines.Id, new CreateRelationshipRequest(stranger.Id, "sister")),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project")); Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same novel"));
} }
[Test] [Test]
public async Task Removing_a_relationship_leaves_both_characters_in_place() public async Task Removing_a_relationship_leaves_both_characters_in_place()
{ {
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara"));
var withRelationship = (await Characters.AddRelationshipAsync( var withRelationship = (await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(mara.Id, "sister")))!; ines.Id, new CreateRelationshipRequest(mara.Id, "sister")))!;
@@ -104,8 +104,8 @@ public class CharacterServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Adding_a_relationship_records_it_on_both_characters() public async Task Adding_a_relationship_records_it_on_both_characters()
{ {
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara"));
await Characters.AddRelationshipAsync( await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(mara.Id, "sister", ReciprocalRelationshipType: "brother")); ines.Id, new CreateRelationshipRequest(mara.Id, "sister", ReciprocalRelationshipType: "brother"));
@@ -127,8 +127,8 @@ public class CharacterServiceTests : ServiceTestFixture
[Test] [Test]
public async Task A_relationship_with_no_reciprocal_type_mirrors_the_same_type_both_ways() public async Task A_relationship_with_no_reciprocal_type_mirrors_the_same_type_both_ways()
{ {
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara"));
await Characters.AddRelationshipAsync(ines.Id, new CreateRelationshipRequest(mara.Id, "rival")); await Characters.AddRelationshipAsync(ines.Id, new CreateRelationshipRequest(mara.Id, "rival"));
@@ -140,8 +140,8 @@ public class CharacterServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Removing_a_relationship_removes_the_reciprocal_side_too() public async Task Removing_a_relationship_removes_the_reciprocal_side_too()
{ {
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara"));
var withRelationship = (await Characters.AddRelationshipAsync( var withRelationship = (await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(mara.Id, "sister", ReciprocalRelationshipType: "brother")))!; ines.Id, new CreateRelationshipRequest(mara.Id, "sister", ReciprocalRelationshipType: "brother")))!;
@@ -156,8 +156,8 @@ public class CharacterServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Deleting_a_character_detaches_it_from_beats_rather_than_deleting_them() public async Task Deleting_a_character_detaches_it_from_beats_rather_than_deleting_them()
{ {
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("She finds the map", CharacterIds: [ines.Id])); var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("She finds the map", CharacterIds: [ines.Id]));
await Characters.DeleteAsync(ines.Id); await Characters.DeleteAsync(ines.Id);
@@ -168,7 +168,7 @@ public class CharacterServiceTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Creating_a_character_under_a_missing_project_returns_null_rather_than_throwing() => public async Task Creating_a_character_under_a_missing_novel_returns_null_rather_than_throwing() =>
Assert.That( Assert.That(
await Characters.CreateAsync(Guid.NewGuid(), new CreateCharacterRequest("Ines")), await Characters.CreateAsync(Guid.NewGuid(), new CreateCharacterRequest("Ines")),
Is.Null); Is.Null);
@@ -181,7 +181,7 @@ public class CharacterServiceTests : ServiceTestFixture
public async Task Aliases_round_trip_on_create_and_update() public async Task Aliases_round_trip_on_create_and_update()
{ {
var created = await Characters.CreateAsync( var created = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man", "Kael"])); _novelId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man", "Kael"]));
Assert.That(created.Aliases, Is.EqualTo(new[] { "The Grey Man", "Kael" })); Assert.That(created.Aliases, Is.EqualTo(new[] { "The Grey Man", "Kael" }));
@@ -195,7 +195,7 @@ public class CharacterServiceTests : ServiceTestFixture
public async Task Clearing_aliases_with_an_empty_list_empties_them() public async Task Clearing_aliases_with_an_empty_list_empties_them()
{ {
var created = await Characters.CreateAsync( var created = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man"])); _novelId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man"]));
var cleared = (await Characters.UpdateAsync(created.Id, new UpdateCharacterRequest(Aliases: [])))!; var cleared = (await Characters.UpdateAsync(created.Id, new UpdateCharacterRequest(Aliases: [])))!;
@@ -205,8 +205,8 @@ public class CharacterServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Linking_a_character_to_its_true_identity_records_it_on_both_sides() public async Task Linking_a_character_to_its_true_identity_records_it_on_both_sides()
{ {
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael")); var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger")); var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger"));
var linked = (await Characters.LinkIdentityAsync( var linked = (await Characters.LinkIdentityAsync(
stranger.Id, new LinkCharacterIdentityRequest(kael.Id, Note: "Same man, after the exile.")))!; stranger.Id, new LinkCharacterIdentityRequest(kael.Id, Note: "Same man, after the exile.")))!;
@@ -225,9 +225,9 @@ public class CharacterServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Linking_to_a_character_that_is_itself_an_alias_flattens_to_the_canonical() public async Task Linking_to_a_character_that_is_itself_an_alias_flattens_to_the_canonical()
{ {
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael")); var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger")); var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger"));
var exile = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Exile")); var exile = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Exile"));
await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id)); await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id));
var linked = (await Characters.LinkIdentityAsync(exile.Id, new LinkCharacterIdentityRequest(stranger.Id)))!; var linked = (await Characters.LinkIdentityAsync(exile.Id, new LinkCharacterIdentityRequest(stranger.Id)))!;
@@ -238,7 +238,7 @@ public class CharacterServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Linking_a_character_to_itself_is_rejected() public async Task Linking_a_character_to_itself_is_rejected()
{ {
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael")); var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
Assert.That( Assert.That(
async () => await Characters.LinkIdentityAsync(kael.Id, new LinkCharacterIdentityRequest(kael.Id)), async () => await Characters.LinkIdentityAsync(kael.Id, new LinkCharacterIdentityRequest(kael.Id)),
@@ -246,22 +246,22 @@ public class CharacterServiceTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Linking_identities_across_projects_is_refused() public async Task Linking_identities_across_novels_is_refused()
{ {
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); var other = await Novels.CreateAsync(new CreateNovelRequest("Other Book"));
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael")); var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger")); var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger"));
Assert.That( Assert.That(
async () => await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id)), async () => await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id)),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project")); Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same novel"));
} }
[Test] [Test]
public async Task Deleting_the_canonical_character_leaves_its_other_identities_alive() public async Task Deleting_the_canonical_character_leaves_its_other_identities_alive()
{ {
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael")); var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger")); var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger"));
await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id)); await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id));
await Characters.DeleteAsync(kael.Id); await Characters.DeleteAsync(kael.Id);
@@ -274,9 +274,9 @@ public class CharacterServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Unlinking_an_identity_clears_the_reveal_chapter_and_note() public async Task Unlinking_an_identity_clears_the_reveal_chapter_and_note()
{ {
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("The Reveal")); var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("The Reveal"));
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael")); var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger")); var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger"));
await Characters.LinkIdentityAsync( await Characters.LinkIdentityAsync(
stranger.Id, new LinkCharacterIdentityRequest(kael.Id, chapter.Id, "Same man.")); stranger.Id, new LinkCharacterIdentityRequest(kael.Id, chapter.Id, "Same man."));
@@ -1,7 +1,7 @@
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
@@ -10,22 +10,22 @@ public class ExceptionHandlingTests : ServiceTestFixture
{ {
[Test] [Test]
public void Guard_rejects_an_empty_guid_passed_as_a_required_id() => public void Guard_rejects_an_empty_guid_passed_as_a_required_id() =>
Assert.That(() => Projects.GetAsync(Guid.Empty), Throws.TypeOf<ArgumentException>()); Assert.That(() => Novels.GetAsync(Guid.Empty), Throws.TypeOf<ArgumentException>());
[Test] [Test]
public void Guard_rejects_a_null_request_object() => public void Guard_rejects_a_null_request_object() =>
Assert.That( Assert.That(
() => Projects.CreateAsync(null!), () => Novels.CreateAsync(null!),
Throws.TypeOf<ArgumentNullException>()); Throws.TypeOf<ArgumentNullException>());
[Test] [Test]
public async Task Deleting_a_missing_project_returns_false_rather_than_throwing() => public async Task Deleting_a_missing_novel_returns_false_rather_than_throwing() =>
Assert.That(await Projects.DeleteAsync(Guid.NewGuid()), Is.False); Assert.That(await Novels.DeleteAsync(Guid.NewGuid()), Is.False);
[Test] [Test]
public void A_blank_title_fails_the_create_project_validator() public void A_blank_title_fails_the_create_novel_validator()
{ {
var result = new CreateProjectRequestValidator().Validate(new CreateProjectRequest("")); var result = new CreateNovelRequestValidator().Validate(new CreateNovelRequest(""));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -37,10 +37,10 @@ public class ExceptionHandlingTests : ServiceTestFixture
[Test] [Test]
public void Calling_a_service_directly_with_an_invalid_request_throws_rather_than_silently_accepting_it() => public void Calling_a_service_directly_with_an_invalid_request_throws_rather_than_silently_accepting_it() =>
Assert.That( Assert.That(
() => Projects.CreateAsync(new CreateProjectRequest("")), () => Novels.CreateAsync(new CreateNovelRequest("")),
Throws.TypeOf<ArgumentException>()); Throws.TypeOf<ArgumentException>());
[Test] [Test]
public async Task Creating_a_chapter_under_a_missing_project_returns_null_rather_than_throwing() => public async Task Creating_a_chapter_under_a_missing_novel_returns_null_rather_than_throwing() =>
Assert.That(await Chapters.CreateAsync(Guid.NewGuid(), new CreateChapterRequest("Landfall")), Is.Null); Assert.That(await Chapters.CreateAsync(Guid.NewGuid(), new CreateChapterRequest("Landfall")), Is.Null);
} }
+8 -8
View File
@@ -1,4 +1,4 @@
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
@@ -20,24 +20,24 @@ public class GenreServiceTests : ServiceTestFixture
} }
[Test] [Test]
public async Task A_project_can_be_filed_under_a_genre_off_the_list() public async Task A_novel_can_be_filed_under_a_genre_off_the_list()
{ {
var fantasy = (await Genres.ListAsync()).First(g => g.Name == "Fantasy"); var fantasy = (await Genres.ListAsync()).First(g => g.Name == "Fantasy");
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road", Genre: fantasy.Name)); var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road", Genre: fantasy.Name));
Assert.That(project.Genre, Is.EqualTo("Fantasy")); Assert.That(novel.Genre, Is.EqualTo("Fantasy"));
} }
[Test] [Test]
public async Task A_project_can_still_carry_a_genre_that_is_not_on_the_list() public async Task A_novel_can_still_carry_a_genre_that_is_not_on_the_list()
{ {
var project = await Projects.CreateAsync( var novel = await Novels.CreateAsync(
new CreateProjectRequest("The Salt Road", Genre: "Nautical Gothic")); new CreateNovelRequest("The Salt Road", Genre: "Nautical Gothic"));
Assert.Multiple(async () => Assert.Multiple(async () =>
{ {
Assert.That(project.Genre, Is.EqualTo("Nautical Gothic")); Assert.That(novel.Genre, Is.EqualTo("Nautical Gothic"));
Assert.That((await Genres.ListAsync()).Select(g => g.Name), Has.No.Member("Nautical Gothic")); Assert.That((await Genres.ListAsync()).Select(g => g.Name), Has.No.Member("Nautical Gothic"));
}); });
} }
@@ -13,8 +13,8 @@ public class ImportAgentToolsetTests : ServiceTestFixture
{ {
_root = Directory.CreateTempSubdirectory("novelly-import-toolset-test-").FullName; _root = Directory.CreateTempSubdirectory("novelly-import-toolset-test-").FullName;
_toolset = new ImportAgentToolset( _toolset = new ImportAgentToolset(
Projects, Characters, Arcs, Chapters, Beats, new CapturingLogger<ImportAgentToolset>()); Novels, Characters, Arcs, Chapters, Beats, new CapturingLogger<ImportAgentToolset>());
_toolset.Initialize(_root, existingProjectId: null); _toolset.Initialize(_root, existingNovelId: null);
} }
[TearDown] [TearDown]
@@ -64,7 +64,7 @@ public class ImportAgentToolsetTests : ServiceTestFixture
[Test] [Test]
public async Task Write_ledger_can_only_ever_touch_the_ledger_file_no_matter_what_path_is_asked_for() public async Task Write_ledger_can_only_ever_touch_the_ledger_file_no_matter_what_path_is_asked_for()
{ {
await _toolset.ExecuteAsync("write_ledger", Input(new { json = """{"completedPasses": ["project"]}""" })); await _toolset.ExecuteAsync("write_ledger", Input(new { json = """{"completedPasses": ["novel"]}""" }));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -86,25 +86,25 @@ public class ImportAgentToolsetTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Create_project_binds_the_toolsets_project_id_for_later_calls() public async Task Create_novel_binds_the_toolsets_novel_id_for_later_calls()
{ {
await _toolset.ExecuteAsync("create_project", Input(new { title = "The Blade Itself", author = "Joe Abercrombie" })); await _toolset.ExecuteAsync("create_novel", Input(new { title = "The Blade Itself", author = "Joe Abercrombie" }));
Assert.That(_toolset.ProjectId, Is.Not.Null); Assert.That(_toolset.NovelId, Is.Not.Null);
var project = await Projects.GetAsync(_toolset.ProjectId!.Value); var novel = await Novels.GetAsync(_toolset.NovelId!.Value);
Assert.That(project!.Title, Is.EqualTo("The Blade Itself")); Assert.That(novel!.Title, Is.EqualTo("The Blade Itself"));
} }
[Test] [Test]
public async Task Domain_tools_refuse_to_run_before_a_project_exists() public async Task Domain_tools_refuse_to_run_before_a_novel_exists()
{ {
var result = await _toolset.ExecuteAsync("create_character", Input(new { name = "Logen" })); var result = await _toolset.ExecuteAsync("create_character", Input(new { name = "Logen" }));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(result.IsError, Is.True); Assert.That(result.IsError, Is.True);
Assert.That(result.Content, Does.Contain("create_project first")); Assert.That(result.Content, Does.Contain("create_novel first"));
}); });
} }
+10 -10
View File
@@ -1,6 +1,6 @@
using System.Threading.Channels; using System.Threading.Channels;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
@@ -17,7 +17,7 @@ public class ImportServiceTests : ServiceTestFixture
_queue = Channel.CreateUnbounded<Guid>(); _queue = Channel.CreateUnbounded<Guid>();
_imports = new ImportService( _imports = new ImportService(
Db.Context, Db.Context,
Projects, Novels,
_queue, _queue,
UserContext, UserContext,
new CapturingLogger<ImportService>(), new CapturingLogger<ImportService>(),
@@ -44,7 +44,7 @@ public class ImportServiceTests : ServiceTestFixture
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(inspection.Readiness, Is.EqualTo(ImportReadiness.Fresh)); Assert.That(inspection.Readiness, Is.EqualTo(ImportReadiness.Fresh));
Assert.That(inspection.ProjectId, Is.Null); Assert.That(inspection.NovelId, Is.Null);
Assert.That(inspection.ChaptersTotal, Is.EqualTo(3)); Assert.That(inspection.ChaptersTotal, Is.EqualTo(3));
Assert.That(inspection.ChaptersCompleted, Is.EqualTo(0)); Assert.That(inspection.ChaptersCompleted, Is.EqualTo(0));
}); });
@@ -54,7 +54,7 @@ public class ImportServiceTests : ServiceTestFixture
public async Task Inspecting_a_folder_with_an_incomplete_ledger_reports_resumable() public async Task Inspecting_a_folder_with_an_incomplete_ledger_reports_resumable()
{ {
WriteChapterFiles(3); WriteChapterFiles(3);
WriteLedger("""{"projectId": "11111111-1111-1111-1111-111111111111", "completedPasses": ["project"], "completedChapters": [1]}"""); WriteLedger("""{"novelId": "11111111-1111-1111-1111-111111111111", "completedPasses": ["novel"], "completedChapters": [1]}""");
var inspection = await _imports.InspectAsync(new InspectImportRequest(_root)); var inspection = await _imports.InspectAsync(new InspectImportRequest(_root));
@@ -72,8 +72,8 @@ public class ImportServiceTests : ServiceTestFixture
WriteChapterFiles(2); WriteChapterFiles(2);
WriteLedger(""" WriteLedger("""
{ {
"projectId": "11111111-1111-1111-1111-111111111111", "novelId": "11111111-1111-1111-1111-111111111111",
"completedPasses": ["project", "characters", "chapters", "arcs"], "completedPasses": ["novel", "characters", "chapters", "arcs"],
"completedChapters": [1, 2] "completedChapters": [1, 2]
} }
"""); """);
@@ -117,17 +117,17 @@ public class ImportServiceTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Force_restarting_a_completed_import_deletes_the_ledger_and_its_project() public async Task Force_restarting_a_completed_import_deletes_the_ledger_and_its_novel()
{ {
var project = await Projects.CreateAsync(new CreateProjectRequest("The Blade Itself")); var novel = await Novels.CreateAsync(new CreateNovelRequest("The Blade Itself"));
WriteLedger($$"""{"projectId": "{{project.Id}}", "completedPasses": ["project", "characters", "chapters", "arcs"], "completedChapters": [1]}"""); WriteLedger($$"""{"novelId": "{{novel.Id}}", "completedPasses": ["novel", "characters", "chapters", "arcs"], "completedChapters": [1]}""");
await _imports.StartOrResumeAsync(new StartImportRequest(_root, ForceRestart: true)); await _imports.StartOrResumeAsync(new StartImportRequest(_root, ForceRestart: true));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(File.Exists(Path.Combine(_root, ".novelly-import.json")), Is.False); Assert.That(File.Exists(Path.Combine(_root, ".novelly-import.json")), Is.False);
Assert.That(Projects.GetAsync(project.Id).Result, Is.Null); Assert.That(Novels.GetAsync(novel.Id).Result, Is.Null);
}); });
} }
+30 -30
View File
@@ -3,7 +3,7 @@ using Microsoft.Extensions.Options;
using Novelly.Api.Agent; using Novelly.Api.Agent;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
@@ -11,14 +11,14 @@ namespace Novelly.Api.Tests;
public class ListingTests : ServiceTestFixture public class ListingTests : ServiceTestFixture
{ {
[Test] [Test]
public async Task Projects_are_listed_most_recently_updated_first() public async Task Novels_are_listed_most_recently_updated_first()
{ {
var older = await Projects.CreateAsync(new CreateProjectRequest("Older Book")); var older = await Novels.CreateAsync(new CreateNovelRequest("Older Book"));
var newer = await Projects.CreateAsync(new CreateProjectRequest("Newer Book")); var newer = await Novels.CreateAsync(new CreateNovelRequest("Newer Book"));
await Projects.UpdateAsync(older.Id, new UpdateProjectRequest(Logline: "Revised.")); await Novels.UpdateAsync(older.Id, new UpdateNovelRequest(Logline: "Revised."));
var listed = await Projects.ListAsync(); var listed = await Novels.ListAsync();
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -28,16 +28,16 @@ public class ListingTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Project_summaries_aggregate_counts_and_words_across_chapters() public async Task Novel_summaries_aggregate_counts_and_words_across_chapters()
{ {
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines")); await Characters.CreateAsync(novel.Id, new CreateCharacterRequest("Ines"));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Mara")); await Characters.CreateAsync(novel.Id, new CreateCharacterRequest("Mara"));
await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall", Prose: "One two three")); await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Landfall", Prose: "One two three"));
await Chapters.CreateAsync(project.Id, new CreateChapterRequest("The Harbour", Prose: "Four five")); await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("The Harbour", Prose: "Four five"));
var summary = (await Projects.ListAsync()).Single(); var summary = (await Novels.ListAsync()).Single();
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -48,11 +48,11 @@ public class ListingTests : ServiceTestFixture
} }
[Test] [Test]
public async Task A_project_with_no_chapters_reports_zero_words_rather_than_failing() public async Task A_novel_with_no_chapters_reports_zero_words_rather_than_failing()
{ {
await Projects.CreateAsync(new CreateProjectRequest("Empty")); await Novels.CreateAsync(new CreateNovelRequest("Empty"));
var summary = (await Projects.ListAsync()).Single(); var summary = (await Novels.ListAsync()).Single();
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -64,11 +64,11 @@ public class ListingTests : ServiceTestFixture
[Test] [Test]
public async Task Chapters_are_listed_in_manuscript_order_with_word_counts() public async Task Chapters_are_listed_in_manuscript_order_with_word_counts()
{ {
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
var second = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Second", Number: 2, Prose: "One two three")); var second = await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Second", Number: 2, Prose: "One two three"));
var first = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("First", Number: 1)); var first = await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("First", Number: 1));
var listed = await Chapters.ListAsync(project.Id); var listed = await Chapters.ListAsync(novel.Id);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -81,19 +81,19 @@ public class ListingTests : ServiceTestFixture
[Test] [Test]
public async Task Conversations_are_listed_most_recently_updated_first() public async Task Conversations_are_listed_most_recently_updated_first()
{ {
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
var agent = new NovelAgentService( var agent = new NovelAgentService(
Db.Context, Db.Context,
new ScriptedModelClient([[new AgentTextBlock("Reply.")]]), new ScriptedModelClient([[new AgentTextBlock("Reply.")]]),
new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger<NovelAgentToolset>.Instance), new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger<NovelAgentToolset>.Instance),
Options.Create(new AgentOptions()), Options.Create(new AgentOptions()),
NullLogger<NovelAgentService>.Instance, NullLogger<NovelAgentService>.Instance,
new SendAgentMessageRequestValidator()); new SendAgentMessageRequestValidator());
await agent.SendMessageAsync(project.Id, new SendAgentMessageRequest("First question.")); await agent.SendMessageAsync(novel.Id, new SendAgentMessageRequest("First question."));
await agent.SendMessageAsync(project.Id, new SendAgentMessageRequest("Second question.")); await agent.SendMessageAsync(novel.Id, new SendAgentMessageRequest("Second question."));
var listed = await agent.ListConversationsAsync(project.Id); var listed = await agent.ListConversationsAsync(novel.Id);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -106,12 +106,12 @@ public class ListingTests : ServiceTestFixture
[Test] [Test]
public async Task Characters_are_listed_by_role_then_name() public async Task Characters_are_listed_by_role_then_name()
{ {
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Zeno", CharacterRole.Supporting)); await Characters.CreateAsync(novel.Id, new CreateCharacterRequest("Zeno", CharacterRole.Supporting));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines", CharacterRole.Protagonist)); await Characters.CreateAsync(novel.Id, new CreateCharacterRequest("Ines", CharacterRole.Protagonist));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Anders", CharacterRole.Supporting)); await Characters.CreateAsync(novel.Id, new CreateCharacterRequest("Anders", CharacterRole.Supporting));
var listed = await Characters.ListAsync(project.Id); var listed = await Characters.ListAsync(novel.Id);
Assert.That(listed.Select(c => c.Name), Is.EqualTo(new[] { "Ines", "Anders", "Zeno" })); Assert.That(listed.Select(c => c.Name), Is.EqualTo(new[] { "Ines", "Anders", "Zeno" }));
} }
+17 -17
View File
@@ -3,7 +3,7 @@ using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
@@ -27,53 +27,53 @@ public class LoggingTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Creating_a_chapter_logs_the_project_and_title_at_information() public async Task Creating_a_chapter_logs_the_novel_and_title_at_information()
{ {
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
ChapterLogs.Entries.Clear(); ChapterLogs.Entries.Clear();
await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall")); await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Landfall"));
var info = ChapterLogs.Entries.Single(e => e.Level == LogLevel.Information); var info = ChapterLogs.Entries.Single(e => e.Level == LogLevel.Information);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(info.Message, Does.Contain("Landfall")); Assert.That(info.Message, Does.Contain("Landfall"));
Assert.That(info.Message, Does.Contain(project.Id.ToString())); Assert.That(info.Message, Does.Contain(novel.Id.ToString()));
}); });
} }
[Test] [Test]
public async Task Logged_values_never_include_a_chapter_summary_body() public async Task Logged_values_never_include_a_chapter_summary_body()
{ {
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
const string secretSummary = "A very specific plot twist nobody should see in a log line."; const string secretSummary = "A very specific plot twist nobody should see in a log line.";
ChapterLogs.Entries.Clear(); ChapterLogs.Entries.Clear();
await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall", Summary: secretSummary)); await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Landfall", Summary: secretSummary));
Assert.That(ChapterLogs.Entries.Select(e => e.Message), Has.None.Contain(secretSummary)); Assert.That(ChapterLogs.Entries.Select(e => e.Message), Has.None.Contain(secretSummary));
} }
[Test] [Test]
public async Task Deleting_a_project_logs_information_before_the_lookup() public async Task Deleting_a_novel_logs_information_before_the_lookup()
{ {
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
ProjectLogs.Entries.Clear(); NovelLogs.Entries.Clear();
await Projects.DeleteAsync(project.Id); await Novels.DeleteAsync(novel.Id);
Assert.That( Assert.That(
ProjectLogs.Entries, NovelLogs.Entries,
Has.Some.Matches<CapturedLogEntry>(e => e.Level == LogLevel.Information && e.Message.Contains(project.Id.ToString()))); Has.Some.Matches<CapturedLogEntry>(e => e.Level == LogLevel.Information && e.Message.Contains(novel.Id.ToString())));
} }
[Test] [Test]
public async Task Rejecting_a_beat_with_a_foreign_character_logs_a_warning_not_an_error() public async Task Rejecting_a_beat_with_a_foreign_character_logs_a_warning_not_an_error()
{ {
var projectA = await Projects.CreateAsync(new CreateProjectRequest("Project A")); var novelA = await Novels.CreateAsync(new CreateNovelRequest("Novel A"));
var projectB = await Projects.CreateAsync(new CreateProjectRequest("Project B")); var novelB = await Novels.CreateAsync(new CreateNovelRequest("Novel B"));
var chapter = await Chapters.CreateAsync(projectA.Id, new CreateChapterRequest("Landfall")); var chapter = await Chapters.CreateAsync(novelA.Id, new CreateChapterRequest("Landfall"));
var foreignCharacter = await Characters.CreateAsync(projectB.Id, new CreateCharacterRequest("Ines")); var foreignCharacter = await Characters.CreateAsync(novelB.Id, new CreateCharacterRequest("Ines"));
BeatLogs.Entries.Clear(); BeatLogs.Entries.Clear();
Assert.That( Assert.That(
@@ -1,13 +1,13 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Projects; using Novelly.Api.Novels;
using Novelly.Api.Users; using Novelly.Api.Users;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
[TestFixture] [TestFixture]
public class ProjectAccessTests : ServiceTestFixture public class NovelAccessTests : ServiceTestFixture
{ {
private Guid AsNewUser(GlobalRole globalRole) private Guid AsNewUser(GlobalRole globalRole)
{ {
@@ -27,9 +27,9 @@ public class ProjectAccessTests : ServiceTestFixture
return user.Id; return user.Id;
} }
private void GrantProjectRole(Guid projectId, Guid userId, ProjectRole role) private void GrantNovelRole(Guid novelId, Guid userId, NovelRole role)
{ {
Db.Context.ProjectMembers.Add(new ProjectMember { ProjectId = projectId, UserId = userId, ProjectRole = role, GrantedByUserId = userId }); Db.Context.NovelMembers.Add(new NovelMember { NovelId = novelId, UserId = userId, NovelRole = role, GrantedByUserId = userId });
Db.Context.SaveChanges(); Db.Context.SaveChanges();
} }
@@ -43,31 +43,31 @@ public class ProjectAccessTests : ServiceTestFixture
public async Task A_writer_sees_only_novels_they_own_or_have_been_granted() public async Task A_writer_sees_only_novels_they_own_or_have_been_granted()
{ {
var writerId = AsNewUser(GlobalRole.Writer); var writerId = AsNewUser(GlobalRole.Writer);
var ownedProject = await Projects.CreateAsync(new CreateProjectRequest("Owned by writer")); var ownedNovel = await Novels.CreateAsync(new CreateNovelRequest("Owned by writer"));
AsAdmin(); AsAdmin();
var otherProject = await Projects.CreateAsync(new CreateProjectRequest("Owned by someone else")); var otherNovel = await Novels.CreateAsync(new CreateNovelRequest("Owned by someone else"));
UserContext.UserId = writerId; UserContext.UserId = writerId;
UserContext.GlobalRole = GlobalRole.Writer; UserContext.GlobalRole = GlobalRole.Writer;
var visibleBeforeGrant = await Projects.ListAsync(); var visibleBeforeGrant = await Novels.ListAsync();
Assert.That(visibleBeforeGrant.Select(p => p.Id), Is.EquivalentTo(new[] { ownedProject.Id })); Assert.That(visibleBeforeGrant.Select(p => p.Id), Is.EquivalentTo(new[] { ownedNovel.Id }));
GrantProjectRole(otherProject.Id, writerId, ProjectRole.Reviewer); GrantNovelRole(otherNovel.Id, writerId, NovelRole.Reviewer);
var visibleAfterGrant = await Projects.ListAsync(); var visibleAfterGrant = await Novels.ListAsync();
Assert.That(visibleAfterGrant.Select(p => p.Id), Is.EquivalentTo(new[] { ownedProject.Id, otherProject.Id })); Assert.That(visibleAfterGrant.Select(p => p.Id), Is.EquivalentTo(new[] { ownedNovel.Id, otherNovel.Id }));
} }
[Test] [Test]
public async Task An_editor_can_rewrite_a_chapter_but_cannot_delete_it() public async Task An_editor_can_rewrite_a_chapter_but_cannot_delete_it()
{ {
var project = await Projects.CreateAsync(new CreateProjectRequest("Editable Novel")); var novel = await Novels.CreateAsync(new CreateNovelRequest("Editable Novel"));
var chapter = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Chapter One")); var chapter = await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Chapter One"));
var editorId = AsNewUser(GlobalRole.Reviewer); var editorId = AsNewUser(GlobalRole.Reviewer);
GrantProjectRole(project.Id, editorId, ProjectRole.Editor); GrantNovelRole(novel.Id, editorId, NovelRole.Editor);
var updated = await Chapters.UpdateAsync(chapter!.Id, new UpdateChapterRequest(Title: "Renamed")); var updated = await Chapters.UpdateAsync(chapter!.Id, new UpdateChapterRequest(Title: "Renamed"));
Assert.That(updated!.Title, Is.EqualTo("Renamed")); Assert.That(updated!.Title, Is.EqualTo("Renamed"));
@@ -80,17 +80,17 @@ public class ProjectAccessTests : ServiceTestFixture
{ {
AsNewUser(GlobalRole.Editor); AsNewUser(GlobalRole.Editor);
Assert.That(() => Projects.CreateAsync(new CreateProjectRequest("Should not exist")), Throws.TypeOf<NotAuthorizedException>()); Assert.That(() => Novels.CreateAsync(new CreateNovelRequest("Should not exist")), Throws.TypeOf<NotAuthorizedException>());
} }
[Test] [Test]
public async Task A_reviewer_can_read_a_chapter_but_not_change_it() public async Task A_reviewer_can_read_a_chapter_but_not_change_it()
{ {
var project = await Projects.CreateAsync(new CreateProjectRequest("Reviewed Novel")); var novel = await Novels.CreateAsync(new CreateNovelRequest("Reviewed Novel"));
var chapter = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Chapter One")); var chapter = await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Chapter One"));
var reviewerId = AsNewUser(GlobalRole.Reviewer); var reviewerId = AsNewUser(GlobalRole.Reviewer);
GrantProjectRole(project.Id, reviewerId, ProjectRole.Reviewer); GrantNovelRole(novel.Id, reviewerId, NovelRole.Reviewer);
var read = await Chapters.GetAsync(chapter!.Id); var read = await Chapters.GetAsync(chapter!.Id);
Assert.That(read, Is.Not.Null); Assert.That(read, Is.Not.Null);
@@ -103,13 +103,13 @@ public class ProjectAccessTests : ServiceTestFixture
[Test] [Test]
public async Task A_writer_granted_access_to_someone_elses_novel_still_cannot_grant_access_to_others() public async Task A_writer_granted_access_to_someone_elses_novel_still_cannot_grant_access_to_others()
{ {
var project = await Projects.CreateAsync(new CreateProjectRequest("Someone Else's Novel")); var novel = await Novels.CreateAsync(new CreateNovelRequest("Someone Else's Novel"));
var grantedWriterId = AsNewUser(GlobalRole.Writer); var grantedWriterId = AsNewUser(GlobalRole.Writer);
GrantProjectRole(project.Id, grantedWriterId, ProjectRole.Writer); GrantNovelRole(novel.Id, grantedWriterId, NovelRole.Writer);
Assert.That( Assert.That(
() => Access.RequireAsync(project.Id, ProjectPermission.ManageAccess), () => Access.RequireAsync(novel.Id, NovelPermission.ManageAccess),
Throws.TypeOf<NotAuthorizedException>()); Throws.TypeOf<NotAuthorizedException>());
} }
@@ -117,19 +117,19 @@ public class ProjectAccessTests : ServiceTestFixture
public async Task An_admin_reaches_every_novel() public async Task An_admin_reaches_every_novel()
{ {
AsNewUser(GlobalRole.Writer); AsNewUser(GlobalRole.Writer);
await Projects.CreateAsync(new CreateProjectRequest("Writer's Novel")); await Novels.CreateAsync(new CreateNovelRequest("Writer's Novel"));
AsAdmin(); AsAdmin();
await Projects.CreateAsync(new CreateProjectRequest("Admin's Novel")); await Novels.CreateAsync(new CreateNovelRequest("Admin's Novel"));
var visible = await Projects.ListAsync(); var visible = await Novels.ListAsync();
Assert.That(visible, Has.Count.EqualTo(2)); Assert.That(visible, Has.Count.EqualTo(2));
} }
[Test] [Test]
public void Deleting_a_user_does_not_cascade_to_their_novels() public void Deleting_a_user_does_not_cascade_to_their_novels()
{ {
var ownerNavigation = Db.Context.Model.FindEntityType(typeof(Project))!.FindNavigation(nameof(Project.Owner))!; var ownerNavigation = Db.Context.Model.FindEntityType(typeof(Novel))!.FindNavigation(nameof(Novel.Owner))!;
Assert.That(ownerNavigation.ForeignKey.DeleteBehavior, Is.EqualTo(DeleteBehavior.Restrict)); Assert.That(ownerNavigation.ForeignKey.DeleteBehavior, Is.EqualTo(DeleteBehavior.Restrict));
} }
@@ -137,10 +137,10 @@ public class ProjectAccessTests : ServiceTestFixture
public async Task The_creator_of_a_novel_sees_their_role_as_owner() public async Task The_creator_of_a_novel_sees_their_role_as_owner()
{ {
var writerId = AsNewUser(GlobalRole.Writer); var writerId = AsNewUser(GlobalRole.Writer);
var project = await Projects.CreateAsync(new CreateProjectRequest("Owned by writer")); var novel = await Novels.CreateAsync(new CreateNovelRequest("Owned by writer"));
UserContext.UserId = writerId; UserContext.UserId = writerId;
var role = await Access.GetMyRoleAsync(project); var role = await Access.GetMyRoleAsync(novel);
Assert.That(role, Is.EqualTo("Owner")); Assert.That(role, Is.EqualTo("Owner"));
} }
@@ -149,10 +149,10 @@ public class ProjectAccessTests : ServiceTestFixture
public async Task An_admin_sees_their_role_as_admin_even_on_a_novel_they_do_not_own() public async Task An_admin_sees_their_role_as_admin_even_on_a_novel_they_do_not_own()
{ {
AsNewUser(GlobalRole.Writer); AsNewUser(GlobalRole.Writer);
var project = await Projects.CreateAsync(new CreateProjectRequest("Owned by someone else")); var novel = await Novels.CreateAsync(new CreateNovelRequest("Owned by someone else"));
AsAdmin(); AsAdmin();
var role = await Access.GetMyRoleAsync(project); var role = await Access.GetMyRoleAsync(novel);
Assert.That(role, Is.EqualTo("Admin")); Assert.That(role, Is.EqualTo("Admin"));
} }
@@ -160,11 +160,11 @@ public class ProjectAccessTests : ServiceTestFixture
[Test] [Test]
public async Task A_user_granted_editor_sees_their_role_as_editor() public async Task A_user_granted_editor_sees_their_role_as_editor()
{ {
var project = await Projects.CreateAsync(new CreateProjectRequest("Granted Novel")); var novel = await Novels.CreateAsync(new CreateNovelRequest("Granted Novel"));
var editorId = AsNewUser(GlobalRole.Reviewer); var editorId = AsNewUser(GlobalRole.Reviewer);
GrantProjectRole(project.Id, editorId, ProjectRole.Editor); GrantNovelRole(novel.Id, editorId, NovelRole.Editor);
var role = await Access.GetMyRoleAsync(project); var role = await Access.GetMyRoleAsync(novel);
Assert.That(role, Is.EqualTo("Editor")); Assert.That(role, Is.EqualTo("Editor"));
} }
@@ -172,10 +172,10 @@ public class ProjectAccessTests : ServiceTestFixture
[Test] [Test]
public async Task A_user_with_no_access_sees_a_null_role() public async Task A_user_with_no_access_sees_a_null_role()
{ {
var project = await Projects.CreateAsync(new CreateProjectRequest("Someone Else's Novel")); var novel = await Novels.CreateAsync(new CreateNovelRequest("Someone Else's Novel"));
AsNewUser(GlobalRole.Writer); AsNewUser(GlobalRole.Writer);
var role = await Access.GetMyRoleAsync(project); var role = await Access.GetMyRoleAsync(novel);
Assert.That(role, Is.Null); Assert.That(role, Is.Null);
} }
@@ -2,7 +2,7 @@ using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Novelly.Api.Agent; using Novelly.Api.Agent;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
@@ -12,7 +12,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
private NovelAgentToolset _toolset = null!; private NovelAgentToolset _toolset = null!;
protected override void OnSetUp() => protected override void OnSetUp() =>
_toolset = new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger<NovelAgentToolset>.Instance); _toolset = new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger<NovelAgentToolset>.Instance);
private NovelAgentService BuildAgent(ScriptedModelClient model) => new( private NovelAgentService BuildAgent(ScriptedModelClient model) => new(
Db.Context, Db.Context,
@@ -25,11 +25,11 @@ public class NovelAgentServiceTests : ServiceTestFixture
[Test] [Test]
public async Task A_plain_reply_is_persisted_as_a_conversation() public async Task A_plain_reply_is_persisted_as_a_conversation()
{ {
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
var model = new ScriptedModelClient([[new AgentTextBlock("Tell me about the ending.")]]); var model = new ScriptedModelClient([[new AgentTextBlock("Tell me about the ending.")]]);
var agent = BuildAgent(model); var agent = BuildAgent(model);
var turn = await agent.SendMessageAsync(projectId, new SendAgentMessageRequest("Where do I start?")); var turn = await agent.SendMessageAsync(novelId, new SendAgentMessageRequest("Where do I start?"));
Assert.That(turn.Content, Is.EqualTo("Tell me about the ending.")); Assert.That(turn.Content, Is.EqualTo("Tell me about the ending."));
@@ -44,9 +44,9 @@ public class NovelAgentServiceTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Tool_calls_are_executed_against_real_project_data() public async Task Tool_calls_are_executed_against_real_novel_data()
{ {
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
var model = new ScriptedModelClient([ var model = new ScriptedModelClient([
[ToolUse("t1", "create_character", new { name = "Ines", role = "Protagonist" })], [ToolUse("t1", "create_character", new { name = "Ines", role = "Protagonist" })],
@@ -54,9 +54,9 @@ public class NovelAgentServiceTests : ServiceTestFixture
]); ]);
var turn = await BuildAgent(model).SendMessageAsync( var turn = await BuildAgent(model).SendMessageAsync(
projectId, new SendAgentMessageRequest("Add a protagonist called Ines.")); novelId, new SendAgentMessageRequest("Add a protagonist called Ines."));
var characters = await Characters.ListAsync(projectId); var characters = await Characters.ListAsync(novelId);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -71,7 +71,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Every_tool_result_comes_back_in_a_single_user_turn() public async Task Every_tool_result_comes_back_in_a_single_user_turn()
{ {
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
var model = new ScriptedModelClient([ var model = new ScriptedModelClient([
[ [
@@ -81,10 +81,10 @@ public class NovelAgentServiceTests : ServiceTestFixture
[new AgentTextBlock("Both added.")] [new AgentTextBlock("Both added.")]
]); ]);
await BuildAgent(model).SendMessageAsync(projectId, new SendAgentMessageRequest("Add two characters.")); await BuildAgent(model).SendMessageAsync(novelId, new SendAgentMessageRequest("Add two characters."));
var resultTurn = model.Transcripts[1][^1]; var resultTurn = model.Transcripts[1][^1];
var listed = await Characters.ListAsync(projectId); var listed = await Characters.ListAsync(novelId);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -97,7 +97,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
[Test] [Test]
public async Task A_failing_tool_is_reported_back_rather_than_thrown() public async Task A_failing_tool_is_reported_back_rather_than_thrown()
{ {
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
var model = new ScriptedModelClient([ var model = new ScriptedModelClient([
[ToolUse("t1", "update_character", new { character_id = Guid.NewGuid().ToString(), name = "Ines" })], [ToolUse("t1", "update_character", new { character_id = Guid.NewGuid().ToString(), name = "Ines" })],
@@ -105,7 +105,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
]); ]);
var turn = await BuildAgent(model).SendMessageAsync( var turn = await BuildAgent(model).SendMessageAsync(
projectId, new SendAgentMessageRequest("Rename her.")); novelId, new SendAgentMessageRequest("Rename her."));
var errorResult = model.Transcripts[1][^1].Content.OfType<AgentToolResultBlock>().Single(); var errorResult = model.Transcripts[1][^1].Content.OfType<AgentToolResultBlock>().Single();
@@ -120,14 +120,14 @@ public class NovelAgentServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Unknown_tools_are_reported_without_breaking_the_loop() public async Task Unknown_tools_are_reported_without_breaking_the_loop()
{ {
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
var model = new ScriptedModelClient([ var model = new ScriptedModelClient([
[ToolUse("t1", "summon_muse", new { })], [ToolUse("t1", "summon_muse", new { })],
[new AgentTextBlock("Sorry — I do not have that tool.")] [new AgentTextBlock("Sorry — I do not have that tool.")]
]); ]);
await BuildAgent(model).SendMessageAsync(projectId, new SendAgentMessageRequest("Summon the muse.")); await BuildAgent(model).SendMessageAsync(novelId, new SendAgentMessageRequest("Summon the muse."));
var result = model.Transcripts[1][^1].Content.OfType<AgentToolResultBlock>().Single(); var result = model.Transcripts[1][^1].Content.OfType<AgentToolResultBlock>().Single();
@@ -141,14 +141,14 @@ public class NovelAgentServiceTests : ServiceTestFixture
[Test] [Test]
public async Task The_loop_stops_at_the_iteration_ceiling() public async Task The_loop_stops_at_the_iteration_ceiling()
{ {
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
var model = new ScriptedModelClient( var model = new ScriptedModelClient(
Enumerable.Repeat<IReadOnlyList<AgentContentBlock>>( Enumerable.Repeat<IReadOnlyList<AgentContentBlock>>(
[ToolUse("t", "list_characters", new { })], 20).ToList()); [ToolUse("t", "list_characters", new { })], 20).ToList());
var turn = await BuildAgent(model).SendMessageAsync( var turn = await BuildAgent(model).SendMessageAsync(
projectId, new SendAgentMessageRequest("Keep going forever.")); novelId, new SendAgentMessageRequest("Keep going forever."));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -160,16 +160,16 @@ public class NovelAgentServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Follow_up_messages_continue_the_same_conversation() public async Task Follow_up_messages_continue_the_same_conversation()
{ {
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
var model = new ScriptedModelClient([ var model = new ScriptedModelClient([
[new AgentTextBlock("First answer.")], [new AgentTextBlock("First answer.")],
[new AgentTextBlock("Second answer.")] [new AgentTextBlock("Second answer.")]
]); ]);
var agent = BuildAgent(model); var agent = BuildAgent(model);
var first = await agent.SendMessageAsync(projectId, new SendAgentMessageRequest("Question one.")); var first = await agent.SendMessageAsync(novelId, new SendAgentMessageRequest("Question one."));
var second = await agent.SendMessageAsync( var second = await agent.SendMessageAsync(
projectId, new SendAgentMessageRequest("Question two.", first.ConversationId)); novelId, new SendAgentMessageRequest("Question two.", first.ConversationId));
var conversation = (await agent.GetConversationAsync(first.ConversationId))!; var conversation = (await agent.GetConversationAsync(first.ConversationId))!;
@@ -2,23 +2,23 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
[TestFixture] [TestFixture]
public class ProjectDataTests : ServiceTestFixture public class NovelDataTests : ServiceTestFixture
{ {
private async Task<Guid> NewProjectAsync() => private async Task<Guid> NewNovelAsync() =>
(await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
[Test] [Test]
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string() public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{ {
var id = (await Projects.CreateAsync( var id = (await Novels.CreateAsync(
new CreateProjectRequest("Draft", Genre: "Fantasy", Logline: "A cartographer goes to sea."))).Id; new CreateNovelRequest("Draft", Genre: "Fantasy", Logline: "A cartographer goes to sea."))).Id;
var afterPartialUpdate = (await Projects.UpdateAsync(id, new UpdateProjectRequest(Title: "The Salt Road")))!; var afterPartialUpdate = (await Novels.UpdateAsync(id, new UpdateNovelRequest(Title: "The Salt Road")))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -27,7 +27,7 @@ public class ProjectDataTests : ServiceTestFixture
Assert.That(afterPartialUpdate.Logline, Is.EqualTo("A cartographer goes to sea.")); Assert.That(afterPartialUpdate.Logline, Is.EqualTo("A cartographer goes to sea."));
}); });
var afterClear = (await Projects.UpdateAsync(id, new UpdateProjectRequest(Genre: "")))!; var afterClear = (await Novels.UpdateAsync(id, new UpdateNovelRequest(Genre: "")))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -37,23 +37,23 @@ public class ProjectDataTests : ServiceTestFixture
} }
[Test] [Test]
public async Task New_projects_start_in_the_brainstorming_phase_and_can_be_advanced() public async Task New_novels_start_in_the_brainstorming_phase_and_can_be_advanced()
{ {
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
Assert.That(project.Phase, Is.EqualTo(ProjectPhase.Brainstorming)); Assert.That(novel.Phase, Is.EqualTo(NovelPhase.Brainstorming));
var afterAdvance = (await Projects.UpdateAsync(project.Id, new UpdateProjectRequest(Phase: ProjectPhase.Outlining)))!; var afterAdvance = (await Novels.UpdateAsync(novel.Id, new UpdateNovelRequest(Phase: NovelPhase.Outlining)))!;
Assert.That(afterAdvance.Phase, Is.EqualTo(ProjectPhase.Outlining)); Assert.That(afterAdvance.Phase, Is.EqualTo(NovelPhase.Outlining));
var afterUnrelatedUpdate = (await Projects.UpdateAsync(project.Id, new UpdateProjectRequest(Genre: "Fantasy")))!; var afterUnrelatedUpdate = (await Novels.UpdateAsync(novel.Id, new UpdateNovelRequest(Genre: "Fantasy")))!;
Assert.That(afterUnrelatedUpdate.Phase, Is.EqualTo(ProjectPhase.Outlining)); Assert.That(afterUnrelatedUpdate.Phase, Is.EqualTo(NovelPhase.Outlining));
} }
[Test] [Test]
public async Task A_project_response_carries_the_owner_id_and_the_caller_s_role() public async Task A_novel_response_carries_the_owner_id_and_the_caller_s_role()
{ {
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
var response = project.ToResponse(await Access.GetMyRoleAsync(project)); var response = novel.ToResponse(await Access.GetMyRoleAsync(novel));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -65,10 +65,10 @@ public class ProjectDataTests : ServiceTestFixture
[Test] [Test]
public async Task Chapters_are_numbered_in_sequence_when_no_number_is_given() public async Task Chapters_are_numbered_in_sequence_when_no_number_is_given()
{ {
var projectId = await NewProjectAsync(); var novelId = await NewNovelAsync();
var first = await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall")); var first = await Chapters.CreateAsync(novelId, new CreateChapterRequest("Landfall"));
var second = await Chapters.CreateAsync(projectId, new CreateChapterRequest("The Harbour")); var second = await Chapters.CreateAsync(novelId, new CreateChapterRequest("The Harbour"));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -80,9 +80,9 @@ public class ProjectDataTests : ServiceTestFixture
[Test] [Test]
public async Task Word_count_is_recomputed_whenever_prose_changes() public async Task Word_count_is_recomputed_whenever_prose_changes()
{ {
var projectId = await NewProjectAsync(); var novelId = await NewNovelAsync();
var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest( var chapter = await Chapters.CreateAsync(novelId, new CreateChapterRequest(
"Landfall", Prose: "Five words go right here")); "Landfall", Prose: "Five words go right here"));
Assert.That(chapter.WordCount, Is.EqualTo(5)); Assert.That(chapter.WordCount, Is.EqualTo(5));
@@ -104,8 +104,8 @@ public class ProjectDataTests : ServiceTestFixture
[Test] [Test]
public async Task Chapter_updates_that_omit_prose_leave_the_draft_untouched() public async Task Chapter_updates_that_omit_prose_leave_the_draft_untouched()
{ {
var projectId = await NewProjectAsync(); var novelId = await NewNovelAsync();
var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest( var chapter = await Chapters.CreateAsync(novelId, new CreateChapterRequest(
"Landfall", Prose: "The tide came in slow.")); "Landfall", Prose: "The tide came in slow."));
var updated = (await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Status: DraftStatus.Revised)))!; var updated = (await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Status: DraftStatus.Revised)))!;
@@ -119,45 +119,45 @@ public class ProjectDataTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Deleting_a_project_takes_its_characters_and_chapters() public async Task Deleting_a_novel_takes_its_characters_and_chapters()
{ {
var projectId = await NewProjectAsync(); var novelId = await NewNovelAsync();
await Characters.CreateAsync(projectId, new CreateCharacterRequest("Ines")); await Characters.CreateAsync(novelId, new CreateCharacterRequest("Ines"));
await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall")); await Chapters.CreateAsync(novelId, new CreateChapterRequest("Landfall"));
await Projects.DeleteAsync(projectId); await Novels.DeleteAsync(novelId);
using var verification = Db.CreateContext(); using var verification = Db.CreateContext();
Assert.Multiple(async () => Assert.Multiple(async () =>
{ {
Assert.That(await verification.Projects.CountAsync(), Is.EqualTo(0)); Assert.That(await verification.Novels.CountAsync(), Is.EqualTo(0));
Assert.That(await verification.Characters.CountAsync(), Is.EqualTo(0)); Assert.That(await verification.Characters.CountAsync(), Is.EqualTo(0));
Assert.That(await verification.Chapters.CountAsync(), Is.EqualTo(0)); Assert.That(await verification.Chapters.CountAsync(), Is.EqualTo(0));
}); });
} }
[Test] [Test]
public async Task Relating_characters_across_projects_is_refused() public async Task Relating_characters_across_novels_is_refused()
{ {
var firstProject = await NewProjectAsync(); var firstNovel = await NewNovelAsync();
var secondProject = (await Projects.CreateAsync(new CreateProjectRequest("Other Book"))).Id; var secondNovel = (await Novels.CreateAsync(new CreateNovelRequest("Other Book"))).Id;
var ines = await Characters.CreateAsync(firstProject, new CreateCharacterRequest("Ines")); var ines = await Characters.CreateAsync(firstNovel, new CreateCharacterRequest("Ines"));
var stranger = await Characters.CreateAsync(secondProject, new CreateCharacterRequest("Stranger")); var stranger = await Characters.CreateAsync(secondNovel, new CreateCharacterRequest("Stranger"));
Assert.That( Assert.That(
async () => await Characters.AddRelationshipAsync( async () => await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(stranger.Id, "sister")), ines.Id, new CreateRelationshipRequest(stranger.Id, "sister")),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project")); Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same novel"));
} }
[Test] [Test]
public async Task Relationships_resolve_the_other_character_by_name() public async Task Relationships_resolve_the_other_character_by_name()
{ {
var projectId = await NewProjectAsync(); var novelId = await NewNovelAsync();
var ines = await Characters.CreateAsync(projectId, new CreateCharacterRequest("Ines")); var ines = await Characters.CreateAsync(novelId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(projectId, new CreateCharacterRequest("Mara")); var mara = await Characters.CreateAsync(novelId, new CreateCharacterRequest("Mara"));
var updated = (await Characters.AddRelationshipAsync( var updated = (await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(mara.Id, "sister", "Estranged since the fire.")))!; ines.Id, new CreateRelationshipRequest(mara.Id, "sister", "Estranged since the fire.")))!;
@@ -170,6 +170,6 @@ public class ProjectDataTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Reading_a_missing_project_returns_null_rather_than_throwing() => public async Task Reading_a_missing_novel_returns_null_rather_than_throwing() =>
Assert.That(await Projects.GetAsync(Guid.NewGuid()), Is.Null); Assert.That(await Novels.GetAsync(Guid.NewGuid()), Is.Null);
} }
+35 -35
View File
@@ -1,7 +1,7 @@
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Projects; using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
@@ -9,21 +9,21 @@ namespace Novelly.Api.Tests;
[TestFixture] [TestFixture]
public class OpenQuestionTests : ServiceTestFixture public class OpenQuestionTests : ServiceTestFixture
{ {
private Guid _projectId; private Guid _novelId;
private Guid _chapterId; private Guid _chapterId;
private Guid _characterId; private Guid _characterId;
protected override void OnSetUp() protected override void OnSetUp()
{ {
_projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id; _novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id;
_chapterId = Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")).Result.Id; _chapterId = Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall")).Result.Id;
_characterId = Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")).Result.Id; _characterId = Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")).Result.Id;
} }
[Test] [Test]
public async Task A_question_can_hang_off_a_chapter_and_a_character_at_once() public async Task A_question_can_hang_off_a_chapter_and_a_character_at_once()
{ {
var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Does she know about the letter before the harbour?", "Does she know about the letter before the harbour?",
ChapterId: _chapterId, ChapterId: _chapterId,
CharacterId: _characterId)); CharacterId: _characterId));
@@ -41,7 +41,7 @@ public class OpenQuestionTests : ServiceTestFixture
public async Task A_question_about_the_book_as_a_whole_needs_no_association() public async Task A_question_about_the_book_as_a_whole_needs_no_association()
{ {
var question = await Questions.CreateAsync( var question = await Questions.CreateAsync(
_projectId, new CreateOpenQuestionRequest("Is this one book or two?")); _novelId, new CreateOpenQuestionRequest("Is this one book or two?"));
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -53,21 +53,21 @@ public class OpenQuestionTests : ServiceTestFixture
[Test] [Test]
public async Task The_outline_and_the_character_page_each_see_only_their_own_questions() public async Task The_outline_and_the_character_page_each_see_only_their_own_questions()
{ {
await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Where does the chapter break?", ChapterId: _chapterId)); "Where does the chapter break?", ChapterId: _chapterId));
await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"What does she actually want?", CharacterId: _characterId)); "What does she actually want?", CharacterId: _characterId));
await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest("Is this one book or two?")); await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest("Is this one book or two?"));
var forChapter = await Questions.ListAsync(_projectId, chapterId: _chapterId); var forChapter = await Questions.ListAsync(_novelId, chapterId: _chapterId);
var forCharacter = await Questions.ListAsync(_projectId, characterId: _characterId); var forCharacter = await Questions.ListAsync(_novelId, characterId: _characterId);
var forProject = await Questions.ListAsync(_projectId); var forNovel = await Questions.ListAsync(_novelId);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(forChapter.Select(q => q.Question), Is.EqualTo(new[] { "Where does the chapter break?" })); Assert.That(forChapter.Select(q => q.Question), Is.EqualTo(new[] { "Where does the chapter break?" }));
Assert.That(forCharacter.Select(q => q.Question), Is.EqualTo(new[] { "What does she actually want?" })); Assert.That(forCharacter.Select(q => q.Question), Is.EqualTo(new[] { "What does she actually want?" }));
Assert.That(forProject, Has.Count.EqualTo(3)); Assert.That(forNovel, Has.Count.EqualTo(3));
}); });
} }
@@ -75,13 +75,13 @@ public class OpenQuestionTests : ServiceTestFixture
public async Task Resolved_questions_drop_off_the_list_unless_asked_for() public async Task Resolved_questions_drop_off_the_list_unless_asked_for()
{ {
var settled = await Questions.CreateAsync( var settled = await Questions.CreateAsync(
_projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); _novelId, new CreateOpenQuestionRequest("Where does the chapter break?"));
await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest("Is this one book or two?")); await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest("Is this one book or two?"));
await Questions.ResolveAsync(settled.Id, new ResolveOpenQuestionRequest("After the harbour.")); await Questions.ResolveAsync(settled.Id, new ResolveOpenQuestionRequest("After the harbour."));
var open = await Questions.ListAsync(_projectId); var open = await Questions.ListAsync(_novelId);
var everything = await Questions.ListAsync(_projectId, includeResolved: true); var everything = await Questions.ListAsync(_novelId, includeResolved: true);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -97,7 +97,7 @@ public class OpenQuestionTests : ServiceTestFixture
public async Task Resolving_records_what_was_decided() public async Task Resolving_records_what_was_decided()
{ {
var question = await Questions.CreateAsync( var question = await Questions.CreateAsync(
_projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); _novelId, new CreateOpenQuestionRequest("Where does the chapter break?"));
var resolved = (await Questions.ResolveAsync( var resolved = (await Questions.ResolveAsync(
question.Id, new ResolveOpenQuestionRequest("After the harbour burns.")))!; question.Id, new ResolveOpenQuestionRequest("After the harbour burns.")))!;
@@ -115,7 +115,7 @@ public class OpenQuestionTests : ServiceTestFixture
{ {
await Chapters.UpdateAsync(_chapterId, new UpdateChapterRequest(Notes: "Runs long.")); await Chapters.UpdateAsync(_chapterId, new UpdateChapterRequest(Notes: "Runs long."));
var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Where does the chapter break?", ChapterId: _chapterId, CharacterId: _characterId)); "Where does the chapter break?", ChapterId: _chapterId, CharacterId: _characterId));
await Questions.ResolveAsync( await Questions.ResolveAsync(
@@ -136,7 +136,7 @@ public class OpenQuestionTests : ServiceTestFixture
[Test] [Test]
public async Task A_resolution_stays_off_the_notes_unless_asked_for() public async Task A_resolution_stays_off_the_notes_unless_asked_for()
{ {
var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Where does the chapter break?", ChapterId: _chapterId)); "Where does the chapter break?", ChapterId: _chapterId));
await Questions.ResolveAsync(question.Id, new ResolveOpenQuestionRequest("After the harbour.")); await Questions.ResolveAsync(question.Id, new ResolveOpenQuestionRequest("After the harbour."));
@@ -147,7 +147,7 @@ public class OpenQuestionTests : ServiceTestFixture
[Test] [Test]
public async Task Reopening_clears_the_resolution_but_leaves_the_note_behind() public async Task Reopening_clears_the_resolution_but_leaves_the_note_behind()
{ {
var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Where does the chapter break?", ChapterId: _chapterId)); "Where does the chapter break?", ChapterId: _chapterId));
await Questions.ResolveAsync( await Questions.ResolveAsync(
@@ -167,7 +167,7 @@ public class OpenQuestionTests : ServiceTestFixture
[Test] [Test]
public async Task A_question_can_be_detached_from_what_it_was_about() public async Task A_question_can_be_detached_from_what_it_was_about()
{ {
var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Where does the chapter break?", ChapterId: _chapterId, CharacterId: _characterId)); "Where does the chapter break?", ChapterId: _chapterId, CharacterId: _characterId));
var detached = (await Questions.UpdateAsync( var detached = (await Questions.UpdateAsync(
@@ -184,7 +184,7 @@ public class OpenQuestionTests : ServiceTestFixture
[Test] [Test]
public async Task Deleting_a_chapter_leaves_its_questions_open_rather_than_taking_them() public async Task Deleting_a_chapter_leaves_its_questions_open_rather_than_taking_them()
{ {
var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Does she know about the letter?", ChapterId: _chapterId)); "Does she know about the letter?", ChapterId: _chapterId));
await Chapters.DeleteAsync(_chapterId); await Chapters.DeleteAsync(_chapterId);
@@ -202,52 +202,52 @@ public class OpenQuestionTests : ServiceTestFixture
public async Task A_question_can_be_deleted_outright() public async Task A_question_can_be_deleted_outright()
{ {
var question = await Questions.CreateAsync( var question = await Questions.CreateAsync(
_projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); _novelId, new CreateOpenQuestionRequest("Where does the chapter break?"));
await Questions.DeleteAsync(question.Id); await Questions.DeleteAsync(question.Id);
Assert.Multiple(async () => Assert.Multiple(async () =>
{ {
Assert.That(await Questions.ListAsync(_projectId, includeResolved: true), Is.Empty); Assert.That(await Questions.ListAsync(_novelId, includeResolved: true), Is.Empty);
Assert.That(await Questions.GetAsync(question.Id), Is.Null); Assert.That(await Questions.GetAsync(question.Id), Is.Null);
}); });
} }
[Test] [Test]
public async Task Deleting_a_project_takes_its_questions() public async Task Deleting_a_novel_takes_its_questions()
{ {
await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest("Is this one book or two?")); await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest("Is this one book or two?"));
await Projects.DeleteAsync(_projectId); await Novels.DeleteAsync(_novelId);
using var verification = Db.CreateContext(); using var verification = Db.CreateContext();
Assert.That(verification.OpenQuestions.Count(), Is.EqualTo(0)); Assert.That(verification.OpenQuestions.Count(), Is.EqualTo(0));
} }
[Test] [Test]
public void A_question_cannot_be_attached_to_another_project_s_chapter() public void A_question_cannot_be_attached_to_another_novel_s_chapter()
{ {
var elsewhere = Chapters.CreateAsync( var elsewhere = Chapters.CreateAsync(
Projects.CreateAsync(new CreateProjectRequest("Other Book")).Result.Id, Novels.CreateAsync(new CreateNovelRequest("Other Book")).Result.Id,
new CreateChapterRequest("Elsewhere")).Result; new CreateChapterRequest("Elsewhere")).Result;
Assert.That( Assert.That(
async () => await Questions.CreateAsync( async () => await Questions.CreateAsync(
_projectId, new CreateOpenQuestionRequest("A question", ChapterId: elsewhere.Id)), _novelId, new CreateOpenQuestionRequest("A question", ChapterId: elsewhere.Id)),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project")); Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same novel"));
} }
[Test] [Test]
public void A_blank_question_is_refused() => public void A_blank_question_is_refused() =>
Assert.That( Assert.That(
async () => await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest(" ")), async () => await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(" ")),
Throws.TypeOf<ArgumentException>()); Throws.TypeOf<ArgumentException>());
[Test] [Test]
public async Task Resolving_with_nothing_decided_is_refused() public async Task Resolving_with_nothing_decided_is_refused()
{ {
var question = await Questions.CreateAsync( var question = await Questions.CreateAsync(
_projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); _novelId, new CreateOpenQuestionRequest("Where does the chapter break?"));
Assert.That( Assert.That(
async () => await Questions.ResolveAsync(question.Id, new ResolveOpenQuestionRequest(" ")), async () => await Questions.ResolveAsync(question.Id, new ResolveOpenQuestionRequest(" ")),
@@ -2,7 +2,7 @@ using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Genres; using Novelly.Api.Genres;
using Novelly.Api.Projects; using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Novelly.Api.Users; using Novelly.Api.Users;
@@ -13,9 +13,9 @@ public abstract class ServiceTestFixture
{ {
protected TestDatabase Db { get; private set; } = null!; protected TestDatabase Db { get; private set; } = null!;
protected TestUserContext UserContext { get; private set; } = null!; protected TestUserContext UserContext { get; private set; } = null!;
protected ProjectAccessService Access { get; private set; } = null!; protected NovelAccessService Access { get; private set; } = null!;
protected TagService Tags { get; private set; } = null!; protected TagService Tags { get; private set; } = null!;
protected ProjectService Projects { get; private set; } = null!; protected NovelService Novels { get; private set; } = null!;
protected CharacterService Characters { get; private set; } = null!; protected CharacterService Characters { get; private set; } = null!;
protected ChapterService Chapters { get; private set; } = null!; protected ChapterService Chapters { get; private set; } = null!;
protected BeatService Beats { get; private set; } = null!; protected BeatService Beats { get; private set; } = null!;
@@ -23,7 +23,7 @@ public abstract class ServiceTestFixture
protected OpenQuestionService Questions { get; private set; } = null!; protected OpenQuestionService Questions { get; private set; } = null!;
protected GenreService Genres { get; private set; } = null!; protected GenreService Genres { get; private set; } = null!;
protected CapturingLogger<ProjectService> ProjectLogs { get; private set; } = null!; protected CapturingLogger<NovelService> NovelLogs { get; private set; } = null!;
protected CapturingLogger<CharacterService> CharacterLogs { get; private set; } = null!; protected CapturingLogger<CharacterService> CharacterLogs { get; private set; } = null!;
protected CapturingLogger<ChapterService> ChapterLogs { get; private set; } = null!; protected CapturingLogger<ChapterService> ChapterLogs { get; private set; } = null!;
protected CapturingLogger<BeatService> BeatLogs { get; private set; } = null!; protected CapturingLogger<BeatService> BeatLogs { get; private set; } = null!;
@@ -37,7 +37,7 @@ public abstract class ServiceTestFixture
{ {
Db = new TestDatabase(); Db = new TestDatabase();
UserContext = new TestUserContext(); UserContext = new TestUserContext();
Access = new ProjectAccessService(Db.Context, UserContext, new CapturingLogger<ProjectAccessService>()); Access = new NovelAccessService(Db.Context, UserContext, new CapturingLogger<NovelAccessService>());
Db.Context.Users.Add(new NovellyUser Db.Context.Users.Add(new NovellyUser
{ {
@@ -50,7 +50,7 @@ public abstract class ServiceTestFixture
Db.Context.SaveChanges(); Db.Context.SaveChanges();
TagLogs = new CapturingLogger<TagService>(); TagLogs = new CapturingLogger<TagService>();
ProjectLogs = new CapturingLogger<ProjectService>(); NovelLogs = new CapturingLogger<NovelService>();
CharacterLogs = new CapturingLogger<CharacterService>(); CharacterLogs = new CapturingLogger<CharacterService>();
ChapterLogs = new CapturingLogger<ChapterService>(); ChapterLogs = new CapturingLogger<ChapterService>();
BeatLogs = new CapturingLogger<BeatService>(); BeatLogs = new CapturingLogger<BeatService>();
@@ -59,8 +59,8 @@ public abstract class ServiceTestFixture
GenreLogs = new CapturingLogger<GenreService>(); GenreLogs = new CapturingLogger<GenreService>();
Tags = new TagService(Db.Context, Access, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator()); Tags = new TagService(Db.Context, Access, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator());
Projects = new ProjectService( Novels = new NovelService(
Db.Context, Access, UserContext, ProjectLogs, new CreateProjectRequestValidator(), new UpdateProjectRequestValidator()); Db.Context, Access, UserContext, NovelLogs, new CreateNovelRequestValidator(), new UpdateNovelRequestValidator());
Characters = new CharacterService( Characters = new CharacterService(
Db.Context, Access, Tags, CharacterLogs, Db.Context, Access, Tags, CharacterLogs,
new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator(), new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator(),
+32 -32
View File
@@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Projects; using Novelly.Api.Novels;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
@@ -10,34 +10,34 @@ namespace Novelly.Api.Tests;
[TestFixture] [TestFixture]
public class TagServiceTests : ServiceTestFixture public class TagServiceTests : ServiceTestFixture
{ {
private Guid _projectId; private Guid _novelId;
protected override void OnSetUp() => protected override void OnSetUp() =>
_projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id; _novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id;
[Test] [Test]
public async Task Applying_an_unknown_tag_by_name_creates_it() public async Task Applying_an_unknown_tag_by_name_creates_it()
{ {
var character = await Characters.CreateAsync( var character = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"])); _novelId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"]));
Assert.Multiple(async () => Assert.Multiple(async () =>
{ {
Assert.That( Assert.That(
character.Tags.Select(t => t.Name), character.Tags.Select(t => t.Name),
Is.EquivalentTo(new[] { "betrayal", "the sea" })); Is.EquivalentTo(new[] { "betrayal", "the sea" }));
Assert.That(await Tags.ListAsync(_projectId), Has.Count.EqualTo(2)); Assert.That(await Tags.ListAsync(_novelId), Has.Count.EqualTo(2));
}); });
} }
[Test] [Test]
public async Task The_same_name_resolves_to_one_tag_regardless_of_casing() public async Task The_same_name_resolves_to_one_tag_regardless_of_casing()
{ {
await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["Betrayal"])); await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines", Tags: ["Betrayal"]));
var chapter = await Chapters.CreateAsync( var chapter = await Chapters.CreateAsync(
_projectId, new CreateChapterRequest("Landfall", Tags: ["betrayal"])); _novelId, new CreateChapterRequest("Landfall", Tags: ["betrayal"]));
var listed = await Tags.ListAsync(_projectId); var listed = await Tags.ListAsync(_novelId);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -52,7 +52,7 @@ public class TagServiceTests : ServiceTestFixture
public async Task Supplying_a_tag_list_replaces_the_existing_tags() public async Task Supplying_a_tag_list_replaces_the_existing_tags()
{ {
var character = await Characters.CreateAsync( var character = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"])); _novelId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"]));
var updated = (await Characters.UpdateAsync( var updated = (await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Tags: ["the sea", "maps"])))!; character.Id, new UpdateCharacterRequest(Tags: ["the sea", "maps"])))!;
@@ -64,7 +64,7 @@ public class TagServiceTests : ServiceTestFixture
public async Task Omitting_the_tag_list_leaves_tags_alone() public async Task Omitting_the_tag_list_leaves_tags_alone()
{ {
var character = await Characters.CreateAsync( var character = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); _novelId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
var updated = (await Characters.UpdateAsync( var updated = (await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Occupation: "Cartographer")))!; character.Id, new UpdateCharacterRequest(Occupation: "Cartographer")))!;
@@ -80,14 +80,14 @@ public class TagServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Cross_reference_gathers_everything_carrying_a_tag() public async Task Cross_reference_gathers_everything_carrying_a_tag()
{ {
await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
var chapter = await Chapters.CreateAsync( var chapter = await Chapters.CreateAsync(
_projectId, new CreateChapterRequest("Landfall", Tags: ["betrayal"])); _novelId, new CreateChapterRequest("Landfall", Tags: ["betrayal"]));
await Beats.CreateAsync(chapter.Id, new CreateBeatRequest( await Beats.CreateAsync(chapter.Id, new CreateBeatRequest(
"She burns the atlas", WhatHappened: "In the galley stove.", Tags: ["betrayal"])); "She burns the atlas", WhatHappened: "In the galley stove.", Tags: ["betrayal"]));
await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Unrelated beat")); await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Unrelated beat"));
var tagId = (await Tags.ListAsync(_projectId)).Single().Id; var tagId = (await Tags.ListAsync(_novelId)).Single().Id;
var references = (await Tags.GetReferencesAsync(tagId))!; var references = (await Tags.GetReferencesAsync(tagId))!;
Assert.Multiple(() => Assert.Multiple(() =>
@@ -106,12 +106,12 @@ public class TagServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Usage_counts_are_reported_per_kind() public async Task Usage_counts_are_reported_per_kind()
{ {
await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["sea"])); await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines", Tags: ["sea"]));
await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara", Tags: ["sea"])); await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara", Tags: ["sea"]));
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("A beat", Tags: ["sea"])); await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("A beat", Tags: ["sea"]));
var summary = (await Tags.ListAsync(_projectId)).Single(); var summary = (await Tags.ListAsync(_novelId)).Single();
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -125,13 +125,13 @@ public class TagServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Duplicate_tag_names_are_refused_on_create_and_rename() public async Task Duplicate_tag_names_are_refused_on_create_and_rename()
{ {
await Tags.CreateAsync(_projectId, new CreateTagRequest("betrayal")); await Tags.CreateAsync(_novelId, new CreateTagRequest("betrayal"));
Assert.That( Assert.That(
async () => await Tags.CreateAsync(_projectId, new CreateTagRequest("Betrayal")), async () => await Tags.CreateAsync(_novelId, new CreateTagRequest("Betrayal")),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("already has a tag")); Throws.TypeOf<InvalidOperationException>().With.Message.Contains("already has a tag"));
var other = await Tags.CreateAsync(_projectId, new CreateTagRequest("the sea")); var other = await Tags.CreateAsync(_novelId, new CreateTagRequest("the sea"));
Assert.That( Assert.That(
async () => await Tags.UpdateAsync(other.Id, new UpdateTagRequest(Name: "betrayal")), async () => await Tags.UpdateAsync(other.Id, new UpdateTagRequest(Name: "betrayal")),
@@ -139,19 +139,19 @@ public class TagServiceTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Tags_are_scoped_to_their_project() public async Task Tags_are_scoped_to_their_novel()
{ {
var otherProject = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); var otherNovel = await Novels.CreateAsync(new CreateNovelRequest("Other Book"));
await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["sea"])); await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines", Tags: ["sea"]));
await Characters.CreateAsync(otherProject.Id, new CreateCharacterRequest("Someone", Tags: ["sea"])); await Characters.CreateAsync(otherNovel.Id, new CreateCharacterRequest("Someone", Tags: ["sea"]));
using var verification = Db.CreateContext(); using var verification = Db.CreateContext();
Assert.Multiple(async () => Assert.Multiple(async () =>
{ {
Assert.That(await Tags.ListAsync(_projectId), Has.Count.EqualTo(1)); Assert.That(await Tags.ListAsync(_novelId), Has.Count.EqualTo(1));
Assert.That(await Tags.ListAsync(otherProject.Id), Has.Count.EqualTo(1)); Assert.That(await Tags.ListAsync(otherNovel.Id), Has.Count.EqualTo(1));
Assert.That(await verification.Tags.CountAsync(), Is.EqualTo(2)); Assert.That(await verification.Tags.CountAsync(), Is.EqualTo(2));
}); });
} }
@@ -160,8 +160,8 @@ public class TagServiceTests : ServiceTestFixture
public async Task Deleting_a_tag_leaves_what_carried_it_intact() public async Task Deleting_a_tag_leaves_what_carried_it_intact()
{ {
var character = await Characters.CreateAsync( var character = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); _novelId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
var tagId = (await Tags.ListAsync(_projectId)).Single().Id; var tagId = (await Tags.ListAsync(_novelId)).Single().Id;
await Tags.DeleteAsync(tagId); await Tags.DeleteAsync(tagId);
@@ -175,11 +175,11 @@ public class TagServiceTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Deleting_a_project_takes_its_tags() public async Task Deleting_a_novel_takes_its_tags()
{ {
await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
await Projects.DeleteAsync(_projectId); await Novels.DeleteAsync(_novelId);
using var verification = Db.CreateContext(); using var verification = Db.CreateContext();
Assert.That(await verification.Tags.CountAsync(), Is.EqualTo(0)); Assert.That(await verification.Tags.CountAsync(), Is.EqualTo(0));
@@ -188,6 +188,6 @@ public class TagServiceTests : ServiceTestFixture
[Test] [Test]
public void A_blank_tag_name_is_refused() => public void A_blank_tag_name_is_refused() =>
Assert.That( Assert.That(
async () => await Tags.CreateAsync(_projectId, new CreateTagRequest(" ")), async () => await Tags.CreateAsync(_novelId, new CreateTagRequest(" ")),
Throws.TypeOf<ArgumentException>()); Throws.TypeOf<ArgumentException>());
} }