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:
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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() =>
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
+1169
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");
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
+19
-19
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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() =>
|
||||||
|
|||||||
@@ -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,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();
|
||||||
|
|||||||
@@ -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);
|
||||||
+5
-5
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,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(
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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.")]
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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) })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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,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('')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
+15
-15
@@ -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>
|
||||||
)
|
)
|
||||||
+21
-21
@@ -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('')
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+34
-34
@@ -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))!;
|
||||||
|
|
||||||
|
|||||||
+42
-42
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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>());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user