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:
@@ -1,14 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
public class AgentConversation
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; init; }
|
||||
public Project? Project { get; init; }
|
||||
public Guid NovelId { get; init; }
|
||||
public Novel? Novel { get; init; }
|
||||
|
||||
public string Title { get; init; } = "New conversation";
|
||||
|
||||
|
||||
@@ -7,22 +7,22 @@ public static class AgentEndpoints
|
||||
{
|
||||
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<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/conversations", async (
|
||||
Guid projectId, NovelAgentService agent, CancellationToken ct) =>
|
||||
Results.Ok(await agent.ListConversationsAsync(projectId, ct)))
|
||||
.WithSummary("List the project's agent conversations.");
|
||||
novelScoped.MapGet("/conversations", async (
|
||||
Guid novelId, NovelAgentService agent, CancellationToken ct) =>
|
||||
Results.Ok(await agent.ListConversationsAsync(novelId, ct)))
|
||||
.WithSummary("List the novel's agent conversations.");
|
||||
|
||||
projectScoped.MapPost("/messages", async (
|
||||
Guid projectId,
|
||||
novelScoped.MapPost("/messages", async (
|
||||
Guid novelId,
|
||||
SendAgentMessageRequest request,
|
||||
NovelAgentService agent,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var reply = await agent.SendMessageAsync(projectId, request, ct);
|
||||
var reply = await agent.SendMessageAsync(novelId, request, ct);
|
||||
return reply is null
|
||||
? Results.NotFound()
|
||||
: Results.Ok(new AgentTurnResponse(reply.ConversationId, reply.ToResponse()));
|
||||
|
||||
@@ -4,9 +4,9 @@ using Novelly.Api.Common.Validation;
|
||||
|
||||
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);
|
||||
|
||||
@@ -46,7 +46,7 @@ public static class AgentMapping
|
||||
|
||||
public static ConversationResponse ToResponse(this AgentConversation conversation) => new(
|
||||
conversation.Id,
|
||||
conversation.ProjectId,
|
||||
conversation.NovelId,
|
||||
conversation.Title,
|
||||
[.. conversation.Messages.OrderBy(m => m.Sequence).Select(m => m.ToResponse())],
|
||||
conversation.UpdatedAt);
|
||||
|
||||
@@ -6,7 +6,7 @@ using Microsoft.Extensions.Options;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
@@ -26,14 +26,14 @@ public class NovelAgentService(
|
||||
private readonly AgentOptions _options = options.Value;
|
||||
|
||||
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
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.Where(c => c.NovelId == novelId)
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -63,39 +63,39 @@ public class NovelAgentService(
|
||||
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));
|
||||
sendMessageValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
|
||||
logger.LogInformation(
|
||||
"Sending agent message for project {ProjectId}, conversation {ConversationId}, message length {MessageLength}",
|
||||
projectId, request.ConversationId, request.Message.Length);
|
||||
"Sending agent message for novel {NovelId}, conversation {ConversationId}, message length {MessageLength}",
|
||||
novelId, request.ConversationId, request.Message.Length);
|
||||
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
|
||||
if (project is null)
|
||||
var novel = await db.Novels.FirstOrDefaultAsync(p => p.Id == novelId, ct);
|
||||
if (novel is null)
|
||||
{
|
||||
logger.LogWarning("Project {ProjectId} not found", projectId);
|
||||
logger.LogWarning("Novel {NovelId} not found", novelId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var conversation = request.ConversationId is { } id
|
||||
? await FindConversationAsync(id, ct)
|
||||
: StartConversation(projectId, request.Message);
|
||||
: StartConversation(novelId, request.Message);
|
||||
|
||||
if (conversation is null) return null;
|
||||
|
||||
await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct);
|
||||
|
||||
var systemPrompt = BuildSystemPrompt(project);
|
||||
var systemPrompt = BuildSystemPrompt(novel);
|
||||
var transcript = BuildTranscript(conversation);
|
||||
var toolCalls = new List<ToolCallResponse>();
|
||||
var text = new StringBuilder();
|
||||
|
||||
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);
|
||||
|
||||
@@ -113,9 +113,9 @@ public class NovelAgentService(
|
||||
var results = new List<AgentContentBlock>();
|
||||
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));
|
||||
results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError));
|
||||
@@ -125,7 +125,7 @@ public class NovelAgentService(
|
||||
|
||||
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._");
|
||||
}
|
||||
@@ -165,19 +165,19 @@ public class NovelAgentService(
|
||||
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
|
||||
{
|
||||
ProjectId = projectId,
|
||||
NovelId = novelId,
|
||||
Title = Summarise(firstMessage)
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -209,25 +209,25 @@ public class NovelAgentService(
|
||||
[new AgentTextBlock(m.Content)]))
|
||||
];
|
||||
|
||||
private static string BuildSystemPrompt(Project project)
|
||||
private static string BuildSystemPrompt(Novel novel)
|
||||
{
|
||||
var brief = new StringBuilder();
|
||||
brief.AppendLine($"Title: {project.Title}");
|
||||
if (!string.IsNullOrWhiteSpace(project.Genre)) brief.AppendLine($"Genre: {project.Genre}");
|
||||
if (!string.IsNullOrWhiteSpace(project.Logline)) brief.AppendLine($"Logline: {project.Logline}");
|
||||
if (project.TargetWordCount is { } target) brief.AppendLine($"Target length: {target:N0} words");
|
||||
brief.AppendLine($"Title: {novel.Title}");
|
||||
if (!string.IsNullOrWhiteSpace(novel.Genre)) brief.AppendLine($"Genre: {novel.Genre}");
|
||||
if (!string.IsNullOrWhiteSpace(novel.Logline)) brief.AppendLine($"Logline: {novel.Logline}");
|
||||
if (novel.TargetWordCount is { } target) brief.AppendLine($"Target length: {target:N0} words");
|
||||
|
||||
return $"""
|
||||
You are a developmental editor and writing partner embedded in the software the
|
||||
writer is using to plan their novel. You have tools that read and write the
|
||||
project's real data: the brief, character dossiers, the outline (beats) and each
|
||||
novel's real data: the brief, character dossiers, the outline (beats) and each
|
||||
chapter's drafted prose.
|
||||
|
||||
The project you are working on:
|
||||
The novel you are working on:
|
||||
{brief}
|
||||
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.
|
||||
- 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.
|
||||
@@ -239,7 +239,7 @@ public class NovelAgentService(
|
||||
genuinely in tension, what the outline is missing — over line-level polish,
|
||||
unless the writer asks for prose.
|
||||
- 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
|
||||
go-ahead first.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
@@ -23,7 +23,7 @@ public record AgentTool(
|
||||
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler);
|
||||
|
||||
public class NovelAgentToolset(
|
||||
ProjectService projects,
|
||||
NovelService novels,
|
||||
CharacterService characters,
|
||||
CharacterArcService arcs,
|
||||
ChapterService chapters,
|
||||
@@ -45,7 +45,7 @@ public class NovelAgentToolset(
|
||||
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
||||
[.. 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))
|
||||
{
|
||||
@@ -53,29 +53,29 @@ public class NovelAgentToolset(
|
||||
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
|
||||
{
|
||||
var result = await tool.Handler(projectId, input, ct);
|
||||
var result = await tool.Handler(novelId, input, ct);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -95,15 +95,15 @@ public class NovelAgentToolset(
|
||||
private IEnumerable<AgentTool> Build()
|
||||
{
|
||||
yield return new AgentTool(
|
||||
"get_project_brief",
|
||||
"Read the project's title, logline, synopsis, genre, notes and word-count target. "
|
||||
"get_novel_brief",
|
||||
"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.",
|
||||
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(
|
||||
"update_project_brief",
|
||||
"Revise the project's top-level fields. Only the fields you supply change; "
|
||||
"update_novel_brief",
|
||||
"Revise the novel's top-level fields. Only the fields you supply change; "
|
||||
+ "pass an empty string to clear a field.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("title", "New title.")
|
||||
@@ -114,27 +114,27 @@ public class NovelAgentToolset(
|
||||
.Str("notes", "Free-form notes on theme, tone, comparable titles.")
|
||||
.Int("target_word_count", "Target manuscript length in words.")
|
||||
.Build(),
|
||||
async (projectId, input, ct) => await OrNotFound(projects.UpdateAsync(projectId, new UpdateProjectRequest(
|
||||
async (novelId, input, ct) => await OrNotFound(novels.UpdateAsync(novelId, new UpdateNovelRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.String(input, "author"),
|
||||
JsonInput.String(input, "genre"),
|
||||
JsonInput.String(input, "logline"),
|
||||
JsonInput.String(input, "synopsis"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(null), "Project", projectId));
|
||||
JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(null), "Novel", novelId));
|
||||
|
||||
yield return new AgentTool(
|
||||
"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(),
|
||||
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(
|
||||
"create_character",
|
||||
"Add a character dossier. Name is the only requirement — leave fields blank when "
|
||||
+ "the writer has not decided them yet rather than inventing detail.",
|
||||
CharacterSchema(includeName: true, nameRequired: true).Build(),
|
||||
async (projectId, input, ct) => await OrNotFound(characters.CreateAsync(projectId, new CreateCharacterRequest(
|
||||
async (novelId, input, ct) => await OrNotFound(characters.CreateAsync(novelId, new CreateCharacterRequest(
|
||||
JsonInput.RequiredString(input, "name"),
|
||||
JsonInput.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting,
|
||||
JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
|
||||
@@ -152,7 +152,7 @@ public class NovelAgentToolset(
|
||||
JsonInput.String(input, "voice"),
|
||||
JsonInput.String(input, "notes"),
|
||||
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(
|
||||
"update_character",
|
||||
@@ -189,7 +189,7 @@ public class NovelAgentToolset(
|
||||
yield return new AgentTool(
|
||||
"link_character_identity",
|
||||
"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.",
|
||||
new JsonSchemaBuilder()
|
||||
.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(
|
||||
"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.",
|
||||
new JsonSchemaBuilder().Build(),
|
||||
async (projectId, _, ct) => await tags.ListAsync(projectId, ct));
|
||||
async (novelId, _, ct) => await tags.ListAsync(novelId, ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"get_tag_references",
|
||||
@@ -368,9 +368,9 @@ public class NovelAgentToolset(
|
||||
|
||||
yield return new AgentTool(
|
||||
"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(),
|
||||
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(
|
||||
"get_chapter",
|
||||
@@ -398,7 +398,7 @@ public class NovelAgentToolset(
|
||||
.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.")
|
||||
.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.Int(input, "number"),
|
||||
JsonInput.String(input, "summary"),
|
||||
@@ -407,7 +407,7 @@ public class NovelAgentToolset(
|
||||
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
|
||||
JsonInput.Int(input, "target_word_count"),
|
||||
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(
|
||||
"update_chapter",
|
||||
@@ -548,8 +548,8 @@ public class NovelAgentToolset(
|
||||
.Str("character_id", "Narrow to questions about one character.")
|
||||
.Bool("include_resolved", "Include questions already settled. Defaults to false.")
|
||||
.Build(),
|
||||
async (projectId, input, ct) => (await questions.ListAsync(
|
||||
projectId,
|
||||
async (novelId, input, ct) => (await questions.ListAsync(
|
||||
novelId,
|
||||
JsonInput.Guid(input, "chapter_id"),
|
||||
JsonInput.Guid(input, "character_id"),
|
||||
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("character_id", "The character this is about, if any.")
|
||||
.Build(),
|
||||
async (projectId, input, ct) => await OrNotFound(questions.CreateAsync(
|
||||
projectId,
|
||||
async (novelId, input, ct) => await OrNotFound(questions.CreateAsync(
|
||||
novelId,
|
||||
new CreateOpenQuestionRequest(
|
||||
JsonInput.RequiredString(input, "question"),
|
||||
JsonInput.String(input, "detail"),
|
||||
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(
|
||||
"resolve_open_question",
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Novelly.Api.Beats;
|
||||
|
||||
public class BeatService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
NovelAccessService access,
|
||||
TagService tags,
|
||||
ILogger<BeatService> logger,
|
||||
IModelValidator<CreateBeatRequest> createValidator,
|
||||
@@ -26,7 +26,7 @@ public class BeatService(
|
||||
|
||||
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
|
||||
|
||||
await RequireChapterAccessAsync(chapterId, ProjectPermission.Read, ct);
|
||||
await RequireChapterAccessAsync(chapterId, NovelPermission.Read, ct);
|
||||
|
||||
return await Query()
|
||||
.Where(b => b.ChapterId == chapterId)
|
||||
@@ -46,7 +46,7 @@ public class BeatService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await RequireBeatAccessAsync(beat, ProjectPermission.Read, ct);
|
||||
await RequireBeatAccessAsync(beat, NovelPermission.Read, ct);
|
||||
return beat;
|
||||
}
|
||||
|
||||
@@ -57,14 +57,14 @@ public class BeatService(
|
||||
|
||||
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);
|
||||
if (characterProjectId is null)
|
||||
var characterNovelId = await db.Characters.Where(c => c.Id == characterId).Select(c => (Guid?)c.NovelId).FirstOrDefaultAsync(ct);
|
||||
if (characterNovelId is null)
|
||||
{
|
||||
logger.LogWarning("Character {CharacterId} not found", characterId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(characterProjectId.Value, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(characterNovelId.Value, NovelPermission.Read, ct);
|
||||
|
||||
var beats = await db.Beats
|
||||
.Include(b => b.Chapter)
|
||||
@@ -95,7 +95,7 @@ public class BeatService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(chapter.ProjectId, ProjectPermission.CreateContent, ct);
|
||||
await access.RequireAsync(chapter.NovelId, NovelPermission.CreateContent, ct);
|
||||
|
||||
var beat = new Beat
|
||||
{
|
||||
@@ -108,12 +108,12 @@ public class BeatService(
|
||||
|
||||
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)
|
||||
{
|
||||
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
||||
beat.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
|
||||
}
|
||||
|
||||
chapter.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
@@ -145,7 +145,7 @@ public class BeatService(
|
||||
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.SortOrder = request.SortOrder ?? beat.SortOrder;
|
||||
@@ -156,12 +156,12 @@ public class BeatService(
|
||||
|
||||
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)
|
||||
{
|
||||
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
||||
beat.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -180,7 +180,7 @@ public class BeatService(
|
||||
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);
|
||||
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);
|
||||
|
||||
await RequireChapterAccessAsync(chapterId, ProjectPermission.Write, ct);
|
||||
await RequireChapterAccessAsync(chapterId, NovelPermission.Write, ct);
|
||||
|
||||
var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct);
|
||||
|
||||
@@ -246,15 +246,15 @@ public class BeatService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
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)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rejected character assignment: character {CharacterId} not found in project {ProjectId}",
|
||||
request.CharacterId, chapter.ProjectId);
|
||||
"Rejected character assignment: character {CharacterId} not found in novel {NovelId}",
|
||||
request.CharacterId, chapter.NovelId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -297,16 +297,16 @@ public class BeatService(
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rejected beat move: target chapter {TargetChapterId} not found in project {ProjectId}",
|
||||
request.TargetChapterId, chapter.ProjectId);
|
||||
"Rejected beat move: target chapter {TargetChapterId} not found in novel {NovelId}",
|
||||
request.TargetChapterId, chapter.NovelId);
|
||||
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 missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
|
||||
@@ -338,9 +338,9 @@ public class BeatService(
|
||||
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();
|
||||
if (distinct.Count == 0)
|
||||
@@ -349,17 +349,17 @@ public class BeatService(
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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(
|
||||
"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;
|
||||
}
|
||||
|
||||
@@ -376,13 +376,13 @@ public class BeatService(
|
||||
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);
|
||||
await access.RequireAsync(projectId, permission, ct);
|
||||
var novelId = await db.Chapters.Where(c => c.Id == chapterId).Select(c => c.NovelId).FirstOrDefaultAsync(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);
|
||||
|
||||
private IQueryable<Beat> Query() =>
|
||||
|
||||
@@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Chapters;
|
||||
@@ -10,8 +10,8 @@ namespace Novelly.Api.Chapters;
|
||||
public class Chapter
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
public Guid NovelId { get; set; }
|
||||
public Novel? Novel { 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.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(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
Guid NovelId,
|
||||
int Number,
|
||||
string Title,
|
||||
string? Summary,
|
||||
@@ -21,7 +21,7 @@ public record ChapterSummaryResponse(
|
||||
|
||||
public record ChapterResponse(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
Guid NovelId,
|
||||
int Number,
|
||||
string Title,
|
||||
string? Summary,
|
||||
@@ -115,7 +115,7 @@ file static class ChapterValidation
|
||||
public static class ChapterMapping
|
||||
{
|
||||
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.Status, c.TargetWordCount,
|
||||
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
|
||||
@@ -124,7 +124,7 @@ public static class ChapterMapping
|
||||
c.UpdatedAt);
|
||||
|
||||
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.Beats.Count, c.WordCount,
|
||||
[.. 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)
|
||||
{
|
||||
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<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) =>
|
||||
Results.Ok((await service.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse())))
|
||||
.WithSummary("List a project's chapters in manuscript order.");
|
||||
novelScoped.MapGet("/", async (Guid novelId, ChapterService service, CancellationToken ct) =>
|
||||
Results.Ok((await service.ListAsync(novelId, ct)).Select(c => c.ToSummaryResponse())))
|
||||
.WithSummary("List a novel's chapters in manuscript order.");
|
||||
|
||||
projectScoped.MapPost("/", async (
|
||||
Guid projectId, CreateChapterRequest request, ChapterService service, CancellationToken ct) =>
|
||||
novelScoped.MapPost("/", async (
|
||||
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)
|
||||
{
|
||||
return Results.NotFound();
|
||||
|
||||
@@ -9,24 +9,24 @@ namespace Novelly.Api.Chapters;
|
||||
|
||||
public class ChapterService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
NovelAccessService access,
|
||||
TagService tags,
|
||||
ILogger<ChapterService> logger,
|
||||
IModelValidator<CreateChapterRequest> createValidator,
|
||||
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
|
||||
.Include(c => c.Beats)
|
||||
.Include(c => c.Tags)
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.Where(c => c.NovelId == novelId)
|
||||
.OrderBy(c => c.Number)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
@@ -43,31 +43,31 @@ public class ChapterService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(chapter.NovelId, NovelPermission.Read, ct);
|
||||
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));
|
||||
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;
|
||||
}
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct);
|
||||
await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
|
||||
|
||||
var chapter = new Chapter
|
||||
{
|
||||
ProjectId = projectId,
|
||||
NovelId = novelId,
|
||||
Title = request.Title,
|
||||
Number = request.Number ?? await NextChapterNumberAsync(projectId, ct),
|
||||
Number = request.Number ?? await NextChapterNumberAsync(novelId, ct),
|
||||
Summary = request.Summary,
|
||||
Setting = request.Setting,
|
||||
Notes = request.Notes,
|
||||
@@ -79,7 +79,7 @@ public class ChapterService(
|
||||
|
||||
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);
|
||||
@@ -102,7 +102,7 @@ public class ChapterService(
|
||||
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.Number = request.Number ?? chapter.Number;
|
||||
@@ -122,7 +122,7 @@ public class ChapterService(
|
||||
|
||||
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);
|
||||
@@ -141,23 +141,23 @@ public class ChapterService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(chapter.ProjectId, ProjectPermission.DeleteContent, ct);
|
||||
await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct);
|
||||
|
||||
db.Chapters.Remove(chapter);
|
||||
await db.SaveChangesAsync(ct);
|
||||
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
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.Where(c => c.NovelId == novelId)
|
||||
.MaxAsync(c => (int?)c.Number, ct);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Characters;
|
||||
@@ -10,8 +10,8 @@ namespace Novelly.Api.Characters;
|
||||
public class Character
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
public Guid NovelId { get; set; }
|
||||
public Novel? Novel { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
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.Role).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.HasMany(c => c.Relationships).WithOne(r => r.Character!)
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace Novelly.Api.Characters;
|
||||
|
||||
public class CharacterArcService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
NovelAccessService access,
|
||||
ILogger<CharacterArcService> logger,
|
||||
IModelValidator<CreateArcStageRequest> createValidator,
|
||||
IModelValidator<UpdateArcStageRequest> updateValidator,
|
||||
@@ -21,7 +21,7 @@ public class CharacterArcService(
|
||||
|
||||
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()
|
||||
.Where(s => s.CharacterId == characterId)
|
||||
@@ -43,7 +43,7 @@ public class CharacterArcService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await RequireCharacterAccessAsync(stage.CharacterId, ProjectPermission.Read, ct);
|
||||
await RequireCharacterAccessAsync(stage.CharacterId, NovelPermission.Read, ct);
|
||||
return stage;
|
||||
}
|
||||
|
||||
@@ -63,8 +63,8 @@ public class CharacterArcService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.CreateContent, ct);
|
||||
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.CreateContent, ct);
|
||||
await EnsureChapterIsInSameNovelAsync(character, request.ChapterId, ct);
|
||||
|
||||
var stage = new CharacterArcStage
|
||||
{
|
||||
@@ -103,8 +103,8 @@ public class CharacterArcService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
|
||||
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
|
||||
await EnsureChapterIsInSameNovelAsync(character, request.ChapterId, ct);
|
||||
|
||||
stage.Title = Patch.Apply(stage.Title, request.Title) ?? stage.Title;
|
||||
stage.SortOrder = request.SortOrder ?? stage.SortOrder;
|
||||
@@ -128,7 +128,7 @@ public class CharacterArcService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await RequireCharacterAccessAsync(stage.CharacterId, ProjectPermission.DeleteContent, ct);
|
||||
await RequireCharacterAccessAsync(stage.CharacterId, NovelPermission.DeleteContent, ct);
|
||||
|
||||
db.CharacterArcStages.Remove(stage);
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -144,7 +144,7 @@ public class CharacterArcService(
|
||||
|
||||
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
|
||||
.Where(s => s.CharacterId == characterId)
|
||||
@@ -194,7 +194,7 @@ public class CharacterArcService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
var beats = await db.Beats
|
||||
.Include(b => b.Characters)
|
||||
@@ -233,7 +233,7 @@ public class CharacterArcService(
|
||||
return (await FindAsync(stageId, ct))!;
|
||||
}
|
||||
|
||||
private async Task EnsureChapterIsInSameProjectAsync(
|
||||
private async Task EnsureChapterIsInSameNovelAsync(
|
||||
Character character, Guid? chapterId, CancellationToken ct)
|
||||
{
|
||||
if (chapterId is not { } id)
|
||||
@@ -241,18 +241,18 @@ public class CharacterArcService(
|
||||
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)
|
||||
{
|
||||
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(
|
||||
"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)
|
||||
@@ -268,10 +268,10 @@ public class CharacterArcService(
|
||||
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);
|
||||
await access.RequireAsync(projectId, permission, ct);
|
||||
var novelId = await db.Characters.Where(c => c.Id == characterId).Select(c => c.NovelId).FirstOrDefaultAsync(ct);
|
||||
await access.RequireAsync(novelId, permission, ct);
|
||||
}
|
||||
|
||||
private IQueryable<CharacterArcStage> Query() =>
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace Novelly.Api.Characters;
|
||||
|
||||
public record CharacterResponse(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
Guid NovelId,
|
||||
string Name,
|
||||
CharacterRole Role,
|
||||
CharacterImportance Importance,
|
||||
@@ -299,7 +299,7 @@ public class SetArcStageBeatsRequestValidator : IModelValidator<SetArcStageBeats
|
||||
public static class CharacterMapping
|
||||
{
|
||||
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.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes,
|
||||
[.. c.Aliases],
|
||||
|
||||
@@ -7,18 +7,18 @@ public static class CharacterEndpoints
|
||||
{
|
||||
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<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/", async (Guid projectId, CharacterService service, CancellationToken ct) =>
|
||||
Results.Ok((await service.ListAsync(projectId, ct)).Select(c => c.ToResponse())))
|
||||
.WithSummary("List a project's character dossiers.");
|
||||
novelScoped.MapGet("/", async (Guid novelId, CharacterService service, CancellationToken ct) =>
|
||||
Results.Ok((await service.ListAsync(novelId, ct)).Select(c => c.ToResponse())))
|
||||
.WithSummary("List a novel's character dossiers.");
|
||||
|
||||
projectScoped.MapPost("/", async (
|
||||
Guid projectId, CreateCharacterRequest request, CharacterService service, CancellationToken ct) =>
|
||||
novelScoped.MapPost("/", async (
|
||||
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)
|
||||
{
|
||||
return Results.NotFound();
|
||||
@@ -49,7 +49,7 @@ public static class CharacterEndpoints
|
||||
characters.MapPost("/{id:guid}/relationships", async (
|
||||
Guid id, CreateRelationshipRequest request, CharacterService service, CancellationToken ct) =>
|
||||
(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 (
|
||||
Guid relationshipId, CharacterService service, CancellationToken ct) =>
|
||||
@@ -59,7 +59,7 @@ public static class CharacterEndpoints
|
||||
characters.MapPut("/{id:guid}/identity", async (
|
||||
Guid id, LinkCharacterIdentityRequest request, CharacterService service, CancellationToken ct) =>
|
||||
(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 (
|
||||
Guid id, CharacterService service, CancellationToken ct) =>
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace Novelly.Api.Characters;
|
||||
|
||||
public class CharacterService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
NovelAccessService access,
|
||||
TagService tags,
|
||||
ILogger<CharacterService> logger,
|
||||
IModelValidator<CreateCharacterRequest> createValidator,
|
||||
@@ -17,16 +17,16 @@ public class CharacterService(
|
||||
IModelValidator<CreateRelationshipRequest> relationshipValidator,
|
||||
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()
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.Where(c => c.NovelId == novelId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return OrderedInMemoryBySignificanceThenName(characters);
|
||||
@@ -52,29 +52,29 @@ public class CharacterService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.Read, ct);
|
||||
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));
|
||||
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;
|
||||
}
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct);
|
||||
await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
|
||||
|
||||
var character = new Character
|
||||
{
|
||||
ProjectId = projectId,
|
||||
NovelId = novelId,
|
||||
Name = request.Name,
|
||||
Role = request.Role,
|
||||
Importance = request.Importance,
|
||||
@@ -95,7 +95,7 @@ public class CharacterService(
|
||||
|
||||
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)
|
||||
@@ -123,7 +123,7 @@ public class CharacterService(
|
||||
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.Role = request.Role ?? character.Role;
|
||||
@@ -145,7 +145,7 @@ public class CharacterService(
|
||||
|
||||
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)
|
||||
@@ -169,7 +169,7 @@ public class CharacterService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.DeleteContent, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.DeleteContent, ct);
|
||||
|
||||
db.Characters.Remove(character);
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -191,7 +191,7 @@ public class CharacterService(
|
||||
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);
|
||||
if (related is null)
|
||||
@@ -200,10 +200,10 @@ public class CharacterService(
|
||||
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);
|
||||
throw new InvalidOperationException("Characters must belong to the same project to be related.");
|
||||
logger.LogWarning("Rejected relationship: character {CharacterId} and {RelatedCharacterId} belong to different novels", characterId, request.RelatedCharacterId);
|
||||
throw new InvalidOperationException("Characters must belong to the same novel to be related.");
|
||||
}
|
||||
|
||||
db.CharacterRelationships.Add(new CharacterRelationship
|
||||
@@ -241,7 +241,7 @@ public class CharacterService(
|
||||
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
|
||||
.Where(r => r.CharacterId == relationship.RelatedCharacterId && r.RelatedCharacterId == relationship.CharacterId)
|
||||
@@ -269,7 +269,7 @@ public class CharacterService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
if (request.SameCharacterAsId == characterId)
|
||||
{
|
||||
@@ -286,12 +286,12 @@ public class CharacterService(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (target.ProjectId != character.ProjectId)
|
||||
if (target.NovelId != character.NovelId)
|
||||
{
|
||||
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);
|
||||
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))
|
||||
@@ -304,11 +304,11 @@ public class CharacterService(
|
||||
|
||||
if (request.RevealedInChapterId is { } chapterId)
|
||||
{
|
||||
var chapterInProject = await db.Chapters.AnyAsync(c => c.Id == chapterId && c.ProjectId == character.ProjectId, ct);
|
||||
if (!chapterInProject)
|
||||
var chapterInNovel = await db.Chapters.AnyAsync(c => c.Id == chapterId && c.NovelId == character.NovelId, ct);
|
||||
if (!chapterInNovel)
|
||||
{
|
||||
logger.LogWarning("Rejected identity link: chapter {ChapterId} not in project {ProjectId}", chapterId, character.ProjectId);
|
||||
throw new InvalidOperationException("The reveal chapter must belong to the same project.");
|
||||
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 novel.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +333,7 @@ public class CharacterService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
character.SameCharacterAsId = null;
|
||||
character.RevealedInChapterId = null;
|
||||
|
||||
@@ -13,7 +13,7 @@ using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Genres;
|
||||
using Novelly.Api.Imports;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Tags;
|
||||
using Novelly.Api.Users;
|
||||
@@ -48,7 +48,7 @@ public static class NovellyServiceRegistration
|
||||
services.AddScoped<IUserClaimsPrincipalFactory<NovellyUser>, NovellyUserClaimsPrincipalFactory>();
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddScoped<INovelUserContext, NovelUserContext>();
|
||||
services.AddScoped<ProjectAccessService>();
|
||||
services.AddScoped<NovelAccessService>();
|
||||
|
||||
services.ConfigureApplicationCookie(options =>
|
||||
{
|
||||
@@ -70,9 +70,9 @@ public static class NovellyServiceRegistration
|
||||
});
|
||||
|
||||
services.AddScoped<UserAccountService>();
|
||||
services.AddScoped<ProjectMemberService>();
|
||||
services.AddScoped<NovelMemberService>();
|
||||
|
||||
services.AddScoped<ProjectService>();
|
||||
services.AddScoped<NovelService>();
|
||||
services.AddScoped<CharacterService>();
|
||||
services.AddScoped<CharacterArcService>();
|
||||
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")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
b.Property<Guid>("NovelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
@@ -176,7 +176,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
b.HasIndex("NovelId");
|
||||
|
||||
b.ToTable("Conversations");
|
||||
});
|
||||
@@ -264,12 +264,12 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("NovelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prose")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -300,7 +300,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId", "Number");
|
||||
b.HasIndex("NovelId", "Number");
|
||||
|
||||
b.ToTable("Chapters");
|
||||
});
|
||||
@@ -355,15 +355,15 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("NovelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Occupation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Personality")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Pronouns")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -389,7 +389,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
b.HasIndex("NovelId");
|
||||
|
||||
b.HasIndex("RevealedInChapterId");
|
||||
|
||||
@@ -591,7 +591,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
b.Property<Guid?>("NovelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("RequestedByUserId")
|
||||
@@ -620,7 +620,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.ToTable("ImportJobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
|
||||
modelBuilder.Entity("Novelly.Api.Novels.Novel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -667,7 +667,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.HasIndex("OwnerId");
|
||||
|
||||
b.ToTable("Projects");
|
||||
b.ToTable("Novels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b =>
|
||||
@@ -688,7 +688,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<string>("Detail")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
b.Property<Guid>("NovelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Question")
|
||||
@@ -711,7 +711,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
b.HasIndex("NovelId");
|
||||
|
||||
b.ToTable("OpenQuestions");
|
||||
});
|
||||
@@ -734,17 +734,50 @@ namespace Novelly.Api.Data.Migrations
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
b.Property<Guid>("NovelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId", "Name")
|
||||
b.HasIndex("NovelId", "Name")
|
||||
.IsUnique();
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -823,39 +856,6 @@ namespace Novelly.Api.Data.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Beats.Beat", null)
|
||||
@@ -960,13 +960,13 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
|
||||
.WithMany("Conversations")
|
||||
.HasForeignKey("ProjectId")
|
||||
.HasForeignKey("NovelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
b.Navigation("Novel");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
|
||||
@@ -993,20 +993,20 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
|
||||
.WithMany("Chapters")
|
||||
.HasForeignKey("ProjectId")
|
||||
.HasForeignKey("NovelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
b.Navigation("Novel");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
|
||||
.WithMany("Characters")
|
||||
.HasForeignKey("ProjectId")
|
||||
.HasForeignKey("NovelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
@@ -1020,7 +1020,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
.HasForeignKey("SameCharacterAsId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Project");
|
||||
b.Navigation("Novel");
|
||||
|
||||
b.Navigation("RevealedInChapter");
|
||||
|
||||
@@ -1064,7 +1064,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Navigation("RelatedCharacter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
|
||||
modelBuilder.Entity("Novelly.Api.Novels.Novel", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Users.NovellyUser", "Owner")
|
||||
.WithMany()
|
||||
@@ -1086,9 +1086,9 @@ namespace Novelly.Api.Data.Migrations
|
||||
.HasForeignKey("CharacterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.HasForeignKey("NovelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
@@ -1096,25 +1096,25 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.Navigation("Character");
|
||||
|
||||
b.Navigation("Project");
|
||||
b.Navigation("Novel");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
|
||||
.WithMany("Tags")
|
||||
.HasForeignKey("ProjectId")
|
||||
.HasForeignKey("NovelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.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")
|
||||
.HasForeignKey("ProjectId")
|
||||
.HasForeignKey("NovelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
@@ -1124,7 +1124,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
b.Navigation("Novel");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
@@ -1148,7 +1148,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Navigation("Relationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
|
||||
modelBuilder.Entity("Novelly.Api.Novels.Novel", b =>
|
||||
{
|
||||
b.Navigation("Chapters");
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Genres;
|
||||
using Novelly.Api.Imports;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Tags;
|
||||
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 DbSet<Project> Projects => Set<Project>();
|
||||
public DbSet<Novel> Novels => Set<Novel>();
|
||||
public DbSet<Character> Characters => Set<Character>();
|
||||
public DbSet<CharacterRelationship> CharacterRelationships => Set<CharacterRelationship>();
|
||||
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<ImportJob> ImportJobs => Set<ImportJob>();
|
||||
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);
|
||||
|
||||
@@ -45,7 +45,7 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options) : Identity
|
||||
|
||||
public interface INovelDbContext
|
||||
{
|
||||
DbSet<Project> Projects { get; }
|
||||
DbSet<Novel> Novels { get; }
|
||||
DbSet<Character> Characters { get; }
|
||||
DbSet<CharacterRelationship> CharacterRelationships { get; }
|
||||
DbSet<CharacterArcStage> CharacterArcStages { get; }
|
||||
@@ -58,7 +58,7 @@ public interface INovelDbContext
|
||||
DbSet<ImportJob> ImportJobs { get; }
|
||||
DbSet<Genre> Genres { get; }
|
||||
DbSet<NovellyUser> Users { get; }
|
||||
DbSet<ProjectMember> ProjectMembers { get; }
|
||||
DbSet<NovelMember> NovelMembers { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ using Novelly.Api.Agent;
|
||||
|
||||
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(
|
||||
IAgentModelClient model,
|
||||
@@ -14,13 +14,13 @@ public class ImportAgentService(
|
||||
private readonly AgentOptions _options = options.Value;
|
||||
|
||||
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(
|
||||
"Running import for {SourceRoot}, existing project {ExistingProjectId}, {ChaptersTotal} chapters total",
|
||||
sourceRoot, existingProjectId, chaptersTotal);
|
||||
"Running import for {SourceRoot}, existing novel {ExistingNovelId}, {ChaptersTotal} chapters total",
|
||||
sourceRoot, existingNovelId, chaptersTotal);
|
||||
|
||||
toolset.Initialize(sourceRoot, existingProjectId);
|
||||
toolset.Initialize(sourceRoot, existingNovelId);
|
||||
|
||||
var startingLedger = toolset.ReadLedgerOrNull();
|
||||
var systemPrompt = BuildSystemPrompt(sourceRoot);
|
||||
@@ -46,7 +46,7 @@ public class ImportAgentService(
|
||||
logger.LogInformation("Import for {SourceRoot} completed after {Turns} turns", sourceRoot, turn + 1);
|
||||
return new ImportRunResult(
|
||||
Completed: true,
|
||||
toolset.ProjectId,
|
||||
toolset.NovelId,
|
||||
ledger?.CompletedChapters?.Count ?? 0,
|
||||
null);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ public class ImportAgentService(
|
||||
|
||||
return new ImportRunResult(
|
||||
Completed: false,
|
||||
toolset.ProjectId,
|
||||
toolset.NovelId,
|
||||
finalLedger?.CompletedChapters?.Count ?? 0,
|
||||
"Reached the safety limit for this run without finishing. Starting the import "
|
||||
+ "again for the same folder will resume from the ledger.");
|
||||
@@ -107,12 +107,12 @@ public class ImportAgentService(
|
||||
|
||||
private const string SystemPromptTemplate = """
|
||||
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
|
||||
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
|
||||
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
|
||||
anything on disk except the resume ledger, and you cannot read anything outside the
|
||||
source folder.
|
||||
@@ -143,10 +143,10 @@ public class ImportAgentService(
|
||||
|
||||
```json
|
||||
{{
|
||||
"projectId": "guid",
|
||||
"novelId": "guid",
|
||||
"characters": {{ "Name": "guid", "Alias": "guid" }},
|
||||
"chapters": {{ "1": "guid" }},
|
||||
"completedPasses": ["project", "characters"],
|
||||
"completedPasses": ["novel", "characters"],
|
||||
"completedChapters": [1, 2, 3]
|
||||
}}
|
||||
```
|
||||
@@ -160,9 +160,9 @@ public class ImportAgentService(
|
||||
Skip a pass whose completion is already recorded. Jump straight to the first
|
||||
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 —
|
||||
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
|
||||
`characters/*.md` not already in the ledger's `characters` map: name from the `#`
|
||||
heading, occupation from the tagline, appearance/backstory/want from
|
||||
|
||||
@@ -4,7 +4,7 @@ using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Imports;
|
||||
|
||||
@@ -20,7 +20,7 @@ internal record ImportAgentTool(
|
||||
Func<JsonElement, CancellationToken, Task<object?>> Handler);
|
||||
|
||||
public class ImportAgentToolset(
|
||||
ProjectService projects,
|
||||
NovelService novels,
|
||||
CharacterService characters,
|
||||
CharacterArcService arcs,
|
||||
ChapterService chapters,
|
||||
@@ -36,15 +36,15 @@ public class ImportAgentToolset(
|
||||
private string _sourceRoot = string.Empty;
|
||||
private Dictionary<string, ImportAgentTool>? _byName;
|
||||
|
||||
public Guid? ProjectId { get; private set; }
|
||||
public Guid? NovelId { get; private set; }
|
||||
|
||||
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
||||
[.. 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;
|
||||
ProjectId = existingProjectId;
|
||||
NovelId = existingNovelId;
|
||||
}
|
||||
|
||||
public ImportLedger? ReadLedgerOrNull() => ImportPaths.ReadLedger(_sourceRoot);
|
||||
@@ -99,9 +99,9 @@ public class ImportAgentToolset(
|
||||
}
|
||||
}
|
||||
|
||||
private Guid RequireProjectId() =>
|
||||
ProjectId ?? throw new InvalidOperationException(
|
||||
"No project exists yet for this import — call create_project first.");
|
||||
private Guid RequireNovelId() =>
|
||||
NovelId ?? throw new InvalidOperationException(
|
||||
"No novel exists yet for this import — call create_novel first.");
|
||||
|
||||
private Dictionary<string, ImportAgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name);
|
||||
|
||||
@@ -187,8 +187,8 @@ public class ImportAgentToolset(
|
||||
});
|
||||
|
||||
yield return new ImportAgentTool(
|
||||
"create_project",
|
||||
"Create the novel project this import populates. Call once, in the first pass.",
|
||||
"create_novel",
|
||||
"Create the novel this import populates. Call once, in the first pass.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("title", "The book's title.", required: true)
|
||||
.Str("author", "Author name, if known.")
|
||||
@@ -196,18 +196,18 @@ public class ImportAgentToolset(
|
||||
.Build(),
|
||||
async (input, ct) =>
|
||||
{
|
||||
var created = await projects.CreateAsync(new CreateProjectRequest(
|
||||
var created = await novels.CreateAsync(new CreateNovelRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
JsonInput.String(input, "author"),
|
||||
Notes: JsonInput.String(input, "notes")), ct);
|
||||
|
||||
ProjectId = created.Id;
|
||||
NovelId = created.Id;
|
||||
return created.ToResponse(null);
|
||||
});
|
||||
|
||||
yield return new ImportAgentTool(
|
||||
"update_project_brief",
|
||||
"Revise the project's top-level fields. Only the fields you supply change.",
|
||||
"update_novel_brief",
|
||||
"Revise the novel's top-level fields. Only the fields you supply change.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("title", "New title.")
|
||||
.Str("author", "Author name.")
|
||||
@@ -216,15 +216,15 @@ public class ImportAgentToolset(
|
||||
.Build(),
|
||||
async (input, ct) =>
|
||||
{
|
||||
var projectId = RequireProjectId();
|
||||
var updated = await projects.UpdateAsync(projectId, new UpdateProjectRequest(
|
||||
var novelId = RequireNovelId();
|
||||
var updated = await novels.UpdateAsync(novelId, new UpdateNovelRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.String(input, "author"),
|
||||
JsonInput.String(input, "genre"),
|
||||
Notes: JsonInput.String(input, "notes")), ct);
|
||||
|
||||
return updated is null
|
||||
? new ImportToolNotFound("Project", projectId)
|
||||
? new ImportToolNotFound("Novel", novelId)
|
||||
: updated.ToResponse(null);
|
||||
});
|
||||
|
||||
@@ -234,8 +234,8 @@ public class ImportAgentToolset(
|
||||
CharacterSchema(nameRequired: true).Build(),
|
||||
async (input, ct) =>
|
||||
{
|
||||
var projectId = RequireProjectId();
|
||||
var created = await characters.CreateAsync(projectId, new CreateCharacterRequest(
|
||||
var novelId = RequireNovelId();
|
||||
var created = await characters.CreateAsync(novelId, new CreateCharacterRequest(
|
||||
JsonInput.RequiredString(input, "name"),
|
||||
Importance: JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
|
||||
Occupation: JsonInput.String(input, "occupation"),
|
||||
@@ -245,7 +245,7 @@ public class ImportAgentToolset(
|
||||
Notes: JsonInput.String(input, "notes")), ct);
|
||||
|
||||
return created is null
|
||||
? new ImportToolNotFound("Project", projectId)
|
||||
? new ImportToolNotFound("Novel", novelId)
|
||||
: created.ToResponse();
|
||||
});
|
||||
|
||||
@@ -284,8 +284,8 @@ public class ImportAgentToolset(
|
||||
.Build(),
|
||||
async (input, ct) =>
|
||||
{
|
||||
var projectId = RequireProjectId();
|
||||
var created = await chapters.CreateAsync(projectId, new CreateChapterRequest(
|
||||
var novelId = RequireNovelId();
|
||||
var created = await chapters.CreateAsync(novelId, new CreateChapterRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
JsonInput.Int(input, "number"),
|
||||
JsonInput.String(input, "summary"),
|
||||
@@ -293,7 +293,7 @@ public class ImportAgentToolset(
|
||||
Tags: JsonInput.Strings(input, "tags")), ct);
|
||||
|
||||
return created is null
|
||||
? new ImportToolNotFound("Project", projectId)
|
||||
? new ImportToolNotFound("Novel", novelId)
|
||||
: created.ToResponse();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Novelly.Api.Imports;
|
||||
public record ImportJobResponse(
|
||||
Guid Id,
|
||||
string SourceRoot,
|
||||
Guid? ProjectId,
|
||||
Guid? NovelId,
|
||||
ImportJobStatus Status,
|
||||
string? StatusMessage,
|
||||
int ChaptersCompleted,
|
||||
@@ -22,7 +22,7 @@ public enum ImportReadiness
|
||||
|
||||
public record ImportInspectionResponse(
|
||||
ImportReadiness Readiness,
|
||||
Guid? ProjectId,
|
||||
Guid? NovelId,
|
||||
int ChaptersCompleted,
|
||||
int ChaptersTotal,
|
||||
IReadOnlyList<string> CompletedPasses);
|
||||
@@ -62,7 +62,7 @@ public static class ImportMapping
|
||||
public static ImportJobResponse ToResponse(this ImportJob job) => new(
|
||||
job.Id,
|
||||
job.SourceRoot,
|
||||
job.ProjectId,
|
||||
job.NovelId,
|
||||
job.Status,
|
||||
job.StatusMessage,
|
||||
job.ChaptersCompleted,
|
||||
|
||||
@@ -18,7 +18,7 @@ public class ImportJob
|
||||
|
||||
public string SourceRoot { get; init; } = string.Empty;
|
||||
|
||||
public Guid? ProjectId { get; set; }
|
||||
public Guid? NovelId { get; set; }
|
||||
|
||||
public Guid? RequestedByUserId { get; init; }
|
||||
|
||||
|
||||
@@ -53,11 +53,11 @@ public class ImportJobRunner(
|
||||
|
||||
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.Status = result.Completed ? ImportJobStatus.Completed : ImportJobStatus.Paused;
|
||||
job.StatusMessage = result.Message;
|
||||
|
||||
@@ -4,7 +4,7 @@ using System.Text.Json.Serialization;
|
||||
namespace Novelly.Api.Imports;
|
||||
|
||||
public record ImportLedger(
|
||||
Guid? ProjectId,
|
||||
Guid? NovelId,
|
||||
Dictionary<string, Guid>? Characters,
|
||||
Dictionary<string, Guid>? Chapters,
|
||||
List<string>? CompletedPasses,
|
||||
@@ -100,7 +100,7 @@ internal static class ImportPaths
|
||||
}
|
||||
|
||||
var passes = ledger.CompletedPasses ?? [];
|
||||
var requiredPasses = new[] { "project", "characters", "chapters", "arcs" };
|
||||
var requiredPasses = new[] { "novel", "characters", "chapters", "arcs" };
|
||||
var chaptersDone = ledger.CompletedChapters?.Count ?? 0;
|
||||
|
||||
return requiredPasses.All(passes.Contains) && (chaptersTotal == 0 || chaptersDone >= chaptersTotal);
|
||||
|
||||
@@ -3,14 +3,14 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Users;
|
||||
|
||||
namespace Novelly.Api.Imports;
|
||||
|
||||
public class ImportService(
|
||||
INovelDbContext db,
|
||||
ProjectService projects,
|
||||
NovelService novels,
|
||||
Channel<Guid> queue,
|
||||
INovelUserContext userContext,
|
||||
ILogger<ImportService> logger,
|
||||
@@ -37,7 +37,7 @@ public class ImportService(
|
||||
var readiness = ImportPaths.IsComplete(ledger, total) ? ImportReadiness.Complete : ImportReadiness.Resumable;
|
||||
|
||||
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)
|
||||
@@ -53,11 +53,11 @@ public class ImportService(
|
||||
if (request.ForceRestart)
|
||||
{
|
||||
var ledger = ImportPaths.ReadLedger(root);
|
||||
if (ledger?.ProjectId is { } existingProjectId)
|
||||
if (ledger?.NovelId is { } existingNovelId)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Force-restarting import for {SourceRoot}: deleting project {ProjectId}", root, existingProjectId);
|
||||
await projects.DeleteAsync(existingProjectId, ct);
|
||||
"Force-restarting import for {SourceRoot}: deleting novel {NovelId}", root, existingNovelId);
|
||||
await novels.DeleteAsync(existingNovelId, ct);
|
||||
}
|
||||
|
||||
ImportPaths.DeleteLedger(root);
|
||||
|
||||
@@ -6,9 +6,9 @@ using Novelly.Api.Characters;
|
||||
using Novelly.Api.Tags;
|
||||
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();
|
||||
|
||||
@@ -24,7 +24,7 @@ public class Project
|
||||
|
||||
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 NovellyUser? Owner { get; set; }
|
||||
@@ -36,25 +36,25 @@ public class Project
|
||||
public List<Chapter> Chapters { get; set; } = [];
|
||||
public List<Tag> Tags { 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.Phase).HasConversion<string>().HasMaxLength(32);
|
||||
entity.HasMany(p => p.Characters).WithOne(c => c.Project!)
|
||||
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Chapters).WithOne(c => c.Project!)
|
||||
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Tags).WithOne(t => t.Project!)
|
||||
.HasForeignKey(t => t.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Conversations).WithOne(c => c.Project!)
|
||||
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Members).WithOne(m => m.Project!)
|
||||
.HasForeignKey(m => m.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Characters).WithOne(c => c.Novel!)
|
||||
.HasForeignKey(c => c.NovelId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Chapters).WithOne(c => c.Novel!)
|
||||
.HasForeignKey(c => c.NovelId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Tags).WithOne(t => t.Novel!)
|
||||
.HasForeignKey(t => t.NovelId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Conversations).WithOne(c => c.Novel!)
|
||||
.HasForeignKey(c => c.NovelId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasMany(p => p.Members).WithOne(m => m.Novel!)
|
||||
.HasForeignKey(m => m.NovelId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(p => p.Owner).WithMany()
|
||||
.HasForeignKey(p => p.OwnerId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
+19
-19
@@ -1,21 +1,21 @@
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Projects;
|
||||
namespace Novelly.Api.Novels;
|
||||
|
||||
public record ProjectSummaryResponse(
|
||||
public record NovelSummaryResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Author,
|
||||
string? Genre,
|
||||
string? Logline,
|
||||
int? TargetWordCount,
|
||||
ProjectPhase Phase,
|
||||
NovelPhase Phase,
|
||||
int CharacterCount,
|
||||
int ChapterCount,
|
||||
int WordCount,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record ProjectResponse(
|
||||
public record NovelResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Author,
|
||||
@@ -24,13 +24,13 @@ public record ProjectResponse(
|
||||
string? Synopsis,
|
||||
string? Notes,
|
||||
int? TargetWordCount,
|
||||
ProjectPhase Phase,
|
||||
NovelPhase Phase,
|
||||
Guid? OwnerId,
|
||||
string? MyRole,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record CreateProjectRequest(
|
||||
public record CreateNovelRequest(
|
||||
string Title,
|
||||
string? Author = null,
|
||||
string? Genre = null,
|
||||
@@ -39,20 +39,20 @@ public record CreateProjectRequest(
|
||||
string? Notes = 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();
|
||||
|
||||
ProjectValidation.Title(model.Title, result);
|
||||
ProjectValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
|
||||
NovelValidation.Title(model.Title, result);
|
||||
NovelValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateProjectRequest(
|
||||
public record UpdateNovelRequest(
|
||||
string? Title = null,
|
||||
string? Author = null,
|
||||
string? Genre = null,
|
||||
@@ -60,22 +60,22 @@ public record UpdateProjectRequest(
|
||||
string? Synopsis = null,
|
||||
string? Notes = 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();
|
||||
|
||||
result.AddUnclearableTextErrors("Title", "Title", model.Title, "a project", 200);
|
||||
ProjectValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
|
||||
result.AddUnclearableTextErrors("Title", "Title", model.Title, "a novel", 200);
|
||||
NovelValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, 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);
|
||||
|
||||
@@ -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.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,
|
||||
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.Genres;
|
||||
using Novelly.Api.Imports;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Tags;
|
||||
using Novelly.Api.Users;
|
||||
@@ -88,9 +88,9 @@ app.MapDefaultEndpoints();
|
||||
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous();
|
||||
|
||||
app.MapUserEndpoints();
|
||||
app.MapProjectMemberEndpoints();
|
||||
app.MapNovelMemberEndpoints();
|
||||
|
||||
app.MapProjectEndpoints()
|
||||
app.MapNovelEndpoints()
|
||||
.MapCharacterEndpoints()
|
||||
.MapChapterEndpoints()
|
||||
.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 Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Questions;
|
||||
|
||||
@@ -10,8 +10,8 @@ public class OpenQuestion
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
public Guid NovelId { get; set; }
|
||||
public Novel? Novel { get; set; }
|
||||
|
||||
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.Ignore(q => q.IsResolved);
|
||||
|
||||
entity.HasIndex(q => q.ProjectId);
|
||||
entity.HasIndex(q => q.NovelId);
|
||||
|
||||
entity.HasOne(q => q.Project).WithMany()
|
||||
.HasForeignKey(q => q.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(q => q.Novel).WithMany()
|
||||
.HasForeignKey(q => q.NovelId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(q => q.Chapter).WithMany()
|
||||
.HasForeignKey(q => q.ChapterId).OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace Novelly.Api.Questions;
|
||||
|
||||
public record OpenQuestionResponse(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
Guid NovelId,
|
||||
string Question,
|
||||
string? Detail,
|
||||
Guid? ChapterId,
|
||||
@@ -76,7 +76,7 @@ public static class OpenQuestionMapping
|
||||
{
|
||||
public static OpenQuestionResponse ToResponse(this OpenQuestion q) => new(
|
||||
q.Id,
|
||||
q.ProjectId,
|
||||
q.NovelId,
|
||||
q.Question,
|
||||
q.Detail,
|
||||
q.ChapterId,
|
||||
|
||||
@@ -7,24 +7,24 @@ public static class OpenQuestionEndpoints
|
||||
{
|
||||
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<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/", async (
|
||||
Guid projectId,
|
||||
novelScoped.MapGet("/", async (
|
||||
Guid novelId,
|
||||
OpenQuestionService service,
|
||||
CancellationToken ct,
|
||||
Guid? chapterId = null,
|
||||
Guid? characterId = null,
|
||||
bool includeResolved = false) =>
|
||||
Results.Ok((await service.ListAsync(projectId, chapterId, characterId, includeResolved, ct)).Select(q => q.ToResponse())))
|
||||
.WithSummary("List a project's open questions, optionally narrowed to one chapter or character.");
|
||||
Results.Ok((await service.ListAsync(novelId, chapterId, characterId, includeResolved, ct)).Select(q => q.ToResponse())))
|
||||
.WithSummary("List a novel's open questions, optionally narrowed to one chapter or character.");
|
||||
|
||||
projectScoped.MapPost("/", async (
|
||||
Guid projectId, CreateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
|
||||
novelScoped.MapPost("/", async (
|
||||
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)
|
||||
{
|
||||
return Results.NotFound();
|
||||
|
||||
@@ -10,28 +10,28 @@ namespace Novelly.Api.Questions;
|
||||
|
||||
public class OpenQuestionService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
NovelAccessService access,
|
||||
ILogger<OpenQuestionService> logger,
|
||||
IModelValidator<CreateOpenQuestionRequest> createValidator,
|
||||
IModelValidator<UpdateOpenQuestionRequest> updateValidator,
|
||||
IModelValidator<ResolveOpenQuestionRequest> resolveValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<OpenQuestion>> ListAsync(
|
||||
Guid projectId,
|
||||
Guid novelId,
|
||||
Guid? chapterId = null,
|
||||
Guid? characterId = null,
|
||||
bool includeResolved = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
Guard.Default(novelId, nameof(novelId));
|
||||
|
||||
logger.LogInformation(
|
||||
"Listing open questions for project {ProjectId}, chapter {ChapterId}, character {CharacterId}, includeResolved {IncludeResolved}",
|
||||
projectId, chapterId, characterId, includeResolved);
|
||||
"Listing open questions for novel {NovelId}, chapter {ChapterId}, character {CharacterId}, includeResolved {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)
|
||||
{
|
||||
@@ -70,31 +70,31 @@ public class OpenQuestionService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(question.ProjectId, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(question.NovelId, NovelPermission.Read, ct);
|
||||
return question;
|
||||
}
|
||||
|
||||
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));
|
||||
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;
|
||||
}
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct);
|
||||
await ValidateAssociationsAsync(projectId, request.ChapterId, request.CharacterId, ct);
|
||||
await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
|
||||
await ValidateAssociationsAsync(novelId, request.ChapterId, request.CharacterId, ct);
|
||||
|
||||
var question = new OpenQuestion
|
||||
{
|
||||
ProjectId = projectId,
|
||||
NovelId = novelId,
|
||||
Question = request.Question.Trim(),
|
||||
Detail = request.Detail,
|
||||
ChapterId = request.ChapterId,
|
||||
@@ -122,8 +122,8 @@ public class OpenQuestionService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(question.ProjectId, ProjectPermission.Write, ct);
|
||||
await ValidateAssociationsAsync(question.ProjectId, request.ChapterId, request.CharacterId, ct);
|
||||
await access.RequireAsync(question.NovelId, NovelPermission.Write, ct);
|
||||
await ValidateAssociationsAsync(question.NovelId, request.ChapterId, request.CharacterId, ct);
|
||||
|
||||
question.Question = Patch.Apply(question.Question, request.Question) ?? question.Question;
|
||||
question.Detail = Patch.Apply(question.Detail, request.Detail);
|
||||
@@ -150,7 +150,7 @@ public class OpenQuestionService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(question.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(question.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
question.Resolution = request.Resolution.Trim();
|
||||
question.ResolvedAt = DateTimeOffset.UtcNow;
|
||||
@@ -201,7 +201,7 @@ public class OpenQuestionService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(question.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(question.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
question.Resolution = null;
|
||||
question.ResolvedAt = null;
|
||||
@@ -223,7 +223,7 @@ public class OpenQuestionService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(question.ProjectId, ProjectPermission.DeleteContent, ct);
|
||||
await access.RequireAsync(question.NovelId, NovelPermission.DeleteContent, ct);
|
||||
|
||||
db.OpenQuestions.Remove(question);
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -234,27 +234,27 @@ public class OpenQuestionService(
|
||||
string.IsNullOrWhiteSpace(existing) ? note : $"{existing.TrimEnd()}\n\n{note}";
|
||||
|
||||
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
|
||||
&& !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(
|
||||
"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
|
||||
&& !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(
|
||||
"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() =>
|
||||
|
||||
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Tags;
|
||||
|
||||
@@ -11,8 +11,8 @@ public class Tag
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
public Guid NovelId { get; set; }
|
||||
public Novel? Novel { get; set; }
|
||||
|
||||
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.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)
|
||||
.UsingEntity(join => join.ToTable("CharacterTags"));
|
||||
|
||||
@@ -7,18 +7,18 @@ public static class TagEndpoints
|
||||
{
|
||||
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<ValidationEndpointFilter>();
|
||||
|
||||
projectScoped.MapGet("/", async (Guid projectId, TagService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(projectId, ct)))
|
||||
.WithSummary("List a project's tags with usage counts.");
|
||||
novelScoped.MapGet("/", async (Guid novelId, TagService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(novelId, ct)))
|
||||
.WithSummary("List a novel's tags with usage counts.");
|
||||
|
||||
projectScoped.MapPost("/", async (
|
||||
Guid projectId, CreateTagRequest request, TagService service, CancellationToken ct) =>
|
||||
novelScoped.MapPost("/", async (
|
||||
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)
|
||||
{
|
||||
return Results.NotFound();
|
||||
|
||||
@@ -8,21 +8,21 @@ namespace Novelly.Api.Tags;
|
||||
|
||||
public class TagService(
|
||||
INovelDbContext db,
|
||||
ProjectAccessService access,
|
||||
NovelAccessService access,
|
||||
ILogger<TagService> logger,
|
||||
IModelValidator<CreateTagRequest> createValidator,
|
||||
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
|
||||
.Where(t => t.ProjectId == projectId)
|
||||
.Where(t => t.NovelId == novelId)
|
||||
.OrderBy(t => t.Name)
|
||||
.Select(t => new TagSummaryResponse(
|
||||
t.Id, t.Name, t.Color,
|
||||
@@ -49,36 +49,36 @@ public class TagService(
|
||||
return tag;
|
||||
}
|
||||
|
||||
await access.RequireAsync(tag.ProjectId, ProjectPermission.Read, ct);
|
||||
await access.RequireAsync(tag.NovelId, NovelPermission.Read, ct);
|
||||
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));
|
||||
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;
|
||||
}
|
||||
|
||||
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct);
|
||||
await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
|
||||
|
||||
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)
|
||||
{
|
||||
logger.LogWarning("Rejected tag creation for project {ProjectId}: '{Name}' already exists", projectId, existing.Name);
|
||||
throw new InvalidOperationException($"The project already has a tag called '{existing.Name}'.");
|
||||
logger.LogWarning("Rejected tag creation for novel {NovelId}: '{Name}' already exists", novelId, 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);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return tag;
|
||||
@@ -99,17 +99,17 @@ public class TagService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(tag.ProjectId, ProjectPermission.Write, ct);
|
||||
await access.RequireAsync(tag.NovelId, NovelPermission.Write, ct);
|
||||
|
||||
if (request.Name is not null)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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;
|
||||
@@ -133,7 +133,7 @@ public class TagService(
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(tag.ProjectId, ProjectPermission.DeleteContent, ct);
|
||||
await access.RequireAsync(tag.NovelId, NovelPermission.DeleteContent, ct);
|
||||
|
||||
db.Tags.Remove(tag);
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -141,12 +141,12 @@ public class TagService(
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
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
|
||||
.Select(TagMapping.Normalise)
|
||||
@@ -156,12 +156,12 @@ public class TagService(
|
||||
|
||||
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 [];
|
||||
}
|
||||
|
||||
var existing = await db.Tags
|
||||
.Where(t => t.ProjectId == projectId)
|
||||
.Where(t => t.NovelId == novelId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var resolved = new List<Tag>();
|
||||
@@ -172,7 +172,7 @@ public class TagService(
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
match = new Tag { ProjectId = projectId, Name = name };
|
||||
match = new Tag { NovelId = novelId, Name = name };
|
||||
db.Tags.Add(match);
|
||||
existing.Add(match);
|
||||
}
|
||||
@@ -180,11 +180,11 @@ public class TagService(
|
||||
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;
|
||||
}
|
||||
|
||||
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(
|
||||
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.Metadata.Builders;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Users;
|
||||
|
||||
public class ProjectMember
|
||||
public class NovelMember
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
public Guid NovelId { get; set; }
|
||||
public Novel? Novel { get; set; }
|
||||
|
||||
public Guid UserId { 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 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.HasIndex(m => new { m.ProjectId, m.UserId }).IsUnique();
|
||||
entity.Property(m => m.NovelRole).HasConversion<string>().HasMaxLength(32);
|
||||
entity.HasIndex(m => new { m.NovelId, m.UserId }).IsUnique();
|
||||
|
||||
entity.HasOne(m => m.User).WithMany()
|
||||
.HasForeignKey(m => m.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
+5
-5
@@ -2,9 +2,9 @@ using Novelly.Api.Common.Validation;
|
||||
|
||||
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>
|
||||
{
|
||||
@@ -16,8 +16,8 @@ public class GrantAccessRequestValidator : IModelValidator<GrantAccessRequest>
|
||||
}
|
||||
}
|
||||
|
||||
public static class ProjectMemberMapping
|
||||
public static class NovelMemberMapping
|
||||
{
|
||||
public static ProjectMemberResponse ToResponse(this ProjectMember m) =>
|
||||
new(m.UserId, m.User!.Email ?? string.Empty, m.User.DisplayName, m.ProjectRole, m.GrantedAt);
|
||||
public static NovelMemberResponse ToResponse(this NovelMember m) =>
|
||||
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;
|
||||
|
||||
public enum ProjectRole
|
||||
public enum NovelRole
|
||||
{
|
||||
Writer,
|
||||
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)
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -8,12 +8,12 @@ namespace Novelly.Mcp.Tools;
|
||||
public static class CharacterTools
|
||||
{
|
||||
[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(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
CancellationToken ct) =>
|
||||
api.GetAsync($"/api/projects/{projectId}/characters", ct);
|
||||
api.GetAsync($"/api/novels/{novelId}/characters", ct);
|
||||
|
||||
[McpServerTool(Name = "get_character")]
|
||||
[Description("Read one character's dossier.")]
|
||||
@@ -24,11 +24,11 @@ public static class CharacterTools
|
||||
api.GetAsync($"/api/characters/{characterId}", ct);
|
||||
|
||||
[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.")]
|
||||
public static Task<CallToolResult> CreateCharacter(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
[Description("The character's name.")] string name,
|
||||
CancellationToken ct,
|
||||
[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("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = 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,
|
||||
role = role ?? "Supporting",
|
||||
@@ -197,7 +197,7 @@ public static class CharacterTools
|
||||
api.PostAsync($"/api/arc-stages/{arcStageId}/beats", new { beatIds }, ct);
|
||||
|
||||
[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 "
|
||||
+ "on both dossiers.")]
|
||||
public static Task<CallToolResult> RelateCharacters(
|
||||
@@ -215,7 +215,7 @@ public static class CharacterTools
|
||||
|
||||
[McpServerTool(Name = "link_character_identity")]
|
||||
[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 "
|
||||
+ "is whichever character you link to.")]
|
||||
public static Task<CallToolResult> LinkCharacterIdentity(
|
||||
|
||||
@@ -8,12 +8,12 @@ namespace Novelly.Mcp.Tools;
|
||||
public static class ManuscriptTools
|
||||
{
|
||||
[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(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
CancellationToken ct) =>
|
||||
api.GetAsync($"/api/projects/{projectId}/chapters", ct);
|
||||
api.GetAsync($"/api/novels/{novelId}/chapters", ct);
|
||||
|
||||
[McpServerTool(Name = "get_chapter")]
|
||||
[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);
|
||||
|
||||
[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(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
[Description("Chapter title.")] string title,
|
||||
CancellationToken ct,
|
||||
[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("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) =>
|
||||
api.PostAsync($"/api/projects/{projectId}/chapters", new
|
||||
api.PostAsync($"/api/novels/{novelId}/chapters", new
|
||||
{
|
||||
title,
|
||||
number,
|
||||
|
||||
@@ -5,25 +5,25 @@ using ModelContextProtocol.Server;
|
||||
namespace Novelly.Mcp.Tools;
|
||||
|
||||
[McpServerToolType]
|
||||
public static class ProjectTools
|
||||
public static class NovelTools
|
||||
{
|
||||
[McpServerTool(Name = "list_projects")]
|
||||
[Description("List every novel project, with counts of characters, chapters and drafted words. "
|
||||
+ "Start here to find the project id everything else needs.")]
|
||||
public static Task<CallToolResult> ListProjects(NovelApiClient api, CancellationToken ct) =>
|
||||
api.GetAsync("/api/projects", ct);
|
||||
[McpServerTool(Name = "list_novels")]
|
||||
[Description("List every novel, with counts of characters, chapters and drafted words. "
|
||||
+ "Start here to find the novel id everything else needs.")]
|
||||
public static Task<CallToolResult> ListNovels(NovelApiClient api, CancellationToken ct) =>
|
||||
api.GetAsync("/api/novels", ct);
|
||||
|
||||
[McpServerTool(Name = "get_project_brief")]
|
||||
[Description("Read a project's title, author, genre, logline, synopsis, notes and word-count target.")]
|
||||
public static Task<CallToolResult> GetProject(
|
||||
[McpServerTool(Name = "get_novel_brief")]
|
||||
[Description("Read a novel's title, author, genre, logline, synopsis, notes and word-count target.")]
|
||||
public static Task<CallToolResult> GetNovel(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
CancellationToken ct) =>
|
||||
api.GetAsync($"/api/projects/{projectId}", ct);
|
||||
api.GetAsync($"/api/novels/{novelId}", ct);
|
||||
|
||||
[McpServerTool(Name = "create_project")]
|
||||
[Description("Create a new novel project.")]
|
||||
public static Task<CallToolResult> CreateProject(
|
||||
[McpServerTool(Name = "create_novel")]
|
||||
[Description("Create a new novel.")]
|
||||
public static Task<CallToolResult> CreateNovel(
|
||||
NovelApiClient api,
|
||||
[Description("Working title.")] string title,
|
||||
CancellationToken ct,
|
||||
@@ -33,14 +33,14 @@ public static class ProjectTools
|
||||
[Description("Paragraph-length summary of the whole book.")] string? synopsis = null,
|
||||
[Description("Free-form notes on theme, tone, comparable titles.")] string? notes = 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")]
|
||||
[Description("Revise a project's top-level fields. Only the fields you supply change; "
|
||||
[McpServerTool(Name = "update_novel_brief")]
|
||||
[Description("Revise a novel's top-level fields. Only the fields you supply change; "
|
||||
+ "pass an empty string to clear one.")]
|
||||
public static Task<CallToolResult> UpdateProject(
|
||||
public static Task<CallToolResult> UpdateNovel(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
CancellationToken ct,
|
||||
[Description("New title.")] string? title = 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("Free-form notes on theme, tone, comparable titles.")] string? notes = 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);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ public static class QuestionTools
|
||||
+ "thinking, not a gap to fill in for them.")]
|
||||
public static Task<CallToolResult> ListOpenQuestions(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
CancellationToken ct,
|
||||
[Description("Narrow to questions about one chapter outline.")] Guid? chapterId = null,
|
||||
[Description("Narrow to questions about one character.")] Guid? characterId = null,
|
||||
@@ -31,7 +31,7 @@ public static class QuestionTools
|
||||
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")]
|
||||
@@ -39,13 +39,13 @@ public static class QuestionTools
|
||||
+ "and/or the character it is about. Prefer raising a question over guessing.")]
|
||||
public static Task<CallToolResult> RaiseOpenQuestion(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
[Description("The question, in one line.")] string question,
|
||||
CancellationToken ct,
|
||||
[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 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);
|
||||
|
||||
[McpServerTool(Name = "update_open_question")]
|
||||
|
||||
@@ -8,13 +8,13 @@ namespace Novelly.Mcp.Tools;
|
||||
public static class TagTools
|
||||
{
|
||||
[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.")]
|
||||
public static Task<CallToolResult> ListTags(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
CancellationToken ct) =>
|
||||
api.GetAsync($"/api/projects/{projectId}/tags", ct);
|
||||
api.GetAsync($"/api/novels/{novelId}/tags", ct);
|
||||
|
||||
[McpServerTool(Name = "get_tag_references")]
|
||||
[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.")]
|
||||
public static Task<CallToolResult> CreateTag(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The tag's name. Unique within the project, matched case-insensitively.")] string name,
|
||||
[Description("The novel's id.")] Guid novelId,
|
||||
[Description("The tag's name. Unique within the novel, matched case-insensitively.")] string name,
|
||||
CancellationToken ct,
|
||||
[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")]
|
||||
[Description("Rename or recolour a tag. Renaming updates it everywhere it is applied.")]
|
||||
|
||||
@@ -11,8 +11,8 @@ using OpenTelemetry.Trace;
|
||||
namespace Microsoft.Extensions.Hosting;
|
||||
|
||||
// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
|
||||
// This project should be referenced by each service project in your solution.
|
||||
// To learn more about using this project, see https://aka.ms/aspire/service-defaults
|
||||
// This novel should be referenced by each service novel in your solution.
|
||||
// To learn more about using this novel, see https://aka.ms/aspire/service-defaults
|
||||
public static class Extensions
|
||||
{
|
||||
private const string HealthEndpointPath = "/health";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Navigate, Outlet, Route, Routes } from 'react-router-dom'
|
||||
import ProjectsPage from './pages/ProjectsPage'
|
||||
import ProjectLayout from './pages/ProjectLayout'
|
||||
import NovelsPage from './pages/NovelsPage'
|
||||
import NovelLayout from './pages/NovelLayout'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import CharactersPage from './pages/CharactersPage'
|
||||
import CharacterDetailPage from './pages/CharacterDetailPage'
|
||||
@@ -34,8 +34,8 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="/" element={<ProjectsPage />} />
|
||||
<Route path="/projects/:projectId" element={<ProjectLayout />}>
|
||||
<Route path="/" element={<NovelsPage />} />
|
||||
<Route path="/novels/:novelId" element={<NovelLayout />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="characters" element={<CharactersPage />} />
|
||||
<Route path="characters/:characterId" element={<CharacterDetailPage />} />
|
||||
@@ -45,7 +45,7 @@ export default function App() {
|
||||
<Route path="agent" element={<AgentPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<ProjectsPage />} />
|
||||
<Route path="*" element={<NovelsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HelpOverlayProvider>
|
||||
|
||||
+112
-112
@@ -15,10 +15,10 @@ import type {
|
||||
ImportJob,
|
||||
ImportJobStatus,
|
||||
OpenQuestion,
|
||||
Project,
|
||||
ProjectMember,
|
||||
ProjectRole,
|
||||
ProjectSummary,
|
||||
Novel,
|
||||
NovelMember,
|
||||
NovelRole,
|
||||
NovelSummary,
|
||||
TagReferences,
|
||||
TagSummary,
|
||||
User,
|
||||
@@ -26,18 +26,18 @@ import type {
|
||||
|
||||
export const keys = {
|
||||
me: ['me'] as const,
|
||||
members: (projectId: string) => ['projects', projectId, 'members'] as const,
|
||||
projects: ['projects'] as const,
|
||||
members: (novelId: string) => ['novels', novelId, 'members'] as const,
|
||||
novels: ['novels'] as const,
|
||||
genres: ['genres'] as const,
|
||||
project: (id: string) => ['projects', id] as const,
|
||||
characters: (projectId: string) => ['projects', projectId, 'characters'] as const,
|
||||
tags: (projectId: string) => ['projects', projectId, 'tags'] as const,
|
||||
novel: (id: string) => ['novels', id] as const,
|
||||
characters: (novelId: string) => ['novels', novelId, 'characters'] as const,
|
||||
tags: (novelId: string) => ['novels', novelId, 'tags'] as const,
|
||||
tagRefs: (tagId: string) => ['tags', tagId, 'references'] as const,
|
||||
characterBeats: (characterId: string) => ['characters', characterId, 'beats'] as const,
|
||||
chapters: (projectId: string) => ['projects', projectId, 'chapters'] as const,
|
||||
questions: (projectId: string) => ['projects', projectId, 'questions'] as const,
|
||||
chapters: (novelId: string) => ['novels', novelId, 'chapters'] as const,
|
||||
questions: (novelId: string) => ['novels', novelId, 'questions'] 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,
|
||||
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({
|
||||
queryKey: keys.members(projectId),
|
||||
queryFn: () => api.get<ProjectMember[]>(`/api/projects/${projectId}/members`),
|
||||
queryKey: keys.members(novelId),
|
||||
queryFn: () => api.get<NovelMember[]>(`/api/novels/${novelId}/members`),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
export function useGrantAccess(projectId: string) {
|
||||
export function useGrantAccess(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: { email: string; projectRole: ProjectRole }) =>
|
||||
api.post<ProjectMember>(`/api/projects/${projectId}/members`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(projectId) }),
|
||||
mutationFn: (body: { email: string; novelRole: NovelRole }) =>
|
||||
api.post<NovelMember>(`/api/novels/${novelId}/members`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(novelId) }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useRevokeAccess(projectId: string) {
|
||||
export function useRevokeAccess(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (userId: string) => api.delete(`/api/projects/${projectId}/members/${userId}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(projectId) }),
|
||||
mutationFn: (userId: string) => api.delete(`/api/novels/${novelId}/members/${userId}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(novelId) }),
|
||||
})
|
||||
}
|
||||
|
||||
export const useProjects = () =>
|
||||
useQuery({ queryKey: keys.projects, queryFn: () => api.get<ProjectSummary[]>('/api/projects') })
|
||||
export const useNovels = () =>
|
||||
useQuery({ queryKey: keys.novels, queryFn: () => api.get<NovelSummary[]>('/api/novels') })
|
||||
|
||||
export const useProject = (id: string) =>
|
||||
useQuery({ queryKey: keys.project(id), queryFn: () => api.get<Project>(`/api/projects/${id}`) })
|
||||
export const useNovel = (id: string) =>
|
||||
useQuery({ queryKey: keys.novel(id), queryFn: () => api.get<Novel>(`/api/novels/${id}`) })
|
||||
|
||||
export function useCreateProject() {
|
||||
export function useCreateNovel() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: { title: string; author?: string; genre?: string; logline?: string }) =>
|
||||
api.post<Project>('/api/projects', body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.projects }),
|
||||
api.post<Novel>('/api/novels', body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.novels }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateProject(id: string) {
|
||||
export function useUpdateNovel(id: string) {
|
||||
const qc = useQueryClient()
|
||||
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) => {
|
||||
qc.setQueryData(keys.project(id), updated)
|
||||
qc.invalidateQueries({ queryKey: keys.projects })
|
||||
qc.setQueryData(keys.novel(id), updated)
|
||||
qc.invalidateQueries({ queryKey: keys.novels })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteProject() {
|
||||
export function useDeleteNovel() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/api/projects/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.projects }),
|
||||
mutationFn: (id: string) => api.delete(`/api/novels/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.novels }),
|
||||
})
|
||||
}
|
||||
|
||||
export const useCharacters = (projectId: string) =>
|
||||
export const useCharacters = (novelId: string) =>
|
||||
useQuery({
|
||||
queryKey: keys.characters(projectId),
|
||||
queryFn: () => api.get<Character[]>(`/api/projects/${projectId}/characters`),
|
||||
queryKey: keys.characters(novelId),
|
||||
queryFn: () => api.get<Character[]>(`/api/novels/${novelId}/characters`),
|
||||
})
|
||||
|
||||
export function useCreateCharacter(projectId: string) {
|
||||
export function useCreateCharacter(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: Partial<Character> & { name: string }) =>
|
||||
api.post<Character>(`/api/projects/${projectId}/characters`, body),
|
||||
api.post<Character>(`/api/novels/${novelId}/characters`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
|
||||
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateCharacter(projectId: string) {
|
||||
export function useUpdateCharacter(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: Partial<Omit<Character, 'tags'>> & { id: string; tags?: string[] }) =>
|
||||
api.patch<Character>(`/api/characters/${id}`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
|
||||
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteCharacter(projectId: string) {
|
||||
export function useDeleteCharacter(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
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()
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -192,19 +192,19 @@ export function useLinkCharacterIdentity(projectId: string) {
|
||||
revealedInChapterId?: string | null
|
||||
note?: string | null
|
||||
}) => 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()
|
||||
return useMutation({
|
||||
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()
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -226,15 +226,15 @@ export function useAddRelationship(projectId: string) {
|
||||
reciprocalRelationshipType,
|
||||
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()
|
||||
return useMutation({
|
||||
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),
|
||||
})
|
||||
|
||||
export function useCreateArcStage(projectId: string) {
|
||||
export function useCreateArcStage(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ characterId, ...body }: { characterId: string; title: string; result?: string; chapterId?: string }) =>
|
||||
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()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: { id: string; title?: string; result?: string; chapterId?: string }) =>
|
||||
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()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, beatIds }: { id: string; beatIds: string[] }) =>
|
||||
api.post<ArcStage>(`/api/arc-stages/${id}/beats`, { beatIds }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
|
||||
qc.invalidateQueries({ queryKey: keys.characterBeats(characterId ?? '') })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteArcStage(projectId: string) {
|
||||
export function useDeleteArcStage(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
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()
|
||||
return useMutation({
|
||||
mutationFn: ({ characterId, stageIds }: { characterId: string; stageIds: string[] }) =>
|
||||
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 = (
|
||||
projectId: string,
|
||||
novelId: string,
|
||||
filter: { chapterId?: string; characterId?: string; includeResolved?: boolean } = {},
|
||||
) => {
|
||||
const params = new URLSearchParams()
|
||||
@@ -303,66 +303,66 @@ export const useOpenQuestions = (
|
||||
const query = params.toString()
|
||||
|
||||
return useQuery({
|
||||
queryKey: [...keys.questions(projectId), query] as const,
|
||||
queryKey: [...keys.questions(novelId), query] as const,
|
||||
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()
|
||||
return useMutation({
|
||||
mutationFn: (body: { question: string; detail?: string; chapterId?: string; characterId?: string }) =>
|
||||
api.post<OpenQuestion>(`/api/projects/${projectId}/questions`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }),
|
||||
api.post<OpenQuestion>(`/api/novels/${novelId}/questions`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(novelId) }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateQuestion(projectId: string) {
|
||||
export function useUpdateQuestion(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: { id: string; question?: string; detail?: string }) =>
|
||||
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()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, resolution, appendToNotes }: { id: string; resolution: string; appendToNotes: boolean }) =>
|
||||
api.post<OpenQuestion>(`/api/questions/${id}/resolve`, { resolution, appendToNotes }),
|
||||
onSuccess: (question) => {
|
||||
qc.invalidateQueries({ queryKey: keys.questions(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.questions(novelId) })
|
||||
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
|
||||
if (question.chapterId) qc.invalidateQueries({ queryKey: keys.chapter(question.chapterId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useReopenQuestion(projectId: string) {
|
||||
export function useReopenQuestion(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
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()
|
||||
return useMutation({
|
||||
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 = () =>
|
||||
useQuery({ queryKey: keys.genres, queryFn: () => api.get<Genre[]>('/api/genres') })
|
||||
|
||||
export const useTags = (projectId: string) =>
|
||||
export const useTags = (novelId: string) =>
|
||||
useQuery({
|
||||
queryKey: keys.tags(projectId),
|
||||
queryFn: () => api.get<TagSummary[]>(`/api/projects/${projectId}/tags`),
|
||||
queryKey: keys.tags(novelId),
|
||||
queryFn: () => api.get<TagSummary[]>(`/api/novels/${novelId}/tags`),
|
||||
})
|
||||
|
||||
export const useTagReferences = (tagId: string | undefined) =>
|
||||
@@ -372,13 +372,13 @@ export const useTagReferences = (tagId: string | undefined) =>
|
||||
enabled: Boolean(tagId),
|
||||
})
|
||||
|
||||
export function useUpdateTag(projectId: string) {
|
||||
export function useUpdateTag(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: { id: string; name?: string; color?: string }) =>
|
||||
api.patch<TagSummary>(`/api/tags/${id}`, body),
|
||||
onSuccess: (_, { id }) => {
|
||||
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
|
||||
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()
|
||||
return useMutation({
|
||||
mutationFn: (
|
||||
@@ -400,12 +400,12 @@ export function useCreateBeat(chapterId: string, projectId: string) {
|
||||
) => api.post<Beat>(`/api/chapters/${chapterId}/beats`, body),
|
||||
onSuccess: () => {
|
||||
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()
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -415,7 +415,7 @@ export function useUpdateBeat(chapterId: string, projectId: string) {
|
||||
api.patch<Beat>(`/api/beats/${id}`, body),
|
||||
onSuccess: () => {
|
||||
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({
|
||||
queryKey: keys.chapters(projectId),
|
||||
queryFn: () => api.get<ChapterSummary[]>(`/api/projects/${projectId}/chapters`),
|
||||
queryKey: keys.chapters(novelId),
|
||||
queryFn: () => api.get<ChapterSummary[]>(`/api/novels/${novelId}/chapters`),
|
||||
})
|
||||
|
||||
export const useChapter = (id: string | undefined) =>
|
||||
@@ -471,40 +471,40 @@ export const useChapter = (id: string | undefined) =>
|
||||
enabled: Boolean(id),
|
||||
})
|
||||
|
||||
export function useCreateChapter(projectId: string) {
|
||||
export function useCreateChapter(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: Partial<Chapter> & { title: string }) =>
|
||||
api.post<Chapter>(`/api/projects/${projectId}/chapters`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(projectId) }),
|
||||
api.post<Chapter>(`/api/novels/${novelId}/chapters`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(novelId) }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateChapter(projectId: string) {
|
||||
export function useUpdateChapter(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: Partial<Omit<Chapter, 'tags'>> & { id: string; tags?: string[] }) =>
|
||||
api.patch<Chapter>(`/api/chapters/${id}`, body),
|
||||
onSuccess: (updated) => {
|
||||
qc.setQueryData(keys.chapter(updated.id), updated)
|
||||
qc.invalidateQueries({ queryKey: keys.chapters(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
|
||||
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteChapter(projectId: string) {
|
||||
export function useDeleteChapter(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
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({
|
||||
queryKey: keys.conversations(projectId),
|
||||
queryFn: () => api.get<ConversationSummary[]>(`/api/projects/${projectId}/agent/conversations`),
|
||||
queryKey: keys.conversations(novelId),
|
||||
queryFn: () => api.get<ConversationSummary[]>(`/api/novels/${novelId}/agent/conversations`),
|
||||
})
|
||||
|
||||
export const useConversation = (id: string | undefined) =>
|
||||
@@ -514,19 +514,19 @@ export const useConversation = (id: string | undefined) =>
|
||||
enabled: Boolean(id),
|
||||
})
|
||||
|
||||
export function useSendAgentMessage(projectId: string) {
|
||||
export function useSendAgentMessage(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
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) => {
|
||||
qc.invalidateQueries({ queryKey: keys.conversations(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.conversations(novelId) })
|
||||
qc.invalidateQueries({ queryKey: keys.conversation(turn.conversationId) })
|
||||
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.chapters(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.questions(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.project(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
|
||||
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
|
||||
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
|
||||
qc.invalidateQueries({ queryKey: keys.questions(novelId) })
|
||||
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 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 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 {
|
||||
id: string
|
||||
@@ -49,11 +49,11 @@ export interface User {
|
||||
globalRole: GlobalRole
|
||||
}
|
||||
|
||||
export interface ProjectMember {
|
||||
export interface NovelMember {
|
||||
userId: string
|
||||
email: string
|
||||
displayName: string
|
||||
projectRole: ProjectRole
|
||||
novelRole: NovelRole
|
||||
grantedAt: string
|
||||
}
|
||||
|
||||
@@ -62,21 +62,21 @@ export interface Genre {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ProjectSummary {
|
||||
export interface NovelSummary {
|
||||
id: string
|
||||
title: string
|
||||
author: string | null
|
||||
genre: string | null
|
||||
logline: string | null
|
||||
targetWordCount: number | null
|
||||
phase: ProjectPhase
|
||||
phase: NovelPhase
|
||||
characterCount: number
|
||||
chapterCount: number
|
||||
wordCount: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
export interface Novel {
|
||||
id: string
|
||||
title: string
|
||||
author: string | null
|
||||
@@ -85,9 +85,9 @@ export interface Project {
|
||||
synopsis: string | null
|
||||
notes: string | null
|
||||
targetWordCount: number | null
|
||||
phase: ProjectPhase
|
||||
phase: NovelPhase
|
||||
ownerId: string | null
|
||||
myRole: ProjectMyRole | null
|
||||
myRole: NovelMyRole | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export interface CharacterBeat {
|
||||
|
||||
export interface Character {
|
||||
id: string
|
||||
projectId: string
|
||||
novelId: string
|
||||
name: string
|
||||
role: CharacterRole
|
||||
importance: CharacterImportance
|
||||
@@ -210,7 +210,7 @@ export interface CharacterIdentity {
|
||||
|
||||
export interface ChapterSummary {
|
||||
id: string
|
||||
projectId: string
|
||||
novelId: string
|
||||
number: number
|
||||
title: string
|
||||
summary: string | null
|
||||
@@ -233,7 +233,7 @@ export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'wordCount'>
|
||||
|
||||
export interface OpenQuestion {
|
||||
id: string
|
||||
projectId: string
|
||||
novelId: string
|
||||
question: string
|
||||
detail: string | null
|
||||
chapterId: string | null
|
||||
@@ -264,7 +264,7 @@ export interface AgentMessage {
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string
|
||||
projectId: string
|
||||
novelId: string
|
||||
title: string
|
||||
messageCount: number
|
||||
updatedAt: string
|
||||
@@ -284,7 +284,7 @@ export type ImportJobStatus = 'Pending' | 'Running' | 'Completed' | 'Failed' | '
|
||||
export interface ImportJob {
|
||||
id: string
|
||||
sourceRoot: string
|
||||
projectId: string | null
|
||||
novelId: string | null
|
||||
status: ImportJobStatus
|
||||
statusMessage: string | null
|
||||
chaptersCompleted: number
|
||||
@@ -297,7 +297,7 @@ export type ImportReadiness = 'Fresh' | 'Resumable' | 'Complete'
|
||||
|
||||
export interface ImportInspection {
|
||||
readiness: ImportReadiness
|
||||
projectId: string | null
|
||||
novelId: string | null
|
||||
chaptersCompleted: number
|
||||
chaptersTotal: number
|
||||
completedPasses: string[]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from 'react'
|
||||
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'
|
||||
|
||||
const projectPermissionsByRole: Record<ProjectMyRole, AuthPermission[]> = {
|
||||
const novelPermissionsByRole: Record<NovelMyRole, AuthPermission[]> = {
|
||||
Admin: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'],
|
||||
Owner: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'],
|
||||
Writer: ['Write', 'CreateContent', 'DeleteContent'],
|
||||
@@ -15,7 +15,7 @@ const projectPermissionsByRole: Record<ProjectMyRole, AuthPermission[]> = {
|
||||
interface AuthValue {
|
||||
user: User | null
|
||||
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 })
|
||||
@@ -28,10 +28,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
() => ({
|
||||
user,
|
||||
isPending,
|
||||
can: (permission, project) => {
|
||||
can: (permission, novel) => {
|
||||
if (permission === 'CreateNovel') return user?.globalRole === 'Admin' || user?.globalRole === 'Writer'
|
||||
const myRole = project?.myRole
|
||||
return myRole ? projectPermissionsByRole[myRole].includes(permission) : false
|
||||
const myRole = novel?.myRole
|
||||
return myRole ? novelPermissionsByRole[myRole].includes(permission) : false
|
||||
},
|
||||
}),
|
||||
[user, isPending],
|
||||
|
||||
@@ -13,22 +13,22 @@ import type { ArcStage, Character } from '../api/types'
|
||||
import { AutoField, ErrorNote } from './ui'
|
||||
|
||||
export function CharacterArc({
|
||||
projectId,
|
||||
novelId,
|
||||
character,
|
||||
canWrite,
|
||||
canCreate,
|
||||
canDelete,
|
||||
}: {
|
||||
projectId: string
|
||||
novelId: string
|
||||
character: Character
|
||||
canWrite: boolean
|
||||
canCreate: boolean
|
||||
canDelete: boolean
|
||||
}) {
|
||||
const { data: chapters } = useChapters(projectId)
|
||||
const { data: chapters } = useChapters(novelId)
|
||||
const { data: beats } = useCharacterBeats(character.id)
|
||||
const create = useCreateArcStage(projectId)
|
||||
const reorder = useReorderArcStages(projectId)
|
||||
const create = useCreateArcStage(novelId)
|
||||
const reorder = useReorderArcStages(novelId)
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
|
||||
@@ -70,7 +70,7 @@ export function CharacterArc({
|
||||
{stages.map((stage, index) => (
|
||||
<ArcStageRow
|
||||
key={stage.id}
|
||||
projectId={projectId}
|
||||
novelId={novelId}
|
||||
stage={stage}
|
||||
chapters={chapters ?? []}
|
||||
unassignedBeats={unassignedBeats}
|
||||
@@ -115,7 +115,7 @@ export function CharacterArc({
|
||||
}
|
||||
|
||||
function ArcStageRow({
|
||||
projectId,
|
||||
novelId,
|
||||
stage,
|
||||
chapters,
|
||||
unassignedBeats,
|
||||
@@ -125,7 +125,7 @@ function ArcStageRow({
|
||||
canWrite,
|
||||
canDelete,
|
||||
}: {
|
||||
projectId: string
|
||||
novelId: string
|
||||
stage: ArcStage
|
||||
chapters: { id: string; number: number; title: string }[]
|
||||
unassignedBeats: { id: string; chapterNumber: number; sortOrder: number; title: string }[]
|
||||
@@ -135,9 +135,9 @@ function ArcStageRow({
|
||||
canWrite: boolean
|
||||
canDelete: boolean
|
||||
}) {
|
||||
const update = useUpdateArcStage(projectId)
|
||||
const remove = useDeleteArcStage(projectId)
|
||||
const setBeats = useSetArcStageBeats(projectId, stage.characterId)
|
||||
const update = useUpdateArcStage(novelId)
|
||||
const remove = useDeleteArcStage(novelId)
|
||||
const setBeats = useSetArcStageBeats(novelId, stage.characterId)
|
||||
|
||||
const addBeat = (beatId: string) => {
|
||||
if (!beatId) return
|
||||
@@ -182,7 +182,7 @@ function ArcStageRow({
|
||||
<Link
|
||||
className="shrink-0 tabular-nums underline"
|
||||
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}
|
||||
</Link>
|
||||
@@ -235,7 +235,7 @@ function ArcStageRow({
|
||||
<Link
|
||||
className="text-xs underline"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
to={`/projects/${projectId}/chapters/${stage.chapterId}`}
|
||||
to={`/novels/${novelId}/chapters/${stage.chapterId}`}
|
||||
>
|
||||
Open outline
|
||||
</Link>
|
||||
|
||||
@@ -4,12 +4,12 @@ import type { ArcStage } from '../api/types'
|
||||
import { ErrorNote, Spinner } from './ui'
|
||||
|
||||
export function CharacterBeats({
|
||||
projectId,
|
||||
novelId,
|
||||
characterId,
|
||||
characterName,
|
||||
arcStages,
|
||||
}: {
|
||||
projectId: string
|
||||
novelId: string
|
||||
characterId: string
|
||||
characterName: string
|
||||
arcStages: ArcStage[]
|
||||
@@ -49,7 +49,7 @@ export function CharacterBeats({
|
||||
<Link
|
||||
className="underline"
|
||||
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}
|
||||
</Link>
|
||||
|
||||
@@ -8,9 +8,9 @@ type MenuState = {
|
||||
onCreated: (characterId: string) => void
|
||||
}
|
||||
|
||||
export function useCharacterContextMenu(projectId: string) {
|
||||
export function useCharacterContextMenu(novelId: string) {
|
||||
const [menu, setMenu] = useState<MenuState | null>(null)
|
||||
const createCharacter = useCreateCharacter(projectId)
|
||||
const createCharacter = useCreateCharacter(novelId)
|
||||
|
||||
const handleContextMenu = (
|
||||
e: MouseEvent<HTMLTextAreaElement>,
|
||||
|
||||
@@ -5,11 +5,11 @@ import type { BeatCharacter } from '../api/types'
|
||||
|
||||
export function CharacterChip({
|
||||
character,
|
||||
projectId,
|
||||
novelId,
|
||||
onRemove,
|
||||
}: {
|
||||
character: BeatCharacter
|
||||
projectId?: string
|
||||
novelId?: string
|
||||
onRemove?: () => void
|
||||
}) {
|
||||
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"
|
||||
style={{ color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 14%, transparent)' }}
|
||||
>
|
||||
{projectId ? (
|
||||
{novelId ? (
|
||||
<Link
|
||||
to={`/projects/${projectId}/characters/${character.id}`}
|
||||
to={`/novels/${novelId}/characters/${character.id}`}
|
||||
className="hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -43,19 +43,19 @@ export function CharacterChip({
|
||||
}
|
||||
|
||||
export function CharacterMultiSelect({
|
||||
projectId,
|
||||
novelId,
|
||||
selected,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
projectId: string
|
||||
novelId: string
|
||||
selected: BeatCharacter[]
|
||||
options: { id: string; name: string }[]
|
||||
onChange: (ids: string[]) => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState('')
|
||||
const listId = 'character-multiselect-options'
|
||||
const createCharacter = useCreateCharacter(projectId)
|
||||
const createCharacter = useCreateCharacter(novelId)
|
||||
|
||||
const add = () => {
|
||||
const name = draft.trim()
|
||||
@@ -83,7 +83,7 @@ export function CharacterMultiSelect({
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{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
|
||||
className="input w-28 flex-1 px-2 py-0.5 text-xs"
|
||||
|
||||
@@ -9,7 +9,7 @@ export function ImportDialog({
|
||||
onImported,
|
||||
}: {
|
||||
onClose: () => void
|
||||
onImported?: (projectId: string) => void
|
||||
onImported?: (novelId: string) => void
|
||||
}) {
|
||||
const [sourceRoot, setSourceRoot] = useState('')
|
||||
const [inspection, setInspection] = useState<ImportInspection | null>(null)
|
||||
@@ -24,8 +24,8 @@ export function ImportDialog({
|
||||
useEffect(() => {
|
||||
if (job.data?.status !== 'Completed') return
|
||||
qc.invalidateQueries()
|
||||
if (job.data.projectId) onImported?.(job.data.projectId)
|
||||
}, [job.data?.status, job.data?.projectId, qc, onImported])
|
||||
if (job.data.novelId) onImported?.(job.data.novelId)
|
||||
}, [job.data?.status, job.data?.novelId, qc, onImported])
|
||||
|
||||
const check = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -173,7 +173,7 @@ function ImportReadinessSummary({
|
||||
) : (
|
||||
<div className="mt-2">
|
||||
<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.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -10,13 +10,13 @@ import type { OpenQuestion } from '../api/types'
|
||||
import { ErrorNote, Spinner } from './ui'
|
||||
|
||||
export function OpenQuestions({
|
||||
projectId,
|
||||
novelId,
|
||||
scope,
|
||||
canCreate,
|
||||
canWrite,
|
||||
canDelete,
|
||||
}: {
|
||||
projectId: string
|
||||
novelId: string
|
||||
scope: { chapterId?: string; characterId?: string }
|
||||
canCreate: boolean
|
||||
canWrite: boolean
|
||||
@@ -25,11 +25,11 @@ export function OpenQuestions({
|
||||
const [showResolved, setShowResolved] = useState(false)
|
||||
const [asking, setAsking] = useState(false)
|
||||
|
||||
const { data: questions, isPending, error } = useOpenQuestions(projectId, {
|
||||
const { data: questions, isPending, error } = useOpenQuestions(novelId, {
|
||||
...scope,
|
||||
includeResolved: showResolved,
|
||||
})
|
||||
const raise = useRaiseQuestion(projectId)
|
||||
const raise = useRaiseQuestion(novelId)
|
||||
|
||||
const [question, setQuestion] = useState('')
|
||||
const [detail, setDetail] = useState('')
|
||||
@@ -109,7 +109,7 @@ export function OpenQuestions({
|
||||
{questions.map((q) => (
|
||||
<QuestionRow
|
||||
key={q.id}
|
||||
projectId={projectId}
|
||||
novelId={novelId}
|
||||
question={q}
|
||||
scope={scope}
|
||||
canWrite={canWrite}
|
||||
@@ -128,21 +128,21 @@ export function OpenQuestions({
|
||||
}
|
||||
|
||||
function QuestionRow({
|
||||
projectId,
|
||||
novelId,
|
||||
question,
|
||||
scope,
|
||||
canWrite,
|
||||
canDelete,
|
||||
}: {
|
||||
projectId: string
|
||||
novelId: string
|
||||
question: OpenQuestion
|
||||
scope: { chapterId?: string; characterId?: string }
|
||||
canWrite: boolean
|
||||
canDelete: boolean
|
||||
}) {
|
||||
const resolve = useResolveQuestion(projectId)
|
||||
const reopen = useReopenQuestion(projectId)
|
||||
const remove = useDeleteQuestion(projectId)
|
||||
const resolve = useResolveQuestion(novelId)
|
||||
const reopen = useReopenQuestion(novelId)
|
||||
const remove = useDeleteQuestion(novelId)
|
||||
|
||||
const [resolving, setResolving] = useState(false)
|
||||
const [resolution, setResolution] = useState('')
|
||||
|
||||
@@ -12,11 +12,11 @@ const starters = [
|
||||
]
|
||||
|
||||
export default function AgentPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const { data: conversations } = useConversations(projectId)
|
||||
const { novelId = '' } = useParams()
|
||||
const { data: conversations } = useConversations(novelId)
|
||||
const [conversationId, setConversationId] = useState<string | undefined>()
|
||||
const { data: conversation } = useConversation(conversationId)
|
||||
const send = useSendAgentMessage(projectId)
|
||||
const send = useSendAgentMessage(novelId)
|
||||
const [draft, setDraft] = useState('')
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
useDeleteBeat,
|
||||
useDeleteChapter,
|
||||
useMoveBeats,
|
||||
useProject,
|
||||
useNovel,
|
||||
useReorderBeats,
|
||||
useTags,
|
||||
useUpdateBeat,
|
||||
@@ -30,24 +30,24 @@ import { useHotkey } from '../keyboard/HotkeysContext'
|
||||
type ChapterTab = 'outline' | 'prose'
|
||||
|
||||
export default function ChapterPage() {
|
||||
const { projectId = '', chapterId = '' } = useParams()
|
||||
const { novelId = '', chapterId = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { data: chapter, isPending, error } = useChapter(chapterId)
|
||||
const { data: project } = useProject(projectId)
|
||||
const { data: characters } = useCharacters(projectId)
|
||||
const { data: allTags } = useTags(projectId)
|
||||
const { data: chapters } = useChapters(projectId)
|
||||
const createChapter = useCreateChapter(projectId)
|
||||
const update = useUpdateChapter(projectId)
|
||||
const remove = useDeleteChapter(projectId)
|
||||
const createBeat = useCreateBeat(chapterId, projectId)
|
||||
const { data: novel } = useNovel(novelId)
|
||||
const { data: characters } = useCharacters(novelId)
|
||||
const { data: allTags } = useTags(novelId)
|
||||
const { data: chapters } = useChapters(novelId)
|
||||
const createChapter = useCreateChapter(novelId)
|
||||
const update = useUpdateChapter(novelId)
|
||||
const remove = useDeleteChapter(novelId)
|
||||
const createBeat = useCreateBeat(chapterId, novelId)
|
||||
const [tab, setTab] = useState<ChapterTab>('outline')
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const { handleContextMenu, menuElement } = useCharacterContextMenu(projectId)
|
||||
const { handleContextMenu, menuElement } = useCharacterContextMenu(novelId)
|
||||
const { can } = useAuth()
|
||||
const canWrite = can('Write', project)
|
||||
const canCreate = can('CreateContent', project)
|
||||
const canDelete = can('DeleteContent', project)
|
||||
const canWrite = can('Write', novel)
|
||||
const canCreate = can('CreateContent', novel)
|
||||
const canDelete = can('DeleteContent', novel)
|
||||
|
||||
useHotkey('b', 'Add beat', () => canCreate && createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
|
||||
|
||||
@@ -59,13 +59,13 @@ export default function ChapterPage() {
|
||||
useHotkey(
|
||||
'[',
|
||||
'Previous chapter',
|
||||
() => prevChapter && navigate(`/projects/${projectId}/chapters/${prevChapter.id}`),
|
||||
() => prevChapter && navigate(`/novels/${novelId}/chapters/${prevChapter.id}`),
|
||||
{ group: 'Chapter', enabled: Boolean(prevChapter) },
|
||||
)
|
||||
useHotkey(
|
||||
']',
|
||||
'Next chapter',
|
||||
() => nextChapter && navigate(`/projects/${projectId}/chapters/${nextChapter.id}`),
|
||||
() => nextChapter && navigate(`/novels/${novelId}/chapters/${nextChapter.id}`),
|
||||
{ group: 'Chapter', enabled: Boolean(nextChapter) },
|
||||
)
|
||||
|
||||
@@ -84,13 +84,13 @@ export default function ChapterPage() {
|
||||
return (
|
||||
<div>
|
||||
<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
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
{prevChapter ? (
|
||||
<Link
|
||||
to={`/projects/${projectId}/chapters/${prevChapter.id}`}
|
||||
to={`/novels/${novelId}/chapters/${prevChapter.id}`}
|
||||
className="muted hover:underline"
|
||||
title={`Chapter ${prevChapter.number}: ${prevChapter.title}`}
|
||||
>
|
||||
@@ -103,7 +103,7 @@ export default function ChapterPage() {
|
||||
)}
|
||||
{nextChapter ? (
|
||||
<Link
|
||||
to={`/projects/${projectId}/chapters/${nextChapter.id}`}
|
||||
to={`/novels/${novelId}/chapters/${nextChapter.id}`}
|
||||
className="muted hover:underline"
|
||||
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.`}
|
||||
onConfirm={() =>
|
||||
remove.mutate(chapter.id, {
|
||||
onSuccess: () => navigate(`/projects/${projectId}/chapters`),
|
||||
onSuccess: () => navigate(`/novels/${novelId}/chapters`),
|
||||
})
|
||||
}
|
||||
onClose={() => setConfirmingDelete(false)}
|
||||
@@ -236,7 +236,7 @@ export default function ChapterPage() {
|
||||
|
||||
<BeatTable
|
||||
chapter={chapter}
|
||||
projectId={projectId}
|
||||
novelId={novelId}
|
||||
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
|
||||
otherChapters={chapters?.filter((c) => c.id !== chapter.id) ?? []}
|
||||
createChapter={createChapter}
|
||||
@@ -278,7 +278,7 @@ export default function ChapterPage() {
|
||||
</div>
|
||||
|
||||
<OpenQuestions
|
||||
projectId={projectId}
|
||||
novelId={novelId}
|
||||
scope={{ chapterId: chapter.id }}
|
||||
canCreate={canCreate}
|
||||
canWrite={canWrite}
|
||||
@@ -307,7 +307,7 @@ const MOVE_TO_NEW_CHAPTER = '__new__'
|
||||
|
||||
function BeatTable({
|
||||
chapter,
|
||||
projectId,
|
||||
novelId,
|
||||
characters,
|
||||
otherChapters,
|
||||
createChapter,
|
||||
@@ -317,7 +317,7 @@ function BeatTable({
|
||||
canDelete,
|
||||
}: {
|
||||
chapter: Chapter
|
||||
projectId: string
|
||||
novelId: string
|
||||
characters: { id: string; name: string }[]
|
||||
otherChapters: ChapterSummary[]
|
||||
createChapter: ReturnType<typeof useCreateChapter>
|
||||
@@ -329,7 +329,7 @@ function BeatTable({
|
||||
canWrite: boolean
|
||||
canDelete: boolean
|
||||
}) {
|
||||
const update = useUpdateBeat(chapter.id, projectId)
|
||||
const update = useUpdateBeat(chapter.id, novelId)
|
||||
const remove = useDeleteBeat(chapter.id)
|
||||
const reorder = useReorderBeats(chapter.id)
|
||||
const assignCharacter = useAssignCharacterToBeats(chapter.id)
|
||||
@@ -579,7 +579,7 @@ function BeatTable({
|
||||
|
||||
<td className="px-2 py-2 align-top">
|
||||
<CharacterMultiSelect
|
||||
projectId={projectId}
|
||||
novelId={novelId}
|
||||
selected={beat.characters}
|
||||
options={characters}
|
||||
onChange={(characterIds) => patch(beat.id, { characterIds })}
|
||||
@@ -726,7 +726,7 @@ function BeatTable({
|
||||
{beat.characters.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{beat.characters.map((character) => (
|
||||
<CharacterChip key={character.id} character={character} projectId={projectId} />
|
||||
<CharacterChip key={character.id} character={character} novelId={novelId} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
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 { TagChip } from '../components/TagEditor'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||
|
||||
export default function ChaptersPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const { data: chapters, isPending, error } = useChapters(projectId)
|
||||
const { data: project } = useProject(projectId)
|
||||
const { novelId = '' } = useParams()
|
||||
const { data: chapters, isPending, error } = useChapters(novelId)
|
||||
const { data: novel } = useNovel(novelId)
|
||||
const { can } = useAuth()
|
||||
const canCreate = can('CreateContent', project)
|
||||
const create = useCreateChapter(projectId)
|
||||
const canCreate = can('CreateContent', novel)
|
||||
const create = useCreateChapter(novelId)
|
||||
|
||||
useHotkey('n', 'Add chapter', () => canCreate && create.mutate({ title: 'Untitled chapter' }), { group: 'Chapters' })
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function ChaptersPage() {
|
||||
{chapters?.map((chapter) => (
|
||||
<li key={chapter.id}>
|
||||
<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"
|
||||
>
|
||||
<span className="w-8 shrink-0 text-right text-sm font-semibold muted">
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useCharacters,
|
||||
useDeleteCharacter,
|
||||
useLinkCharacterIdentity,
|
||||
useProject,
|
||||
useNovel,
|
||||
useRemoveRelationship,
|
||||
useTags,
|
||||
useUnlinkCharacterIdentity,
|
||||
@@ -23,13 +23,13 @@ import { CharacterBeats } from '../components/CharacterBeats'
|
||||
import { OpenQuestions } from '../components/OpenQuestions'
|
||||
|
||||
export default function CharacterDetailPage() {
|
||||
const { projectId = '', characterId = '' } = useParams()
|
||||
const { data: characters, isPending, error } = useCharacters(projectId)
|
||||
const { data: project } = useProject(projectId)
|
||||
const { novelId = '', characterId = '' } = useParams()
|
||||
const { data: characters, isPending, error } = useCharacters(novelId)
|
||||
const { data: novel } = useNovel(novelId)
|
||||
const { can } = useAuth()
|
||||
const canWrite = can('Write', project)
|
||||
const canCreate = can('CreateContent', project)
|
||||
const canDelete = can('DeleteContent', project)
|
||||
const canWrite = can('Write', novel)
|
||||
const canCreate = can('CreateContent', novel)
|
||||
const canDelete = can('DeleteContent', novel)
|
||||
|
||||
if (isPending) return <Spinner label="Loading character" />
|
||||
if (error) return <ErrorNote error={error} />
|
||||
@@ -39,7 +39,7 @@ export default function CharacterDetailPage() {
|
||||
if (!character) {
|
||||
return (
|
||||
<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
|
||||
</Link>
|
||||
<EmptyState title="Character not found" hint="It may have been deleted." />
|
||||
@@ -49,13 +49,13 @@ export default function CharacterDetailPage() {
|
||||
|
||||
return (
|
||||
<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
|
||||
</Link>
|
||||
|
||||
<CharacterSheet
|
||||
key={character.id}
|
||||
projectId={projectId}
|
||||
novelId={novelId}
|
||||
character={character}
|
||||
canWrite={canWrite}
|
||||
canCreate={canCreate}
|
||||
@@ -66,28 +66,28 @@ export default function CharacterDetailPage() {
|
||||
}
|
||||
|
||||
function CharacterSheet({
|
||||
projectId,
|
||||
novelId,
|
||||
character,
|
||||
canWrite,
|
||||
canCreate,
|
||||
canDelete,
|
||||
}: {
|
||||
projectId: string
|
||||
novelId: string
|
||||
character: Character
|
||||
canWrite: boolean
|
||||
canCreate: boolean
|
||||
canDelete: boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const { data: allTags } = useTags(projectId)
|
||||
const { data: allCharacters } = useCharacters(projectId)
|
||||
const { data: chapters } = useChapters(projectId)
|
||||
const update = useUpdateCharacter(projectId)
|
||||
const remove = useDeleteCharacter(projectId)
|
||||
const linkIdentity = useLinkCharacterIdentity(projectId)
|
||||
const unlinkIdentity = useUnlinkCharacterIdentity(projectId)
|
||||
const addRelationship = useAddRelationship(projectId)
|
||||
const removeRelationship = useRemoveRelationship(projectId)
|
||||
const { data: allTags } = useTags(novelId)
|
||||
const { data: allCharacters } = useCharacters(novelId)
|
||||
const { data: chapters } = useChapters(novelId)
|
||||
const update = useUpdateCharacter(novelId)
|
||||
const remove = useDeleteCharacter(novelId)
|
||||
const linkIdentity = useLinkCharacterIdentity(novelId)
|
||||
const unlinkIdentity = useUnlinkCharacterIdentity(novelId)
|
||||
const addRelationship = useAddRelationship(novelId)
|
||||
const removeRelationship = useRemoveRelationship(novelId)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const patch = (body: Partial<Omit<Character, 'tags' | 'aliases'>> & { tags?: string[]; aliases?: string[] }) =>
|
||||
update.mutate({ id: character.id, ...body })
|
||||
@@ -281,7 +281,7 @@ function CharacterSheet({
|
||||
|
||||
{(character.importance === 'Main' || character.arcStages.length > 0) && (
|
||||
<CharacterArc
|
||||
projectId={projectId}
|
||||
novelId={novelId}
|
||||
character={character}
|
||||
canWrite={canWrite}
|
||||
canCreate={canCreate}
|
||||
@@ -290,14 +290,14 @@ function CharacterSheet({
|
||||
)}
|
||||
|
||||
<CharacterBeats
|
||||
projectId={projectId}
|
||||
novelId={novelId}
|
||||
characterId={character.id}
|
||||
characterName={character.name}
|
||||
arcStages={character.arcStages}
|
||||
/>
|
||||
|
||||
<OpenQuestions
|
||||
projectId={projectId}
|
||||
novelId={novelId}
|
||||
scope={{ characterId: character.id }}
|
||||
canCreate={canCreate}
|
||||
canWrite={canWrite}
|
||||
@@ -309,7 +309,7 @@ function CharacterSheet({
|
||||
title="Delete character"
|
||||
message={`Delete ${character.name}? This cannot be undone.`}
|
||||
onConfirm={() =>
|
||||
remove.mutate(character.id, { onSuccess: () => navigate(`/projects/${projectId}/characters`) })
|
||||
remove.mutate(character.id, { onSuccess: () => navigate(`/novels/${novelId}/characters`) })
|
||||
}
|
||||
onClose={() => setConfirmingDelete(false)}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
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 { useAuth } from '../auth/AuthContext'
|
||||
import { EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
|
||||
@@ -10,13 +10,13 @@ import { useHotkey } from '../keyboard/HotkeysContext'
|
||||
type SortKey = 'name' | 'updatedAt'
|
||||
|
||||
export default function CharactersPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const { novelId = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { data: characters, isPending, error } = useCharacters(projectId)
|
||||
const { data: project } = useProject(projectId)
|
||||
const { data: allTags } = useTags(projectId)
|
||||
const { data: characters, isPending, error } = useCharacters(novelId)
|
||||
const { data: novel } = useNovel(novelId)
|
||||
const { data: allTags } = useTags(novelId)
|
||||
const { can } = useAuth()
|
||||
const canCreate = can('CreateContent', project)
|
||||
const canCreate = can('CreateContent', novel)
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -76,9 +76,9 @@ export default function CharactersPage() {
|
||||
/>
|
||||
{adding && (
|
||||
<AddCharacterModal
|
||||
projectId={projectId}
|
||||
novelId={novelId}
|
||||
onClose={() => setAdding(false)}
|
||||
onCreated={(id) => navigate(`/projects/${projectId}/characters/${id}`)}
|
||||
onCreated={(id) => navigate(`/novels/${novelId}/characters/${id}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -115,13 +115,13 @@ export default function CharactersPage() {
|
||||
onSortDir={setSortDir}
|
||||
/>
|
||||
|
||||
<CharacterTable characters={sorted} projectId={projectId} />
|
||||
<CharacterTable characters={sorted} novelId={novelId} />
|
||||
|
||||
{adding && (
|
||||
<AddCharacterModal
|
||||
projectId={projectId}
|
||||
novelId={novelId}
|
||||
onClose={() => setAdding(false)}
|
||||
onCreated={(id) => navigate(`/projects/${projectId}/characters/${id}`)}
|
||||
onCreated={(id) => navigate(`/novels/${novelId}/characters/${id}`)}
|
||||
/>
|
||||
)}
|
||||
</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) {
|
||||
return (
|
||||
<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)' }}>
|
||||
<td className="p-0">
|
||||
<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)]"
|
||||
>
|
||||
<div className="font-medium">{character.name}</div>
|
||||
@@ -325,15 +325,15 @@ function CharacterTable({ characters, projectId }: { characters: Character[]; pr
|
||||
}
|
||||
|
||||
function AddCharacterModal({
|
||||
projectId,
|
||||
novelId,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
projectId: string
|
||||
novelId: string
|
||||
onClose: () => void
|
||||
onCreated: (id: string) => void
|
||||
}) {
|
||||
const create = useCreateCharacter(projectId)
|
||||
const create = useCreateCharacter(novelId)
|
||||
const [name, setName] = useState('')
|
||||
const [role, setRole] = useState<Character['role']>('Supporting')
|
||||
const [importance, setImportance] = useState<Character['importance']>('Supporting')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useChapters, useCharacters, useProject, useTags, useUpdateProject } from '../api/hooks'
|
||||
import type { Project, TagSummary } from '../api/types'
|
||||
import { useChapters, useCharacters, useNovel, useTags, useUpdateNovel } from '../api/hooks'
|
||||
import type { Novel, TagSummary } from '../api/types'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
|
||||
|
||||
@@ -8,21 +8,21 @@ const RECENT_COUNT = 5
|
||||
const RECENT_CHAPTERS_COUNT = 10
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const { data: project, isPending, error } = useProject(projectId)
|
||||
const { novelId = '' } = useParams()
|
||||
const { data: novel, isPending, error } = useNovel(novelId)
|
||||
|
||||
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' ? (
|
||||
<BrainstormingDashboard project={project} />
|
||||
return novel.phase === 'Brainstorming' ? (
|
||||
<BrainstormingDashboard novel={novel} />
|
||||
) : (
|
||||
<OutliningDashboard projectId={projectId} />
|
||||
<OutliningDashboard novelId={novelId} />
|
||||
)
|
||||
}
|
||||
|
||||
function BrainstormingDashboard({ project }: { project: Project }) {
|
||||
const update = useUpdateProject(project.id)
|
||||
function BrainstormingDashboard({ novel }: { novel: Novel }) {
|
||||
const update = useUpdateNovel(novel.id)
|
||||
const { can } = useAuth()
|
||||
|
||||
return (
|
||||
@@ -33,22 +33,22 @@ function BrainstormingDashboard({ project }: { project: Project }) {
|
||||
there's a shape to work from.
|
||||
</p>
|
||||
<AutoField
|
||||
value={project.notes}
|
||||
value={novel.notes}
|
||||
multiline
|
||||
rows={20}
|
||||
serif
|
||||
placeholder="Start anywhere."
|
||||
onCommit={(notes) => update.mutate({ notes })}
|
||||
readOnly={!can('Write', project)}
|
||||
readOnly={!can('Write', novel)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OutliningDashboard({ projectId }: { projectId: string }) {
|
||||
const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(projectId)
|
||||
const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(projectId)
|
||||
const { data: tags, isPending: tagsPending, error: tagsError } = useTags(projectId)
|
||||
function OutliningDashboard({ novelId }: { novelId: string }) {
|
||||
const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(novelId)
|
||||
const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(novelId)
|
||||
const { data: tags, isPending: tagsPending, error: tagsError } = useTags(novelId)
|
||||
|
||||
const recentCharacters = [...(characters ?? [])].sort(
|
||||
(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 ? (
|
||||
<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>
|
||||
</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 sizeFor = (count: number) => {
|
||||
@@ -174,7 +174,7 @@ function TagCloud({ projectId, tags }: { projectId: string; tags: TagSummary[] }
|
||||
.map((tag) => (
|
||||
<Link
|
||||
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"
|
||||
style={{
|
||||
fontSize: `${sizeFor(tag.totalCount)}rem`,
|
||||
|
||||
+15
-15
@@ -1,6 +1,6 @@
|
||||
import { Outlet, useParams, Link, NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useLogout, useProject, useUpdateProject } from '../api/hooks'
|
||||
import { projectPhases } from '../api/types'
|
||||
import { useLogout, useNovel, useUpdateNovel } from '../api/hooks'
|
||||
import { novelPhases } from '../api/types'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { ErrorNote, Spinner } from '../components/ui'
|
||||
import { HelpButton } from '../keyboard/HelpButton'
|
||||
@@ -15,16 +15,16 @@ const sections: { to: string; label: string; end?: boolean }[] = [
|
||||
{ to: 'settings', label: 'Settings' },
|
||||
]
|
||||
|
||||
export default function ProjectLayout() {
|
||||
const { projectId = '' } = useParams()
|
||||
export default function NovelLayout() {
|
||||
const { novelId = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { data: project, isPending, error } = useProject(projectId)
|
||||
const update = useUpdateProject(projectId)
|
||||
const { data: novel, isPending, error } = useNovel(novelId)
|
||||
const update = useUpdateNovel(novelId)
|
||||
const { user, can } = useAuth()
|
||||
const canWrite = can('Write', project)
|
||||
const canWrite = can('Write', novel)
|
||||
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 o', 'Go to outline', () => goTo('chapters'), { group: 'Navigate' })
|
||||
@@ -40,19 +40,19 @@ export default function ProjectLayout() {
|
||||
<Link to="/" className="text-sm muted hover:underline">
|
||||
← Novels
|
||||
</Link>
|
||||
<Link to={`/projects/${projectId}`} className="truncate text-base font-semibold hover:underline">
|
||||
{project?.title ?? '…'}
|
||||
<Link to={`/novels/${novelId}`} className="truncate text-base font-semibold hover:underline">
|
||||
{novel?.title ?? '…'}
|
||||
</Link>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{project && (
|
||||
{novel && (
|
||||
<select
|
||||
className="input w-auto"
|
||||
value={project.phase}
|
||||
value={novel.phase}
|
||||
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"
|
||||
>
|
||||
{projectPhases.map((phase) => (
|
||||
{novelPhases.map((phase) => (
|
||||
<option key={phase} value={phase}>
|
||||
{phase}
|
||||
</option>
|
||||
@@ -96,7 +96,7 @@ export default function ProjectLayout() {
|
||||
|
||||
<main className="mx-auto max-w-[100rem] px-6 py-8">
|
||||
{error && <ErrorNote error={error} />}
|
||||
{isPending ? <Spinner label="Loading project" /> : <Outlet context={{ projectId }} />}
|
||||
{isPending ? <Spinner label="Loading novel" /> : <Outlet context={{ novelId }} />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
+21
-21
@@ -1,14 +1,14 @@
|
||||
import { useId, useState } from 'react'
|
||||
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 { ImportDialog } from '../components/ImportDialog'
|
||||
import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui'
|
||||
import { HelpButton } from '../keyboard/HelpButton'
|
||||
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const { data: projects, isPending, error } = useProjects()
|
||||
export default function NovelsPage() {
|
||||
const { data: novels, isPending, error } = useNovels()
|
||||
const { user, can } = useAuth()
|
||||
const logout = useLogout()
|
||||
const [creating, setCreating] = useState(false)
|
||||
@@ -62,9 +62,9 @@ export default function ProjectsPage() {
|
||||
</p>
|
||||
|
||||
{error && <ErrorNote error={error} />}
|
||||
{isPending && <Spinner label="Loading projects" />}
|
||||
{isPending && <Spinner label="Loading novels" />}
|
||||
|
||||
{projects?.length === 0 && (
|
||||
{novels?.length === 0 && (
|
||||
<EmptyState
|
||||
title="Nothing here yet"
|
||||
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">
|
||||
{projects?.map((project) => (
|
||||
{novels?.map((novel) => (
|
||||
<Link
|
||||
key={project.id}
|
||||
to={`/projects/${project.id}`}
|
||||
key={novel.id}
|
||||
to={`/novels/${novel.id}`}
|
||||
className="card block px-5 py-4 transition hover:shadow-md"
|
||||
>
|
||||
<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">
|
||||
{project.genre ?? 'Uncategorised'}
|
||||
{project.author && ` · ${project.author}`}
|
||||
{novel.genre ?? 'Uncategorised'}
|
||||
{novel.author && ` · ${novel.author}`}
|
||||
</span>
|
||||
</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">
|
||||
<span>{project.characterCount} characters</span>
|
||||
<span>{project.chapterCount} chapters</span>
|
||||
<span>{novel.characterCount} characters</span>
|
||||
<span>{novel.chapterCount} chapters</span>
|
||||
<span>
|
||||
{project.wordCount.toLocaleString()}
|
||||
{project.targetWordCount
|
||||
? ` / ${project.targetWordCount.toLocaleString()} words`
|
||||
{novel.wordCount.toLocaleString()}
|
||||
{novel.targetWordCount
|
||||
? ` / ${novel.targetWordCount.toLocaleString()} words`
|
||||
: ' words'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -100,11 +100,11 @@ export default function ProjectsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{creating && <CreateProjectModal onClose={() => setCreating(false)} />}
|
||||
{creating && <CreateNovelModal onClose={() => setCreating(false)} />}
|
||||
{importing && (
|
||||
<ImportDialog
|
||||
onClose={() => setImporting(false)}
|
||||
onImported={(projectId) => navigate(`/projects/${projectId}`)}
|
||||
onImported={(novelId) => navigate(`/novels/${novelId}`)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
@@ -112,8 +112,8 @@ export default function ProjectsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
const create = useCreateProject()
|
||||
function CreateNovelModal({ onClose }: { onClose: () => void }) {
|
||||
const create = useCreateNovel()
|
||||
const { data: genres } = useGenres()
|
||||
const genreListId = useId()
|
||||
const [title, setTitle] = useState('')
|
||||
@@ -3,42 +3,42 @@ import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
useChapters,
|
||||
useCharacters,
|
||||
useDeleteProject,
|
||||
useDeleteNovel,
|
||||
useGenres,
|
||||
useGrantAccess,
|
||||
useProject,
|
||||
useProjectMembers,
|
||||
useNovel,
|
||||
useNovelMembers,
|
||||
useRevokeAccess,
|
||||
useUpdateProject,
|
||||
useUpdateNovel,
|
||||
} from '../api/hooks'
|
||||
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 { ImportDialog } from '../components/ImportDialog'
|
||||
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const { novelId = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { data: project, isPending } = useProject(projectId)
|
||||
const { data: characters } = useCharacters(projectId)
|
||||
const { data: chapters } = useChapters(projectId)
|
||||
const { data: novel, isPending } = useNovel(novelId)
|
||||
const { data: characters } = useCharacters(novelId)
|
||||
const { data: chapters } = useChapters(novelId)
|
||||
const { data: genres } = useGenres()
|
||||
const update = useUpdateProject(projectId)
|
||||
const remove = useDeleteProject()
|
||||
const update = useUpdateNovel(novelId)
|
||||
const remove = useDeleteNovel()
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
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 canDelete = can('DeleteContent', project)
|
||||
const canManageAccess = can('ManageAccess', project)
|
||||
const canWrite = can('Write', novel)
|
||||
const canDelete = can('DeleteContent', novel)
|
||||
const canManageAccess = can('ManageAccess', novel)
|
||||
|
||||
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
|
||||
|
||||
return (
|
||||
@@ -48,20 +48,20 @@ export default function SettingsPage() {
|
||||
<div className="grid gap-4">
|
||||
<AutoField
|
||||
label="Title"
|
||||
value={project.title}
|
||||
value={novel.title}
|
||||
onCommit={(title) => title.trim() && update.mutate({ title })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<AutoField
|
||||
label="Author"
|
||||
value={project.author}
|
||||
value={novel.author}
|
||||
onCommit={(author) => update.mutate({ author })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<AutoField
|
||||
label="Genre"
|
||||
value={project.genre}
|
||||
value={novel.genre}
|
||||
placeholder="Pick one, or name your own."
|
||||
suggestions={genres?.map((g) => g.name)}
|
||||
onCommit={(genre) => update.mutate({ genre })}
|
||||
@@ -70,7 +70,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<AutoField
|
||||
label="Logline"
|
||||
value={project.logline}
|
||||
value={novel.logline}
|
||||
multiline
|
||||
rows={2}
|
||||
placeholder="Who wants what, and what stands in the way."
|
||||
@@ -79,7 +79,7 @@ export default function SettingsPage() {
|
||||
/>
|
||||
<AutoField
|
||||
label="Synopsis"
|
||||
value={project.synopsis}
|
||||
value={novel.synopsis}
|
||||
multiline
|
||||
rows={8}
|
||||
serif
|
||||
@@ -89,7 +89,7 @@ export default function SettingsPage() {
|
||||
/>
|
||||
<AutoField
|
||||
label="Notes"
|
||||
value={project.notes}
|
||||
value={novel.notes}
|
||||
multiline
|
||||
rows={4}
|
||||
placeholder="Theme, tone, comparable titles, research threads."
|
||||
@@ -103,11 +103,11 @@ export default function SettingsPage() {
|
||||
type="number"
|
||||
min={0}
|
||||
step={1000}
|
||||
defaultValue={project.targetWordCount ?? ''}
|
||||
defaultValue={novel.targetWordCount ?? ''}
|
||||
readOnly={!canWrite}
|
||||
onBlur={(e) => {
|
||||
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 })
|
||||
}
|
||||
}}
|
||||
@@ -176,20 +176,20 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{canManageAccess && <ProjectPeople projectId={projectId} />}
|
||||
{canManageAccess && <NovelPeople novelId={novelId} />}
|
||||
|
||||
{importing && (
|
||||
<ImportDialog
|
||||
onClose={() => setImporting(false)}
|
||||
onImported={(newProjectId) => navigate(`/projects/${newProjectId}`)}
|
||||
onImported={(newNovelId) => navigate(`/novels/${newNovelId}`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmingDelete && (
|
||||
<ConfirmModal
|
||||
title="Delete novel"
|
||||
message={`Delete "${project.title}" and everything in it? This cannot be undone.`}
|
||||
onConfirm={() => remove.mutate(projectId, { onSuccess: () => navigate('/') })}
|
||||
message={`Delete "${novel.title}" and everything in it? This cannot be undone.`}
|
||||
onConfirm={() => remove.mutate(novelId, { onSuccess: () => navigate('/') })}
|
||||
onClose={() => setConfirmingDelete(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -197,13 +197,13 @@ export default function SettingsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectPeople({ projectId }: { projectId: string }) {
|
||||
const { data: members, isPending, error } = useProjectMembers(projectId)
|
||||
const grant = useGrantAccess(projectId)
|
||||
const revoke = useRevokeAccess(projectId)
|
||||
function NovelPeople({ novelId }: { novelId: string }) {
|
||||
const { data: members, isPending, error } = useNovelMembers(novelId)
|
||||
const grant = useGrantAccess(novelId)
|
||||
const revoke = useRevokeAccess(novelId)
|
||||
const [email, setEmail] = useState('')
|
||||
const [projectRole, setProjectRole] = useState<ProjectRole>('Reviewer')
|
||||
const [revoking, setRevoking] = useState<ProjectMember | null>(null)
|
||||
const [novelRole, setNovelRole] = useState<NovelRole>('Reviewer')
|
||||
const [revoking, setRevoking] = useState<NovelMember | null>(null)
|
||||
|
||||
if (isPending) 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) => {
|
||||
e.preventDefault()
|
||||
if (!email.trim()) return
|
||||
grant.mutate({ email: email.trim(), projectRole }, { onSuccess: () => setEmail('') })
|
||||
grant.mutate({ email: email.trim(), novelRole }, { onSuccess: () => setEmail('') })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -240,9 +240,9 @@ function ProjectPeople({ projectId }: { projectId: string }) {
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Select
|
||||
value={member.projectRole}
|
||||
options={projectRoles}
|
||||
onChange={(next) => grant.mutate({ email: member.email, projectRole: next })}
|
||||
value={member.novelRole}
|
||||
options={novelRoles}
|
||||
onChange={(next) => grant.mutate({ email: member.email, novelRole: next })}
|
||||
/>
|
||||
<button className="btn btn-danger" onClick={() => setRevoking(member)}>
|
||||
Remove
|
||||
@@ -264,7 +264,7 @@ function ProjectPeople({ projectId }: { projectId: string }) {
|
||||
placeholder="someone@example.com"
|
||||
/>
|
||||
</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}>
|
||||
{grant.isPending ? 'Granting…' : 'Grant'}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
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 { EmptyState, ErrorNote, Spinner } from '../components/ui'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
@@ -8,12 +8,12 @@ import { TagChip } from '../components/TagEditor'
|
||||
import { TagColorPicker } from '../components/TagColorPicker'
|
||||
|
||||
export default function TagsPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const { data: tags, isPending, error } = useTags(projectId)
|
||||
const { data: project } = useProject(projectId)
|
||||
const { novelId = '' } = useParams()
|
||||
const { data: tags, isPending, error } = useTags(novelId)
|
||||
const { data: novel } = useNovel(novelId)
|
||||
const { can } = useAuth()
|
||||
const canWrite = can('Write', project)
|
||||
const canDelete = can('DeleteContent', project)
|
||||
const canWrite = can('Write', novel)
|
||||
const canDelete = can('DeleteContent', novel)
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const selectedId = searchParams.get('tag') ?? undefined
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function TagsPage() {
|
||||
) : (
|
||||
<TagReferencePanel
|
||||
key={selected.id}
|
||||
projectId={projectId}
|
||||
novelId={novelId}
|
||||
tagId={selected.id}
|
||||
canWrite={canWrite}
|
||||
canDelete={canDelete}
|
||||
@@ -82,18 +82,18 @@ export default function TagsPage() {
|
||||
}
|
||||
|
||||
function TagReferencePanel({
|
||||
projectId,
|
||||
novelId,
|
||||
tagId,
|
||||
canWrite,
|
||||
canDelete,
|
||||
}: {
|
||||
projectId: string
|
||||
novelId: string
|
||||
tagId: string
|
||||
canWrite: boolean
|
||||
canDelete: boolean
|
||||
}) {
|
||||
const { data, isPending, error } = useTagReferences(tagId)
|
||||
const update = useUpdateTag(projectId)
|
||||
const update = useUpdateTag(novelId)
|
||||
const remove = useDeleteTag()
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
|
||||
@@ -155,7 +155,7 @@ function TagReferencePanel({
|
||||
<ul className="grid gap-1 text-sm">
|
||||
{data.characters.map((c) => (
|
||||
<li key={c.id}>
|
||||
<Link to={`/projects/${projectId}/characters`} className="hover:underline">
|
||||
<Link to={`/novels/${novelId}/characters`} className="hover:underline">
|
||||
{c.name}
|
||||
</Link>
|
||||
<span className="muted"> — {c.role}</span>
|
||||
@@ -172,7 +172,7 @@ function TagReferencePanel({
|
||||
{data.chapters.map((c) => (
|
||||
<li key={c.id}>
|
||||
<Link
|
||||
to={`/projects/${projectId}/chapters/${c.id}`}
|
||||
to={`/novels/${novelId}/chapters/${c.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{c.number}. {c.title}
|
||||
@@ -191,7 +191,7 @@ function TagReferencePanel({
|
||||
{data.beats.map((b) => (
|
||||
<li key={b.id}>
|
||||
<Link
|
||||
to={`/projects/${projectId}/chapters/${b.chapterId}`}
|
||||
to={`/novels/${novelId}/chapters/${b.chapterId}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{b.title}
|
||||
|
||||
Reference in New Issue
Block a user