From 4313c8f206796062ecbc9695d1a90d0c12cd1ccd Mon Sep 17 00:00:00 2001 From: James Wampler Date: Mon, 17 Aug 2026 23:03:09 -0700 Subject: [PATCH] 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/. --- CLAUDE.md | 2 +- src/Novelly.Api/Agent/AgentConversation.cs | 6 +- src/Novelly.Api/Agent/AgentEndpoints.cs | 16 +- src/Novelly.Api/Agent/AgentHttpContracts.cs | 6 +- src/Novelly.Api/Agent/NovelAgentService.cs | 62 +- src/Novelly.Api/Agent/NovelAgentToolset.cs | 64 +- src/Novelly.Api/Beats/BeatService.cs | 64 +- src/Novelly.Api/Chapters/Chapter.cs | 8 +- src/Novelly.Api/Chapters/ChapterContracts.cs | 8 +- src/Novelly.Api/Chapters/ChapterEndpoints.cs | 14 +- src/Novelly.Api/Chapters/ChapterService.cs | 46 +- src/Novelly.Api/Characters/Character.cs | 8 +- .../Characters/CharacterArcService.cs | 38 +- .../Characters/CharacterContracts.cs | 4 +- .../Characters/CharacterEndpoints.cs | 18 +- .../Characters/CharacterService.cs | 64 +- .../Common/NovellyServiceRegistration.cs | 8 +- ...818055934_RenameProjectToNovel.Designer.cs | 1169 +++++++++++++++++ .../20260818055934_RenameProjectToNovel.cs | 363 +++++ .../Migrations/NovelDbContextModelSnapshot.cs | 142 +- src/Novelly.Api/Data/NovelDbContext.cs | 10 +- src/Novelly.Api/Imports/ImportAgentService.cs | 26 +- src/Novelly.Api/Imports/ImportAgentToolset.cs | 46 +- src/Novelly.Api/Imports/ImportContracts.cs | 6 +- src/Novelly.Api/Imports/ImportJob.cs | 2 +- src/Novelly.Api/Imports/ImportJobRunner.cs | 6 +- src/Novelly.Api/Imports/ImportPaths.cs | 4 +- src/Novelly.Api/Imports/ImportService.cs | 12 +- .../{Projects/Project.cs => Novels/Novel.cs} | 32 +- .../NovelContracts.cs} | 38 +- src/Novelly.Api/Novels/NovelEndpoints.cs | 57 + .../ProjectPhase.cs => Novels/NovelPhase.cs} | 4 +- src/Novelly.Api/Novels/NovelService.cs | 133 ++ src/Novelly.Api/Program.cs | 6 +- src/Novelly.Api/Projects/ProjectEndpoints.cs | 57 - src/Novelly.Api/Projects/ProjectService.cs | 133 -- src/Novelly.Api/Questions/OpenQuestion.cs | 12 +- .../Questions/OpenQuestionContracts.cs | 4 +- .../Questions/OpenQuestionEndpoints.cs | 16 +- .../Questions/OpenQuestionService.cs | 60 +- src/Novelly.Api/Tags/Tag.cs | 8 +- src/Novelly.Api/Tags/TagEndpoints.cs | 14 +- src/Novelly.Api/Tags/TagService.cs | 60 +- src/Novelly.Api/Users/NovelAccessService.cs | 89 ++ .../{ProjectMember.cs => NovelMember.cs} | 18 +- ...erContracts.cs => NovelMemberContracts.cs} | 10 +- src/Novelly.Api/Users/NovelMemberEndpoints.cs | 28 + src/Novelly.Api/Users/NovelMemberService.cs | 107 ++ .../Users/{ProjectRole.cs => NovelRole.cs} | 2 +- src/Novelly.Api/Users/ProjectAccessService.cs | 89 -- .../Users/ProjectMemberEndpoints.cs | 28 - src/Novelly.Api/Users/ProjectMemberService.cs | 107 -- src/Novelly.Api/Users/UserAccountService.cs | 2 +- src/Novelly.Mcp/Tools/CharacterTools.cs | 16 +- src/Novelly.Mcp/Tools/ManuscriptTools.cs | 12 +- .../Tools/{ProjectTools.cs => NovelTools.cs} | 40 +- src/Novelly.Mcp/Tools/QuestionTools.cs | 8 +- src/Novelly.Mcp/Tools/TagTools.cs | 12 +- src/Novelly.ServiceDefaults/Extensions.cs | 4 +- src/Novelly.Web/src/App.tsx | 10 +- src/Novelly.Web/src/api/hooks.ts | 224 ++-- src/Novelly.Web/src/api/types.ts | 36 +- src/Novelly.Web/src/auth/AuthContext.tsx | 12 +- .../src/components/CharacterArc.tsx | 26 +- .../src/components/CharacterBeats.tsx | 6 +- .../src/components/CharacterContextMenu.tsx | 4 +- .../src/components/CharacterMultiSelect.tsx | 16 +- .../src/components/ImportDialog.tsx | 8 +- .../src/components/OpenQuestions.tsx | 20 +- src/Novelly.Web/src/pages/AgentPage.tsx | 6 +- src/Novelly.Web/src/pages/ChapterPage.tsx | 54 +- src/Novelly.Web/src/pages/ChaptersPage.tsx | 14 +- .../src/pages/CharacterDetailPage.tsx | 50 +- src/Novelly.Web/src/pages/CharactersPage.tsx | 32 +- src/Novelly.Web/src/pages/DashboardPage.tsx | 38 +- .../{ProjectLayout.tsx => NovelLayout.tsx} | 30 +- .../{ProjectsPage.tsx => NovelsPage.tsx} | 42 +- src/Novelly.Web/src/pages/SettingsPage.tsx | 78 +- src/Novelly.Web/src/pages/TagsPage.tsx | 26 +- tests/Novelly.Api.Tests/BeatServiceTests.cs | 40 +- .../Novelly.Api.Tests/ChapterServiceTests.cs | 16 +- tests/Novelly.Api.Tests/CharacterArcTests.cs | 50 +- .../CharacterServiceTests.cs | 76 +- .../ExceptionHandlingTests.cs | 18 +- tests/Novelly.Api.Tests/GenreServiceTests.cs | 16 +- .../ImportAgentToolsetTests.cs | 20 +- tests/Novelly.Api.Tests/ImportServiceTests.cs | 20 +- tests/Novelly.Api.Tests/ListingTests.cs | 60 +- tests/Novelly.Api.Tests/LoggingTests.cs | 34 +- ...jectAccessTests.cs => NovelAccessTests.cs} | 68 +- .../NovelAgentServiceTests.cs | 40 +- ...{ProjectDataTests.cs => NovelDataTests.cs} | 84 +- tests/Novelly.Api.Tests/OpenQuestionTests.cs | 70 +- tests/Novelly.Api.Tests/ServiceTestFixture.cs | 16 +- tests/Novelly.Api.Tests/TagServiceTests.cs | 64 +- 95 files changed, 3192 insertions(+), 1660 deletions(-) create mode 100644 src/Novelly.Api/Data/Migrations/20260818055934_RenameProjectToNovel.Designer.cs create mode 100644 src/Novelly.Api/Data/Migrations/20260818055934_RenameProjectToNovel.cs rename src/Novelly.Api/{Projects/Project.cs => Novels/Novel.cs} (56%) rename src/Novelly.Api/{Projects/ProjectContracts.cs => Novels/NovelContracts.cs} (68%) create mode 100644 src/Novelly.Api/Novels/NovelEndpoints.cs rename src/Novelly.Api/{Projects/ProjectPhase.cs => Novels/NovelPhase.cs} (57%) create mode 100644 src/Novelly.Api/Novels/NovelService.cs delete mode 100644 src/Novelly.Api/Projects/ProjectEndpoints.cs delete mode 100644 src/Novelly.Api/Projects/ProjectService.cs create mode 100644 src/Novelly.Api/Users/NovelAccessService.cs rename src/Novelly.Api/Users/{ProjectMember.cs => NovelMember.cs} (50%) rename src/Novelly.Api/Users/{ProjectMemberContracts.cs => NovelMemberContracts.cs} (54%) create mode 100644 src/Novelly.Api/Users/NovelMemberEndpoints.cs create mode 100644 src/Novelly.Api/Users/NovelMemberService.cs rename src/Novelly.Api/Users/{ProjectRole.cs => NovelRole.cs} (74%) delete mode 100644 src/Novelly.Api/Users/ProjectAccessService.cs delete mode 100644 src/Novelly.Api/Users/ProjectMemberEndpoints.cs delete mode 100644 src/Novelly.Api/Users/ProjectMemberService.cs rename src/Novelly.Mcp/Tools/{ProjectTools.cs => NovelTools.cs} (54%) rename src/Novelly.Web/src/pages/{ProjectLayout.tsx => NovelLayout.tsx} (79%) rename src/Novelly.Web/src/pages/{ProjectsPage.tsx => NovelsPage.tsx} (82%) rename tests/Novelly.Api.Tests/{ProjectAccessTests.cs => NovelAccessTests.cs} (58%) rename tests/Novelly.Api.Tests/{ProjectDataTests.cs => NovelDataTests.cs} (51%) diff --git a/CLAUDE.md b/CLAUDE.md index 0320cb5..63fadc0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ Novelly: software plan + write novel. ASP.NET Core 10, C#, TypeScript, React, .N ## Structure - `src/Novelly.Api/` — whole back end, organised by feature. One folder per feature holds - entity, DTOs, service, endpoints together: `Projects/`, `Characters/`, `Chapters/`, `Beats/`, + entity, DTOs, service, endpoints together: `Novels/`, `Characters/`, `Chapters/`, `Beats/`, `Scenes/`, `Tags/`, `Agent/`. `Common/` holds what crosses features; `Data/` holds `DbContext` + EF migrations. - `src/Novelly.AppHost/` — .NET Aspire orchestration; run this to bring up API + web client diff --git a/src/Novelly.Api/Agent/AgentConversation.cs b/src/Novelly.Api/Agent/AgentConversation.cs index 13e0322..0916888 100644 --- a/src/Novelly.Api/Agent/AgentConversation.cs +++ b/src/Novelly.Api/Agent/AgentConversation.cs @@ -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"; diff --git a/src/Novelly.Api/Agent/AgentEndpoints.cs b/src/Novelly.Api/Agent/AgentEndpoints.cs index c53a0c9..9b8c3cc 100644 --- a/src/Novelly.Api/Agent/AgentEndpoints.cs +++ b/src/Novelly.Api/Agent/AgentEndpoints.cs @@ -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() .AddEndpointFilter(); - 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())); diff --git a/src/Novelly.Api/Agent/AgentHttpContracts.cs b/src/Novelly.Api/Agent/AgentHttpContracts.cs index 69378c4..1dcbd6b 100644 --- a/src/Novelly.Api/Agent/AgentHttpContracts.cs +++ b/src/Novelly.Api/Agent/AgentHttpContracts.cs @@ -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 Messages, DateTimeOffset UpdatedAt); +public record ConversationResponse(Guid Id, Guid NovelId, string Title, IReadOnlyList Messages, DateTimeOffset UpdatedAt); public record AgentMessageResponse(Guid Id, AgentRole Role, string Content, IReadOnlyList 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); diff --git a/src/Novelly.Api/Agent/NovelAgentService.cs b/src/Novelly.Api/Agent/NovelAgentService.cs index 7fb2ead..634a35d 100644 --- a/src/Novelly.Api/Agent/NovelAgentService.cs +++ b/src/Novelly.Api/Agent/NovelAgentService.cs @@ -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> 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 SendMessageAsync(Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default) + public async Task 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(); 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(); 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. diff --git a/src/Novelly.Api/Agent/NovelAgentToolset.cs b/src/Novelly.Api/Agent/NovelAgentToolset.cs index e9b59df..084c15f 100644 --- a/src/Novelly.Api/Agent/NovelAgentToolset.cs +++ b/src/Novelly.Api/Agent/NovelAgentToolset.cs @@ -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> Handler); public class NovelAgentToolset( - ProjectService projects, + NovelService novels, CharacterService characters, CharacterArcService arcs, ChapterService chapters, @@ -45,7 +45,7 @@ public class NovelAgentToolset( public IReadOnlyList Definitions => [.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))]; - public async Task ExecuteAsync(string name, Guid projectId, JsonElement input, CancellationToken ct = default) + public async Task 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 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(input, "role") ?? CharacterRole.Supporting, JsonInput.Enum(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(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", diff --git a/src/Novelly.Api/Beats/BeatService.cs b/src/Novelly.Api/Beats/BeatService.cs index c2df9c9..0f62fa9 100644 --- a/src/Novelly.Api/Beats/BeatService.cs +++ b/src/Novelly.Api/Beats/BeatService.cs @@ -11,7 +11,7 @@ namespace Novelly.Api.Beats; public class BeatService( INovelDbContext db, - ProjectAccessService access, + NovelAccessService access, TagService tags, ILogger logger, IModelValidator 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> ResolveCharactersAsync(Guid projectId, IReadOnlyList characterIds, CancellationToken ct) + private async Task> ResolveCharactersAsync(Guid novelId, IReadOnlyList 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 Query() => diff --git a/src/Novelly.Api/Chapters/Chapter.cs b/src/Novelly.Api/Chapters/Chapter.cs index dd5a085..7cc495d 100644 --- a/src/Novelly.Api/Chapters/Chapter.cs +++ b/src/Novelly.Api/Chapters/Chapter.cs @@ -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 { entity.Property(c => c.Title).IsRequired().HasMaxLength(300); entity.Property(c => c.Status).HasConversion().HasMaxLength(32); - entity.HasIndex(c => new { c.ProjectId, c.Number }); + entity.HasIndex(c => new { c.NovelId, c.Number }); } } diff --git a/src/Novelly.Api/Chapters/ChapterContracts.cs b/src/Novelly.Api/Chapters/ChapterContracts.cs index f4039e4..0921268 100644 --- a/src/Novelly.Api/Chapters/ChapterContracts.cs +++ b/src/Novelly.Api/Chapters/ChapterContracts.cs @@ -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())], diff --git a/src/Novelly.Api/Chapters/ChapterEndpoints.cs b/src/Novelly.Api/Chapters/ChapterEndpoints.cs index 5df7cf0..eceeb74 100644 --- a/src/Novelly.Api/Chapters/ChapterEndpoints.cs +++ b/src/Novelly.Api/Chapters/ChapterEndpoints.cs @@ -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() .AddEndpointFilter(); - 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(); diff --git a/src/Novelly.Api/Chapters/ChapterService.cs b/src/Novelly.Api/Chapters/ChapterService.cs index bd99557..8077b9b 100644 --- a/src/Novelly.Api/Chapters/ChapterService.cs +++ b/src/Novelly.Api/Chapters/ChapterService.cs @@ -9,24 +9,24 @@ namespace Novelly.Api.Chapters; public class ChapterService( INovelDbContext db, - ProjectAccessService access, + NovelAccessService access, TagService tags, ILogger logger, IModelValidator createValidator, IModelValidator updateValidator) { - public async Task> ListAsync(Guid projectId, CancellationToken ct = default) + public async Task> 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 CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default) + public async Task 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 NextChapterNumberAsync(Guid projectId, CancellationToken ct) + private async Task 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; } diff --git a/src/Novelly.Api/Characters/Character.cs b/src/Novelly.Api/Characters/Character.cs index 29bffbe..2c220fc 100644 --- a/src/Novelly.Api/Characters/Character.cs +++ b/src/Novelly.Api/Characters/Character.cs @@ -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 c.Name).IsRequired().HasMaxLength(200); entity.Property(c => c.Role).HasConversion().HasMaxLength(32); entity.Property(c => c.Importance).HasConversion().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!) diff --git a/src/Novelly.Api/Characters/CharacterArcService.cs b/src/Novelly.Api/Characters/CharacterArcService.cs index 1c85277..48e202d 100644 --- a/src/Novelly.Api/Characters/CharacterArcService.cs +++ b/src/Novelly.Api/Characters/CharacterArcService.cs @@ -8,7 +8,7 @@ namespace Novelly.Api.Characters; public class CharacterArcService( INovelDbContext db, - ProjectAccessService access, + NovelAccessService access, ILogger logger, IModelValidator createValidator, IModelValidator 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 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 Query() => diff --git a/src/Novelly.Api/Characters/CharacterContracts.cs b/src/Novelly.Api/Characters/CharacterContracts.cs index 7becdf9..258442a 100644 --- a/src/Novelly.Api/Characters/CharacterContracts.cs +++ b/src/Novelly.Api/Characters/CharacterContracts.cs @@ -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 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], diff --git a/src/Novelly.Api/Characters/CharacterEndpoints.cs b/src/Novelly.Api/Characters/CharacterEndpoints.cs index 0d93c26..4ab2e94 100644 --- a/src/Novelly.Api/Characters/CharacterEndpoints.cs +++ b/src/Novelly.Api/Characters/CharacterEndpoints.cs @@ -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() .AddEndpointFilter(); - 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) => diff --git a/src/Novelly.Api/Characters/CharacterService.cs b/src/Novelly.Api/Characters/CharacterService.cs index 6eac946..dbced19 100644 --- a/src/Novelly.Api/Characters/CharacterService.cs +++ b/src/Novelly.Api/Characters/CharacterService.cs @@ -9,7 +9,7 @@ namespace Novelly.Api.Characters; public class CharacterService( INovelDbContext db, - ProjectAccessService access, + NovelAccessService access, TagService tags, ILogger logger, IModelValidator createValidator, @@ -17,16 +17,16 @@ public class CharacterService( IModelValidator relationshipValidator, IModelValidator identityValidator) { - public async Task> ListAsync(Guid projectId, CancellationToken ct = default) + public async Task> 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 CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default) + public async Task 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; diff --git a/src/Novelly.Api/Common/NovellyServiceRegistration.cs b/src/Novelly.Api/Common/NovellyServiceRegistration.cs index 83cc074..5e8fff0 100644 --- a/src/Novelly.Api/Common/NovellyServiceRegistration.cs +++ b/src/Novelly.Api/Common/NovellyServiceRegistration.cs @@ -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, NovellyUserClaimsPrincipalFactory>(); services.AddHttpContextAccessor(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.ConfigureApplicationCookie(options => { @@ -70,9 +70,9 @@ public static class NovellyServiceRegistration }); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Novelly.Api/Data/Migrations/20260818055934_RenameProjectToNovel.Designer.cs b/src/Novelly.Api/Data/Migrations/20260818055934_RenameProjectToNovel.Designer.cs new file mode 100644 index 0000000..448a1e3 --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260818055934_RenameProjectToNovel.Designer.cs @@ -0,0 +1,1169 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Novelly.Api.Data; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + [DbContext(typeof(NovelDbContext))] + [Migration("20260818055934_RenameProjectToNovel")] + partial class RenameProjectToNovel + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("BeatCharacter", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "CharactersId"); + + b.HasIndex("CharactersId"); + + b.ToTable("BeatCharacters", (string)null); + }); + + modelBuilder.Entity("BeatCharacterArcStage", b => + { + b.Property("ArcStagesId") + .HasColumnType("TEXT"); + + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.HasKey("ArcStagesId", "BeatsId"); + + b.HasIndex("BeatsId"); + + b.ToTable("ArcStageBeats", (string)null); + }); + + modelBuilder.Entity("BeatTag", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("BeatTags", (string)null); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTags", (string)null); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("CharactersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("CharacterTags", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("NovelId"); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ConversationId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Sequence") + .HasColumnType("INTEGER"); + + b.Property("ToolCallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "Sequence") + .IsUnique(); + + b.ToTable("AgentMessages"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WhatHappened") + .HasColumnType("TEXT"); + + b.Property("WhatsNext") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId", "SortOrder"); + + b.ToTable("Beats"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Prose") + .HasColumnType("TEXT"); + + b.Property("Setting") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("NovelId", "Number"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Age") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Aliases") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Appearance") + .HasColumnType("TEXT"); + + b.Property("ArcSummary") + .HasColumnType("TEXT"); + + b.Property("Backstory") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExternalConflict") + .HasColumnType("TEXT"); + + b.Property("IdentityNote") + .HasColumnType("TEXT"); + + b.Property("Importance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("InternalConflict") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Need") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("Occupation") + .HasColumnType("TEXT"); + + b.Property("Personality") + .HasColumnType("TEXT"); + + b.Property("Pronouns") + .HasColumnType("TEXT"); + + b.Property("RevealedInChapterId") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SameCharacterAsId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("Voice") + .HasColumnType("TEXT"); + + b.Property("Want") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NovelId"); + + b.HasIndex("RevealedInChapterId"); + + b.HasIndex("SameCharacterAsId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Result") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId", "SortOrder"); + + b.ToTable("CharacterArcStages"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("RelatedCharacterId") + .HasColumnType("TEXT"); + + b.Property("RelationshipType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("RelatedCharacterId"); + + b.ToTable("CharacterRelationships"); + }); + + modelBuilder.Entity("Novelly.Api.Genres.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + + b.HasData( + new + { + Id = new Guid("b89aadb3-ee96-5a33-897d-94946b037f96"), + Name = "Adventure" + }, + new + { + Id = new Guid("1295b746-5de1-5724-aab8-186d4220c84f"), + Name = "Contemporary Fiction" + }, + new + { + Id = new Guid("786d6d01-be6c-5dff-ab53-17081d2979ed"), + Name = "Crime" + }, + new + { + Id = new Guid("800eea0a-52cb-5e03-8b6f-5e1ceaec8554"), + Name = "Dystopian" + }, + new + { + Id = new Guid("8dbe0291-1ab6-5045-b327-00f2025a7b0a"), + Name = "Fantasy" + }, + new + { + Id = new Guid("93face5a-9a61-5d63-9a8d-7fd5d49eab7d"), + Name = "Historical Fiction" + }, + new + { + Id = new Guid("4eba456f-b706-5f1f-bfc9-5d32cab0da62"), + Name = "Horror" + }, + new + { + Id = new Guid("d49c5adf-3ed9-5bc9-8652-1f7a9a098ecb"), + Name = "Literary Fiction" + }, + new + { + Id = new Guid("f72c6437-c8e7-519f-8d35-5aefeebbff9e"), + Name = "Magical Realism" + }, + new + { + Id = new Guid("1b670010-b4cc-5b22-a879-d36eb1bf3429"), + Name = "Memoir" + }, + new + { + Id = new Guid("03063bbf-de5d-5dd0-af06-0ee939de58bc"), + Name = "Middle Grade" + }, + new + { + Id = new Guid("c22ed045-52e5-54b0-8cdd-cd1d6a699c19"), + Name = "Mystery" + }, + new + { + Id = new Guid("abe2e8bc-a35e-5a30-a07f-7ae30a00d838"), + Name = "Non-Fiction" + }, + new + { + Id = new Guid("f8543db0-c519-56a0-996a-c6028176e57e"), + Name = "Poetry" + }, + new + { + Id = new Guid("b6251b9e-63a1-563f-94c0-834162fb580b"), + Name = "Romance" + }, + new + { + Id = new Guid("4f188842-488e-567a-b31d-831e0c551fa5"), + Name = "Science Fiction" + }, + new + { + Id = new Guid("ae67fc84-1ed9-55ae-8c9f-8a37adb52b57"), + Name = "Thriller" + }, + new + { + Id = new Guid("37956a94-e9c4-5d29-abbc-f121d687f997"), + Name = "Young Adult" + }); + }); + + modelBuilder.Entity("Novelly.Api.Imports.ImportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChaptersCompleted") + .HasColumnType("INTEGER"); + + b.Property("ChaptersTotal") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasColumnType("TEXT"); + + b.Property("SourceRoot") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SourceRoot"); + + b.ToTable("ImportJobs"); + }); + + modelBuilder.Entity("Novelly.Api.Novels.Novel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Genre") + .HasColumnType("TEXT"); + + b.Property("Logline") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Synopsis") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Novels"); + }); + + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Detail") + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Resolution") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("NovelId"); + + b.ToTable("OpenQuestions"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Color") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NovelId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Novelly.Api.Users.NovelMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("INTEGER"); + + b.Property("GrantedByUserId") + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("NovelRole") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("GlobalRole") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("INTEGER"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("BeatCharacter", b => + { + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", null) + .WithMany() + .HasForeignKey("CharactersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BeatCharacterArcStage", b => + { + b.HasOne("Novelly.Api.Characters.CharacterArcStage", null) + .WithMany() + .HasForeignKey("ArcStagesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BeatTag", b => + { + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.HasOne("Novelly.Api.Characters.Character", null) + .WithMany() + .HasForeignKey("CharactersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Novelly.Api.Users.NovellyUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Novelly.Api.Users.NovellyUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Novelly.Api.Users.NovellyUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Conversations") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.HasOne("Novelly.Api.Agent.AgentConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany("Beats") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Chapters") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Characters") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Chapters.Chapter", "RevealedInChapter") + .WithMany() + .HasForeignKey("RevealedInChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "SameCharacterAs") + .WithMany("OtherIdentities") + .HasForeignKey("SameCharacterAsId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Novel"); + + b.Navigation("RevealedInChapter"); + + b.Navigation("SameCharacterAs"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("ArcStages") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("Relationships") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", "RelatedCharacter") + .WithMany() + .HasForeignKey("RelatedCharacterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Character"); + + b.Navigation("RelatedCharacter"); + }); + + modelBuilder.Entity("Novelly.Api.Novels.Novel", b => + { + b.HasOne("Novelly.Api.Users.NovellyUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany() + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Tags") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("Novelly.Api.Users.NovelMember", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Members") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Users.NovellyUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Navigation("Beats"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Navigation("ArcStages"); + + b.Navigation("OtherIdentities"); + + b.Navigation("Relationships"); + }); + + modelBuilder.Entity("Novelly.Api.Novels.Novel", b => + { + b.Navigation("Chapters"); + + b.Navigation("Characters"); + + b.Navigation("Conversations"); + + b.Navigation("Members"); + + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/20260818055934_RenameProjectToNovel.cs b/src/Novelly.Api/Data/Migrations/20260818055934_RenameProjectToNovel.cs new file mode 100644 index 0000000..ec8ac04 --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260818055934_RenameProjectToNovel.cs @@ -0,0 +1,363 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + /// + public partial class RenameProjectToNovel : Migration + { + /// + 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); + } + + /// + 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); + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs index ca3a072..182acbe 100644 --- a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs +++ b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs @@ -163,7 +163,7 @@ namespace Novelly.Api.Data.Migrations b.Property("CreatedAt") .HasColumnType("INTEGER"); - b.Property("ProjectId") + b.Property("NovelId") .HasColumnType("TEXT"); b.Property("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("Notes") .HasColumnType("TEXT"); + b.Property("NovelId") + .HasColumnType("TEXT"); + b.Property("Number") .HasColumnType("INTEGER"); - b.Property("ProjectId") - .HasColumnType("TEXT"); - b.Property("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("Notes") .HasColumnType("TEXT"); + b.Property("NovelId") + .HasColumnType("TEXT"); + b.Property("Occupation") .HasColumnType("TEXT"); b.Property("Personality") .HasColumnType("TEXT"); - b.Property("ProjectId") - .HasColumnType("TEXT"); - b.Property("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("CreatedAt") .HasColumnType("INTEGER"); - b.Property("ProjectId") + b.Property("NovelId") .HasColumnType("TEXT"); b.Property("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("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("Detail") .HasColumnType("TEXT"); - b.Property("ProjectId") + b.Property("NovelId") .HasColumnType("TEXT"); b.Property("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("ProjectId") + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("INTEGER"); + + b.Property("GrantedByUserId") + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("NovelRole") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("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("Id") @@ -823,39 +856,6 @@ namespace Novelly.Api.Data.Migrations b.ToTable("AspNetUsers", (string)null); }); - modelBuilder.Entity("Novelly.Api.Users.ProjectMember", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("GrantedAt") - .HasColumnType("INTEGER"); - - b.Property("GrantedByUserId") - .HasColumnType("TEXT"); - - b.Property("ProjectId") - .HasColumnType("TEXT"); - - b.Property("ProjectRole") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("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"); diff --git a/src/Novelly.Api/Data/NovelDbContext.cs b/src/Novelly.Api/Data/NovelDbContext.cs index 521b641..b4572e0 100644 --- a/src/Novelly.Api/Data/NovelDbContext.cs +++ b/src/Novelly.Api/Data/NovelDbContext.cs @@ -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(value public class NovelDbContext(DbContextOptions options) : IdentityUserContext(options), INovelDbContext { - public DbSet Projects => Set(); + public DbSet Novels => Set(); public DbSet Characters => Set(); public DbSet CharacterRelationships => Set(); public DbSet CharacterArcStages => Set(); @@ -30,7 +30,7 @@ public class NovelDbContext(DbContextOptions options) : Identity public DbSet AgentMessages => Set(); public DbSet ImportJobs => Set(); public DbSet Genres => Set(); - public DbSet ProjectMembers => Set(); + public DbSet NovelMembers => Set(); Task INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) => base.SaveChangesAsync(cancellationToken); @@ -45,7 +45,7 @@ public class NovelDbContext(DbContextOptions options) : Identity public interface INovelDbContext { - DbSet Projects { get; } + DbSet Novels { get; } DbSet Characters { get; } DbSet CharacterRelationships { get; } DbSet CharacterArcStages { get; } @@ -58,7 +58,7 @@ public interface INovelDbContext DbSet ImportJobs { get; } DbSet Genres { get; } DbSet Users { get; } - DbSet ProjectMembers { get; } + DbSet NovelMembers { get; } Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Novelly.Api/Imports/ImportAgentService.cs b/src/Novelly.Api/Imports/ImportAgentService.cs index e320d40..5ad5b4b 100644 --- a/src/Novelly.Api/Imports/ImportAgentService.cs +++ b/src/Novelly.Api/Imports/ImportAgentService.cs @@ -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 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 diff --git a/src/Novelly.Api/Imports/ImportAgentToolset.cs b/src/Novelly.Api/Imports/ImportAgentToolset.cs index f00d60a..b3a1bce 100644 --- a/src/Novelly.Api/Imports/ImportAgentToolset.cs +++ b/src/Novelly.Api/Imports/ImportAgentToolset.cs @@ -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> 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? _byName; - public Guid? ProjectId { get; private set; } + public Guid? NovelId { get; private set; } public IReadOnlyList 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 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(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(); }); diff --git a/src/Novelly.Api/Imports/ImportContracts.cs b/src/Novelly.Api/Imports/ImportContracts.cs index e91f344..622b805 100644 --- a/src/Novelly.Api/Imports/ImportContracts.cs +++ b/src/Novelly.Api/Imports/ImportContracts.cs @@ -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 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, diff --git a/src/Novelly.Api/Imports/ImportJob.cs b/src/Novelly.Api/Imports/ImportJob.cs index e43a474..001e16e 100644 --- a/src/Novelly.Api/Imports/ImportJob.cs +++ b/src/Novelly.Api/Imports/ImportJob.cs @@ -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; } diff --git a/src/Novelly.Api/Imports/ImportJobRunner.cs b/src/Novelly.Api/Imports/ImportJobRunner.cs index 1f6e82f..e7668b3 100644 --- a/src/Novelly.Api/Imports/ImportJobRunner.cs +++ b/src/Novelly.Api/Imports/ImportJobRunner.cs @@ -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; diff --git a/src/Novelly.Api/Imports/ImportPaths.cs b/src/Novelly.Api/Imports/ImportPaths.cs index 6ad7e27..7aa7a01 100644 --- a/src/Novelly.Api/Imports/ImportPaths.cs +++ b/src/Novelly.Api/Imports/ImportPaths.cs @@ -4,7 +4,7 @@ using System.Text.Json.Serialization; namespace Novelly.Api.Imports; public record ImportLedger( - Guid? ProjectId, + Guid? NovelId, Dictionary? Characters, Dictionary? Chapters, List? 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); diff --git a/src/Novelly.Api/Imports/ImportService.cs b/src/Novelly.Api/Imports/ImportService.cs index 214c089..7122700 100644 --- a/src/Novelly.Api/Imports/ImportService.cs +++ b/src/Novelly.Api/Imports/ImportService.cs @@ -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 queue, INovelUserContext userContext, ILogger 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 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); diff --git a/src/Novelly.Api/Projects/Project.cs b/src/Novelly.Api/Novels/Novel.cs similarity index 56% rename from src/Novelly.Api/Projects/Project.cs rename to src/Novelly.Api/Novels/Novel.cs index 53bd9f2..9ebfa8f 100644 --- a/src/Novelly.Api/Projects/Project.cs +++ b/src/Novelly.Api/Novels/Novel.cs @@ -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 Chapters { get; set; } = []; public List Tags { get; set; } = []; public List Conversations { get; set; } = []; - public List Members { get; set; } = []; + public List Members { get; set; } = []; } -public class ProjectEntityTypeConfiguration : IEntityTypeConfiguration +public class NovelEntityTypeConfiguration : IEntityTypeConfiguration { - public void Configure(EntityTypeBuilder entity) + public void Configure(EntityTypeBuilder entity) { entity.Property(p => p.Title).IsRequired().HasMaxLength(300); entity.Property(p => p.Phase).HasConversion().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); } diff --git a/src/Novelly.Api/Projects/ProjectContracts.cs b/src/Novelly.Api/Novels/NovelContracts.cs similarity index 68% rename from src/Novelly.Api/Projects/ProjectContracts.cs rename to src/Novelly.Api/Novels/NovelContracts.cs index f0f996f..851b733 100644 --- a/src/Novelly.Api/Projects/ProjectContracts.cs +++ b/src/Novelly.Api/Novels/NovelContracts.cs @@ -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 +public class CreateNovelRequestValidator : IModelValidator { - 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 +public class UpdateNovelRequestValidator : IModelValidator { - 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); } diff --git a/src/Novelly.Api/Novels/NovelEndpoints.cs b/src/Novelly.Api/Novels/NovelEndpoints.cs new file mode 100644 index 0000000..9434841 --- /dev/null +++ b/src/Novelly.Api/Novels/NovelEndpoints.cs @@ -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() + .AddEndpointFilter(); + + 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; + } +} diff --git a/src/Novelly.Api/Projects/ProjectPhase.cs b/src/Novelly.Api/Novels/NovelPhase.cs similarity index 57% rename from src/Novelly.Api/Projects/ProjectPhase.cs rename to src/Novelly.Api/Novels/NovelPhase.cs index b447ec7..b3811b1 100644 --- a/src/Novelly.Api/Projects/ProjectPhase.cs +++ b/src/Novelly.Api/Novels/NovelPhase.cs @@ -1,6 +1,6 @@ -namespace Novelly.Api.Projects; +namespace Novelly.Api.Novels; -public enum ProjectPhase +public enum NovelPhase { Brainstorming, Outlining, diff --git a/src/Novelly.Api/Novels/NovelService.cs b/src/Novelly.Api/Novels/NovelService.cs new file mode 100644 index 0000000..36539a6 --- /dev/null +++ b/src/Novelly.Api/Novels/NovelService.cs @@ -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 logger, + IModelValidator createValidator, + IModelValidator updateValidator) +{ + public async Task> 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 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 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 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 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 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; + } +} diff --git a/src/Novelly.Api/Program.cs b/src/Novelly.Api/Program.cs index 2ca6517..66648d8 100644 --- a/src/Novelly.Api/Program.cs +++ b/src/Novelly.Api/Program.cs @@ -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() diff --git a/src/Novelly.Api/Projects/ProjectEndpoints.cs b/src/Novelly.Api/Projects/ProjectEndpoints.cs deleted file mode 100644 index f7e4cbf..0000000 --- a/src/Novelly.Api/Projects/ProjectEndpoints.cs +++ /dev/null @@ -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() - .AddEndpointFilter(); - - 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; - } -} diff --git a/src/Novelly.Api/Projects/ProjectService.cs b/src/Novelly.Api/Projects/ProjectService.cs deleted file mode 100644 index fbce64a..0000000 --- a/src/Novelly.Api/Projects/ProjectService.cs +++ /dev/null @@ -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 logger, - IModelValidator createValidator, - IModelValidator updateValidator) -{ - public async Task> 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 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 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 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 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 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; - } -} diff --git a/src/Novelly.Api/Questions/OpenQuestion.cs b/src/Novelly.Api/Questions/OpenQuestion.cs index 0acd32b..bc69034 100644 --- a/src/Novelly.Api/Questions/OpenQuestion.cs +++ b/src/Novelly.Api/Questions/OpenQuestion.cs @@ -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 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); diff --git a/src/Novelly.Api/Questions/OpenQuestionContracts.cs b/src/Novelly.Api/Questions/OpenQuestionContracts.cs index add53ab..4319964 100644 --- a/src/Novelly.Api/Questions/OpenQuestionContracts.cs +++ b/src/Novelly.Api/Questions/OpenQuestionContracts.cs @@ -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, diff --git a/src/Novelly.Api/Questions/OpenQuestionEndpoints.cs b/src/Novelly.Api/Questions/OpenQuestionEndpoints.cs index 1cf57fd..1453553 100644 --- a/src/Novelly.Api/Questions/OpenQuestionEndpoints.cs +++ b/src/Novelly.Api/Questions/OpenQuestionEndpoints.cs @@ -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() .AddEndpointFilter(); - 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(); diff --git a/src/Novelly.Api/Questions/OpenQuestionService.cs b/src/Novelly.Api/Questions/OpenQuestionService.cs index 0b21990..18c1cb6 100644 --- a/src/Novelly.Api/Questions/OpenQuestionService.cs +++ b/src/Novelly.Api/Questions/OpenQuestionService.cs @@ -10,28 +10,28 @@ namespace Novelly.Api.Questions; public class OpenQuestionService( INovelDbContext db, - ProjectAccessService access, + NovelAccessService access, ILogger logger, IModelValidator createValidator, IModelValidator updateValidator, IModelValidator resolveValidator) { public async Task> 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 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 Query() => diff --git a/src/Novelly.Api/Tags/Tag.cs b/src/Novelly.Api/Tags/Tag.cs index 6b1b744..a1de44f 100644 --- a/src/Novelly.Api/Tags/Tag.cs +++ b/src/Novelly.Api/Tags/Tag.cs @@ -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 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")); diff --git a/src/Novelly.Api/Tags/TagEndpoints.cs b/src/Novelly.Api/Tags/TagEndpoints.cs index ca6c07d..cee0aaa 100644 --- a/src/Novelly.Api/Tags/TagEndpoints.cs +++ b/src/Novelly.Api/Tags/TagEndpoints.cs @@ -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() .AddEndpointFilter(); - 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(); diff --git a/src/Novelly.Api/Tags/TagService.cs b/src/Novelly.Api/Tags/TagService.cs index a1d2349..229cf29 100644 --- a/src/Novelly.Api/Tags/TagService.cs +++ b/src/Novelly.Api/Tags/TagService.cs @@ -8,21 +8,21 @@ namespace Novelly.Api.Tags; public class TagService( INovelDbContext db, - ProjectAccessService access, + NovelAccessService access, ILogger logger, IModelValidator createValidator, IModelValidator updateValidator) { - public async Task> ListAsync(Guid projectId, CancellationToken ct = default) + public async Task> 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 CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default) + public async Task 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> ResolveAsync( - Guid projectId, IReadOnlyList names, CancellationToken ct) + Guid novelId, IReadOnlyList 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(); @@ -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 FindByNameAsync(Guid projectId, string name, CancellationToken ct) => + private async Task 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); } diff --git a/src/Novelly.Api/Users/NovelAccessService.cs b/src/Novelly.Api/Users/NovelAccessService.cs new file mode 100644 index 0000000..72be3be --- /dev/null +++ b/src/Novelly.Api/Users/NovelAccessService.cs @@ -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 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 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 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 + }; +} diff --git a/src/Novelly.Api/Users/ProjectMember.cs b/src/Novelly.Api/Users/NovelMember.cs similarity index 50% rename from src/Novelly.Api/Users/ProjectMember.cs rename to src/Novelly.Api/Users/NovelMember.cs index 12e7d8e..fe78475 100644 --- a/src/Novelly.Api/Users/ProjectMember.cs +++ b/src/Novelly.Api/Users/NovelMember.cs @@ -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 +public class NovelMemberEntityTypeConfiguration : IEntityTypeConfiguration { - public void Configure(EntityTypeBuilder entity) + public void Configure(EntityTypeBuilder entity) { - entity.Property(m => m.ProjectRole).HasConversion().HasMaxLength(32); - entity.HasIndex(m => new { m.ProjectId, m.UserId }).IsUnique(); + entity.Property(m => m.NovelRole).HasConversion().HasMaxLength(32); + entity.HasIndex(m => new { m.NovelId, m.UserId }).IsUnique(); entity.HasOne(m => m.User).WithMany() .HasForeignKey(m => m.UserId).OnDelete(DeleteBehavior.Cascade); diff --git a/src/Novelly.Api/Users/ProjectMemberContracts.cs b/src/Novelly.Api/Users/NovelMemberContracts.cs similarity index 54% rename from src/Novelly.Api/Users/ProjectMemberContracts.cs rename to src/Novelly.Api/Users/NovelMemberContracts.cs index d8fb1c0..1120801 100644 --- a/src/Novelly.Api/Users/ProjectMemberContracts.cs +++ b/src/Novelly.Api/Users/NovelMemberContracts.cs @@ -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 { @@ -16,8 +16,8 @@ public class GrantAccessRequestValidator : IModelValidator } } -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); } diff --git a/src/Novelly.Api/Users/NovelMemberEndpoints.cs b/src/Novelly.Api/Users/NovelMemberEndpoints.cs new file mode 100644 index 0000000..811d512 --- /dev/null +++ b/src/Novelly.Api/Users/NovelMemberEndpoints.cs @@ -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() + .AddEndpointFilter(); + + 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; + } +} diff --git a/src/Novelly.Api/Users/NovelMemberService.cs b/src/Novelly.Api/Users/NovelMemberService.cs new file mode 100644 index 0000000..a85c424 --- /dev/null +++ b/src/Novelly.Api/Users/NovelMemberService.cs @@ -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 userManager, + INovelUserContext userContext, + ILogger logger, + IModelValidator grantValidator) +{ + public async Task?> 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 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 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; + } +} diff --git a/src/Novelly.Api/Users/ProjectRole.cs b/src/Novelly.Api/Users/NovelRole.cs similarity index 74% rename from src/Novelly.Api/Users/ProjectRole.cs rename to src/Novelly.Api/Users/NovelRole.cs index bafb043..57b9d30 100644 --- a/src/Novelly.Api/Users/ProjectRole.cs +++ b/src/Novelly.Api/Users/NovelRole.cs @@ -1,6 +1,6 @@ namespace Novelly.Api.Users; -public enum ProjectRole +public enum NovelRole { Writer, Editor, diff --git a/src/Novelly.Api/Users/ProjectAccessService.cs b/src/Novelly.Api/Users/ProjectAccessService.cs deleted file mode 100644 index affa89a..0000000 --- a/src/Novelly.Api/Users/ProjectAccessService.cs +++ /dev/null @@ -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 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 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 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 - }; -} diff --git a/src/Novelly.Api/Users/ProjectMemberEndpoints.cs b/src/Novelly.Api/Users/ProjectMemberEndpoints.cs deleted file mode 100644 index 698c233..0000000 --- a/src/Novelly.Api/Users/ProjectMemberEndpoints.cs +++ /dev/null @@ -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() - .AddEndpointFilter(); - - 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; - } -} diff --git a/src/Novelly.Api/Users/ProjectMemberService.cs b/src/Novelly.Api/Users/ProjectMemberService.cs deleted file mode 100644 index 5004bae..0000000 --- a/src/Novelly.Api/Users/ProjectMemberService.cs +++ /dev/null @@ -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 userManager, - INovelUserContext userContext, - ILogger logger, - IModelValidator grantValidator) -{ - public async Task?> 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 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 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; - } -} diff --git a/src/Novelly.Api/Users/UserAccountService.cs b/src/Novelly.Api/Users/UserAccountService.cs index 1dac567..c3928dd 100644 --- a/src/Novelly.Api/Users/UserAccountService.cs +++ b/src/Novelly.Api/Users/UserAccountService.cs @@ -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); diff --git a/src/Novelly.Mcp/Tools/CharacterTools.cs b/src/Novelly.Mcp/Tools/CharacterTools.cs index 96b2eb4..d608e6b 100644 --- a/src/Novelly.Mcp/Tools/CharacterTools.cs +++ b/src/Novelly.Mcp/Tools/CharacterTools.cs @@ -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 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 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 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 LinkCharacterIdentity( diff --git a/src/Novelly.Mcp/Tools/ManuscriptTools.cs b/src/Novelly.Mcp/Tools/ManuscriptTools.cs index 53bd20c..3911d16 100644 --- a/src/Novelly.Mcp/Tools/ManuscriptTools.cs +++ b/src/Novelly.Mcp/Tools/ManuscriptTools.cs @@ -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 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 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, diff --git a/src/Novelly.Mcp/Tools/ProjectTools.cs b/src/Novelly.Mcp/Tools/NovelTools.cs similarity index 54% rename from src/Novelly.Mcp/Tools/ProjectTools.cs rename to src/Novelly.Mcp/Tools/NovelTools.cs index 5bf625c..9d262f5 100644 --- a/src/Novelly.Mcp/Tools/ProjectTools.cs +++ b/src/Novelly.Mcp/Tools/NovelTools.cs @@ -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 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 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 GetProject( + [McpServerTool(Name = "get_novel_brief")] + [Description("Read a novel's title, author, genre, logline, synopsis, notes and word-count target.")] + public static Task 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 CreateProject( + [McpServerTool(Name = "create_novel")] + [Description("Create a new novel.")] + public static Task 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 UpdateProject( + public static Task 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); } diff --git a/src/Novelly.Mcp/Tools/QuestionTools.cs b/src/Novelly.Mcp/Tools/QuestionTools.cs index d77a704..c7927cb 100644 --- a/src/Novelly.Mcp/Tools/QuestionTools.cs +++ b/src/Novelly.Mcp/Tools/QuestionTools.cs @@ -13,7 +13,7 @@ public static class QuestionTools + "thinking, not a gap to fill in for them.")] public static Task 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 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")] diff --git a/src/Novelly.Mcp/Tools/TagTools.cs b/src/Novelly.Mcp/Tools/TagTools.cs index 07a09e8..6d05fcb 100644 --- a/src/Novelly.Mcp/Tools/TagTools.cs +++ b/src/Novelly.Mcp/Tools/TagTools.cs @@ -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 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 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.")] diff --git a/src/Novelly.ServiceDefaults/Extensions.cs b/src/Novelly.ServiceDefaults/Extensions.cs index 18e7da3..48da494 100755 --- a/src/Novelly.ServiceDefaults/Extensions.cs +++ b/src/Novelly.ServiceDefaults/Extensions.cs @@ -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"; diff --git a/src/Novelly.Web/src/App.tsx b/src/Novelly.Web/src/App.tsx index e5e6026..b76d771 100644 --- a/src/Novelly.Web/src/App.tsx +++ b/src/Novelly.Web/src/App.tsx @@ -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() { } /> }> - } /> - }> + } /> + }> } /> } /> } /> @@ -45,7 +45,7 @@ export default function App() { } /> } /> - } /> + } /> diff --git a/src/Novelly.Web/src/api/hooks.ts b/src/Novelly.Web/src/api/hooks.ts index c047da3..64acdf6 100644 --- a/src/Novelly.Web/src/api/hooks.ts +++ b/src/Novelly.Web/src/api/hooks.ts @@ -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(`/api/projects/${projectId}/members`), + queryKey: keys.members(novelId), + queryFn: () => api.get(`/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(`/api/projects/${projectId}/members`, body), - onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(projectId) }), + mutationFn: (body: { email: string; novelRole: NovelRole }) => + api.post(`/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('/api/projects') }) +export const useNovels = () => + useQuery({ queryKey: keys.novels, queryFn: () => api.get('/api/novels') }) -export const useProject = (id: string) => - useQuery({ queryKey: keys.project(id), queryFn: () => api.get(`/api/projects/${id}`) }) +export const useNovel = (id: string) => + useQuery({ queryKey: keys.novel(id), queryFn: () => api.get(`/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('/api/projects', body), - onSuccess: () => qc.invalidateQueries({ queryKey: keys.projects }), + api.post('/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) => api.patch(`/api/projects/${id}`, body), + mutationFn: (body: Partial) => api.patch(`/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(`/api/projects/${projectId}/characters`), + queryKey: keys.characters(novelId), + queryFn: () => api.get(`/api/novels/${novelId}/characters`), }) -export function useCreateCharacter(projectId: string) { +export function useCreateCharacter(novelId: string) { const qc = useQueryClient() return useMutation({ mutationFn: (body: Partial & { name: string }) => - api.post(`/api/projects/${projectId}/characters`, body), + api.post(`/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> & { id: string; tags?: string[] }) => api.patch(`/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(`/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(`/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(`/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(`/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(`/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(`/api/projects/${projectId}/questions${query ? `?${query}` : ''}`), + api.get(`/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(`/api/projects/${projectId}/questions`, body), - onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }), + api.post(`/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(`/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(`/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(`/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('/api/genres') }) -export const useTags = (projectId: string) => +export const useTags = (novelId: string) => useQuery({ - queryKey: keys.tags(projectId), - queryFn: () => api.get(`/api/projects/${projectId}/tags`), + queryKey: keys.tags(novelId), + queryFn: () => api.get(`/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(`/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(`/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(`/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(`/api/projects/${projectId}/chapters`), + queryKey: keys.chapters(novelId), + queryFn: () => api.get(`/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 & { title: string }) => - api.post(`/api/projects/${projectId}/chapters`, body), - onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(projectId) }), + api.post(`/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> & { id: string; tags?: string[] }) => api.patch(`/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(`/api/projects/${projectId}/agent/conversations`), + queryKey: keys.conversations(novelId), + queryFn: () => api.get(`/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(`/api/projects/${projectId}/agent/messages`, body), + api.post(`/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) }) }, }) } diff --git a/src/Novelly.Web/src/api/types.ts b/src/Novelly.Web/src/api/types.ts index 32efb4e..122bebc 100644 --- a/src/Novelly.Web/src/api/types.ts +++ b/src/Novelly.Web/src/api/types.ts @@ -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 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[] diff --git a/src/Novelly.Web/src/auth/AuthContext.tsx b/src/Novelly.Web/src/auth/AuthContext.tsx index 264bdb7..c54b2ff 100644 --- a/src/Novelly.Web/src/auth/AuthContext.tsx +++ b/src/Novelly.Web/src/auth/AuthContext.tsx @@ -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 = { +const novelPermissionsByRole: Record = { Admin: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'], Owner: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'], Writer: ['Write', 'CreateContent', 'DeleteContent'], @@ -15,7 +15,7 @@ const projectPermissionsByRole: Record = { interface AuthValue { user: User | null isPending: boolean - can: (permission: AuthPermission, project?: Pick | null) => boolean + can: (permission: AuthPermission, novel?: Pick | null) => boolean } const AuthContext = createContext({ 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], diff --git a/src/Novelly.Web/src/components/CharacterArc.tsx b/src/Novelly.Web/src/components/CharacterArc.tsx index 1314635..a8e05aa 100644 --- a/src/Novelly.Web/src/components/CharacterArc.tsx +++ b/src/Novelly.Web/src/components/CharacterArc.tsx @@ -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) => ( { if (!beatId) return @@ -182,7 +182,7 @@ function ArcStageRow({ {beat.chapterNumber}.{beat.sortOrder} @@ -235,7 +235,7 @@ function ArcStageRow({ Open outline diff --git a/src/Novelly.Web/src/components/CharacterBeats.tsx b/src/Novelly.Web/src/components/CharacterBeats.tsx index 04ba264..293ba9a 100644 --- a/src/Novelly.Web/src/components/CharacterBeats.tsx +++ b/src/Novelly.Web/src/components/CharacterBeats.tsx @@ -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({ {beat.chapterNumber}.{beat.sortOrder} diff --git a/src/Novelly.Web/src/components/CharacterContextMenu.tsx b/src/Novelly.Web/src/components/CharacterContextMenu.tsx index 78410b4..7c65123 100644 --- a/src/Novelly.Web/src/components/CharacterContextMenu.tsx +++ b/src/Novelly.Web/src/components/CharacterContextMenu.tsx @@ -8,9 +8,9 @@ type MenuState = { onCreated: (characterId: string) => void } -export function useCharacterContextMenu(projectId: string) { +export function useCharacterContextMenu(novelId: string) { const [menu, setMenu] = useState(null) - const createCharacter = useCreateCharacter(projectId) + const createCharacter = useCreateCharacter(novelId) const handleContextMenu = ( e: MouseEvent, diff --git a/src/Novelly.Web/src/components/CharacterMultiSelect.tsx b/src/Novelly.Web/src/components/CharacterMultiSelect.tsx index 0eb309b..26324f2 100644 --- a/src/Novelly.Web/src/components/CharacterMultiSelect.tsx +++ b/src/Novelly.Web/src/components/CharacterMultiSelect.tsx @@ -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 ? ( 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 (
{selected.map((character) => ( - remove(character.id)} /> + remove(character.id)} /> ))} void - onImported?: (projectId: string) => void + onImported?: (novelId: string) => void }) { const [sourceRoot, setSourceRoot] = useState('') const [inspection, setInspection] = useState(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({ ) : (

- 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.

diff --git a/src/Novelly.Web/src/components/OpenQuestions.tsx b/src/Novelly.Web/src/components/OpenQuestions.tsx index 0fdeeb8..be4a672 100644 --- a/src/Novelly.Web/src/components/OpenQuestions.tsx +++ b/src/Novelly.Web/src/components/OpenQuestions.tsx @@ -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) => ( () const { data: conversation } = useConversation(conversationId) - const send = useSendAgentMessage(projectId) + const send = useSendAgentMessage(novelId) const [draft, setDraft] = useState('') const endRef = useRef(null) diff --git a/src/Novelly.Web/src/pages/ChapterPage.tsx b/src/Novelly.Web/src/pages/ChapterPage.tsx index 319d340..94579dd 100644 --- a/src/Novelly.Web/src/pages/ChapterPage.tsx +++ b/src/Novelly.Web/src/pages/ChapterPage.tsx @@ -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('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 (
- + ← All chapters
{prevChapter ? ( @@ -103,7 +103,7 @@ export default function ChapterPage() { )} {nextChapter ? ( @@ -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() { ({ id: c.id, name: c.name })) ?? []} otherChapters={chapters?.filter((c) => c.id !== chapter.id) ?? []} createChapter={createChapter} @@ -278,7 +278,7 @@ export default function ChapterPage() {
@@ -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({ patch(beat.id, { characterIds })} @@ -726,7 +726,7 @@ function BeatTable({ {beat.characters.length > 0 ? (
{beat.characters.map((character) => ( - + ))}
) : ( diff --git a/src/Novelly.Web/src/pages/ChaptersPage.tsx b/src/Novelly.Web/src/pages/ChaptersPage.tsx index 733c7e4..8e8e38a 100644 --- a/src/Novelly.Web/src/pages/ChaptersPage.tsx +++ b/src/Novelly.Web/src/pages/ChaptersPage.tsx @@ -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) => (
  • diff --git a/src/Novelly.Web/src/pages/CharacterDetailPage.tsx b/src/Novelly.Web/src/pages/CharacterDetailPage.tsx index 66b8aad..b6a74fd 100644 --- a/src/Novelly.Web/src/pages/CharacterDetailPage.tsx +++ b/src/Novelly.Web/src/pages/CharacterDetailPage.tsx @@ -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 if (error) return @@ -39,7 +39,7 @@ export default function CharacterDetailPage() { if (!character) { return (
    - + ← All characters @@ -49,13 +49,13 @@ export default function CharacterDetailPage() { return (
    - + ← All characters > & { tags?: string[]; aliases?: string[] }) => update.mutate({ id: character.id, ...body }) @@ -281,7 +281,7 @@ function CharacterSheet({ {(character.importance === 'Main' || character.arcStages.length > 0) && ( - remove.mutate(character.id, { onSuccess: () => navigate(`/projects/${projectId}/characters`) }) + remove.mutate(character.id, { onSuccess: () => navigate(`/novels/${novelId}/characters`) }) } onClose={() => setConfirmingDelete(false)} /> diff --git a/src/Novelly.Web/src/pages/CharactersPage.tsx b/src/Novelly.Web/src/pages/CharactersPage.tsx index 98e7456..94ada54 100644 --- a/src/Novelly.Web/src/pages/CharactersPage.tsx +++ b/src/Novelly.Web/src/pages/CharactersPage.tsx @@ -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 && ( setAdding(false)} - onCreated={(id) => navigate(`/projects/${projectId}/characters/${id}`)} + onCreated={(id) => navigate(`/novels/${novelId}/characters/${id}`)} /> )}
    @@ -115,13 +115,13 @@ export default function CharactersPage() { onSortDir={setSortDir} /> - + {adding && ( setAdding(false)} - onCreated={(id) => navigate(`/projects/${projectId}/characters/${id}`)} + onCreated={(id) => navigate(`/novels/${novelId}/characters/${id}`)} /> )}
    @@ -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 (
    @@ -297,7 +297,7 @@ function CharacterTable({ characters, projectId }: { characters: Character[]; pr
    {character.name}
    @@ -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('Supporting') const [importance, setImportance] = useState('Supporting') diff --git a/src/Novelly.Web/src/pages/DashboardPage.tsx b/src/Novelly.Web/src/pages/DashboardPage.tsx index 0aab407..3ff2787 100644 --- a/src/Novelly.Web/src/pages/DashboardPage.tsx +++ b/src/Novelly.Web/src/pages/DashboardPage.tsx @@ -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 - if (isPending || !project) return + if (isPending || !novel) return - return project.phase === 'Brainstorming' ? ( - + return novel.phase === 'Brainstorming' ? ( + ) : ( - + ) } -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.

    update.mutate({ notes })} - readOnly={!can('Write', project)} + readOnly={!can('Write', novel)} />
    ) } -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 ? ( ) : ( - + )}
  • @@ -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) => ( 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() { ← Novels - - {project?.title ?? '…'} + + {novel?.title ?? '…'}
    - {project && ( + {novel && ( grant.mutate({ email: member.email, projectRole: next })} + value={member.novelRole} + options={novelRoles} + onChange={(next) => grant.mutate({ email: member.email, novelRole: next })} /> diff --git a/src/Novelly.Web/src/pages/TagsPage.tsx b/src/Novelly.Web/src/pages/TagsPage.tsx index d728383..b29ea2b 100644 --- a/src/Novelly.Web/src/pages/TagsPage.tsx +++ b/src/Novelly.Web/src/pages/TagsPage.tsx @@ -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() { ) : ( {data.characters.map((c) => (
  • - + {c.name} — {c.role} @@ -172,7 +172,7 @@ function TagReferencePanel({ {data.chapters.map((c) => (
  • {c.number}. {c.title} @@ -191,7 +191,7 @@ function TagReferencePanel({ {data.beats.map((b) => (
  • {b.title} diff --git a/tests/Novelly.Api.Tests/BeatServiceTests.cs b/tests/Novelly.Api.Tests/BeatServiceTests.cs index cbd0b9f..c03f442 100644 --- a/tests/Novelly.Api.Tests/BeatServiceTests.cs +++ b/tests/Novelly.Api.Tests/BeatServiceTests.cs @@ -1,20 +1,20 @@ using Novelly.Api.Beats; using Novelly.Api.Chapters; using Novelly.Api.Characters; -using Novelly.Api.Projects; +using Novelly.Api.Novels; namespace Novelly.Api.Tests; [TestFixture] public class BeatServiceTests : ServiceTestFixture { - private Guid _projectId; + private Guid _novelId; private Guid _chapterId; protected override void OnSetUp() { - _projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id; - _chapterId = Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")).Result.Id; + _novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id; + _chapterId = Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall")).Result.Id; } [Test] @@ -100,8 +100,8 @@ public class BeatServiceTests : ServiceTestFixture [Test] public async Task A_beat_can_carry_several_characters() { - var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); - var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); + var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); + var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara")); var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest( "She burns the atlas", @@ -117,22 +117,22 @@ public class BeatServiceTests : ServiceTestFixture } [Test] - public async Task A_beat_cannot_borrow_a_character_from_another_project() + public async Task A_beat_cannot_borrow_a_character_from_another_novel() { - var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); + var other = await Novels.CreateAsync(new CreateNovelRequest("Other Book")); var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger")); Assert.That( async () => await Beats.CreateAsync( _chapterId, new CreateBeatRequest("A beat", CharacterIds: [stranger.Id])), - Throws.TypeOf().With.Message.Contains("same project")); + Throws.TypeOf().With.Message.Contains("same novel")); } [Test] public async Task Assigning_a_character_to_several_beats_leaves_their_other_characters_alone() { - var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); - var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); + var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); + var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara")); var first = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First", CharacterIds: [ines.Id])); var second = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Second")); @@ -153,7 +153,7 @@ public class BeatServiceTests : ServiceTestFixture [Test] public async Task Assigning_a_character_already_on_a_beat_does_not_duplicate_it() { - var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); + var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First", CharacterIds: [ines.Id])); var assigned = await Beats.AssignCharacterAsync( @@ -163,9 +163,9 @@ public class BeatServiceTests : ServiceTestFixture } [Test] - public async Task Assigning_a_character_from_another_project_returns_null() + public async Task Assigning_a_character_from_another_novel_returns_null() { - var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); + var other = await Novels.CreateAsync(new CreateNovelRequest("Other Book")); var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger")); var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); @@ -177,7 +177,7 @@ public class BeatServiceTests : ServiceTestFixture [Test] public async Task Assigning_to_an_unknown_beat_returns_null_rather_than_partially_applying() { - var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); + var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); var result = await Beats.AssignCharacterAsync( @@ -190,7 +190,7 @@ public class BeatServiceTests : ServiceTestFixture [Test] public async Task Moving_beats_appends_them_to_the_end_of_the_target_chapter() { - var other = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Second landfall")); + var other = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Second landfall")); await Beats.CreateAsync(other.Id, new CreateBeatRequest("Already there")); var first = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); var second = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Second")); @@ -216,9 +216,9 @@ public class BeatServiceTests : ServiceTestFixture } [Test] - public async Task Moving_to_a_chapter_in_another_project_returns_null() + public async Task Moving_to_a_chapter_in_another_novel_returns_null() { - var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); + var other = await Novels.CreateAsync(new CreateNovelRequest("Other Book")); var otherChapter = await Chapters.CreateAsync(other.Id, new CreateChapterRequest("Elsewhere")); var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); @@ -228,7 +228,7 @@ public class BeatServiceTests : ServiceTestFixture [Test] public async Task Moving_an_unknown_beat_returns_null_rather_than_partially_applying() { - var other = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Second landfall")); + var other = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Second landfall")); var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); var result = await Beats.MoveAsync(_chapterId, new MoveBeatsRequest(other.Id, [beat.Id, Guid.NewGuid()])); @@ -265,7 +265,7 @@ public class BeatServiceTests : ServiceTestFixture [Test] public async Task An_empty_CharacterIds_list_clears_a_beats_characters_since_null_means_leave_it_alone() { - var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); + var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest( "She burns the atlas", CharacterIds: [ines.Id])); diff --git a/tests/Novelly.Api.Tests/ChapterServiceTests.cs b/tests/Novelly.Api.Tests/ChapterServiceTests.cs index 19c3a0f..3e6a923 100644 --- a/tests/Novelly.Api.Tests/ChapterServiceTests.cs +++ b/tests/Novelly.Api.Tests/ChapterServiceTests.cs @@ -2,25 +2,25 @@ using Microsoft.EntityFrameworkCore; using Novelly.Api.Beats; using Novelly.Api.Chapters; using Novelly.Api.Common; -using Novelly.Api.Projects; +using Novelly.Api.Novels; namespace Novelly.Api.Tests; [TestFixture] public class ChapterServiceTests : ServiceTestFixture { - private Guid _projectId; + private Guid _novelId; protected override void OnSetUp() { - _projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id; + _novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id; } [Test] public async Task Setting_a_number_that_already_exists_is_still_stored_as_given() { - var first = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall", Number: 5)); - var second = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("The Harbour", Number: 5)); + var first = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall", Number: 5)); + var second = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("The Harbour", Number: 5)); Assert.Multiple(() => { @@ -32,7 +32,7 @@ public class ChapterServiceTests : ServiceTestFixture [Test] public async Task Updating_leaves_omitted_fields_alone_and_clears_notes_on_empty_string() { - var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest( + var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest( "Landfall", Summary: "The ship makes shore.", Notes: "Check the tide tables.")); var renamed = (await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Title: "First Landfall")))!; @@ -56,7 +56,7 @@ public class ChapterServiceTests : ServiceTestFixture [Test] public async Task Deleting_a_chapter_takes_its_beats_with_it() { - var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); + var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall")); await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("She finds the map")); await Chapters.DeleteAsync(chapter.Id); @@ -66,7 +66,7 @@ public class ChapterServiceTests : ServiceTestFixture } [Test] - public async Task Creating_a_chapter_under_a_missing_project_returns_null_rather_than_throwing() => + public async Task Creating_a_chapter_under_a_missing_novel_returns_null_rather_than_throwing() => Assert.That( await Chapters.CreateAsync(Guid.NewGuid(), new CreateChapterRequest("Landfall")), Is.Null); diff --git a/tests/Novelly.Api.Tests/CharacterArcTests.cs b/tests/Novelly.Api.Tests/CharacterArcTests.cs index 65cc977..45d4669 100644 --- a/tests/Novelly.Api.Tests/CharacterArcTests.cs +++ b/tests/Novelly.Api.Tests/CharacterArcTests.cs @@ -2,21 +2,21 @@ 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.Tests; [TestFixture] public class CharacterArcTests : ServiceTestFixture { - private Guid _projectId; + private Guid _novelId; private Guid _characterId; protected override void OnSetUp() { - _projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id; + _novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id; _characterId = Characters.CreateAsync( - _projectId, + _novelId, new CreateCharacterRequest("Ines", CharacterRole.Protagonist, CharacterImportance.Main)) .Result.Id; } @@ -24,7 +24,7 @@ public class CharacterArcTests : ServiceTestFixture [Test] public async Task A_character_is_supporting_until_promoted() { - var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); + var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara")); Assert.That(mara.Importance, Is.EqualTo(CharacterImportance.Supporting)); @@ -37,7 +37,7 @@ public class CharacterArcTests : ServiceTestFixture [Test] public async Task Importance_is_separate_from_the_part_a_character_plays() { - var mentor = await Characters.CreateAsync(_projectId, new CreateCharacterRequest( + var mentor = await Characters.CreateAsync(_novelId, new CreateCharacterRequest( "Anders", CharacterRole.Mentor, CharacterImportance.Main)); Assert.Multiple(() => @@ -50,11 +50,11 @@ public class CharacterArcTests : ServiceTestFixture [Test] public async Task Main_characters_are_listed_before_supporting_ones() { - await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Zeno")); + await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Zeno")); await Characters.CreateAsync( - _projectId, new CreateCharacterRequest("Mara", Importance: CharacterImportance.Main)); + _novelId, new CreateCharacterRequest("Mara", Importance: CharacterImportance.Main)); - var listed = await Characters.ListAsync(_projectId); + var listed = await Characters.ListAsync(_novelId); Assert.That( listed.Select(c => c.Name), @@ -64,12 +64,12 @@ public class CharacterArcTests : ServiceTestFixture [Test] public async Task Within_a_group_the_lead_comes_before_the_second_lead() { - await Characters.CreateAsync(_projectId, new CreateCharacterRequest( + await Characters.CreateAsync(_novelId, new CreateCharacterRequest( "Mara", CharacterRole.Deuteragonist, CharacterImportance.Main)); - await Characters.CreateAsync(_projectId, new CreateCharacterRequest( + await Characters.CreateAsync(_novelId, new CreateCharacterRequest( "Anders", CharacterRole.Antagonist, CharacterImportance.Main)); - var listed = await Characters.ListAsync(_projectId); + var listed = await Characters.ListAsync(_novelId); Assert.That( listed.Select(c => c.Name), @@ -127,7 +127,7 @@ public class CharacterArcTests : ServiceTestFixture [Test] public async Task A_stage_pinned_to_a_chapter_resolves_that_chapter() { - var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); + var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall")); var stage = await Arcs.CreateAsync( _characterId, new CreateArcStageRequest("The map is wrong", ChapterId: chapter.Id)); @@ -140,21 +140,21 @@ public class CharacterArcTests : ServiceTestFixture } [Test] - public async Task A_stage_cannot_be_pinned_to_a_chapter_from_another_project() + public async Task A_stage_cannot_be_pinned_to_a_chapter_from_another_novel() { - var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); + var other = await Novels.CreateAsync(new CreateNovelRequest("Other Book")); var elsewhere = await Chapters.CreateAsync(other.Id, new CreateChapterRequest("Elsewhere")); Assert.That( async () => await Arcs.CreateAsync( _characterId, new CreateArcStageRequest("A stage", ChapterId: elsewhere.Id)), - Throws.TypeOf().With.Message.Contains("same project")); + Throws.TypeOf().With.Message.Contains("same novel")); } [Test] public async Task Deleting_a_chapter_unpins_an_arc_stage_rather_than_deleting_it() { - var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); + var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall")); var stage = await Arcs.CreateAsync( _characterId, new CreateArcStageRequest("The map is wrong", ChapterId: chapter.Id)); @@ -188,9 +188,9 @@ public class CharacterArcTests : ServiceTestFixture [Test] public async Task The_character_page_sees_every_beat_they_appear_in_across_the_book() { - var second = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Second", Number: 2)); - var first = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("First", Number: 1)); - var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); + var second = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Second", Number: 2)); + var first = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("First", Number: 1)); + var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara")); await Beats.CreateAsync(second.Id, new CreateBeatRequest("She boards anyway", CharacterIds: [_characterId])); await Beats.CreateAsync(first.Id, new CreateBeatRequest("She finds the map", CharacterIds: [_characterId])); @@ -215,7 +215,7 @@ public class CharacterArcTests : ServiceTestFixture [Test] public async Task An_arc_stage_groups_the_beats_assigned_to_it() { - var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); + var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall")); var spoiled = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Snaps at the crew", CharacterIds: [_characterId])); var humbled = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Learns to swab a deck", CharacterIds: [_characterId])); var stage = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble")); @@ -229,7 +229,7 @@ public class CharacterArcTests : ServiceTestFixture [Test] public async Task Assigning_a_beat_to_a_stage_moves_it_out_of_the_characters_other_stage() { - var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); + var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall")); var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Gets hurt", CharacterIds: [_characterId])); var early = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble")); var later = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Humbled")); @@ -250,8 +250,8 @@ public class CharacterArcTests : ServiceTestFixture [Test] public async Task A_beat_can_only_be_grouped_into_a_stage_for_a_character_who_appears_in_it() { - var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); - var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); + var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall")); + var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara")); var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Mara alone", CharacterIds: [mara.Id])); var stage = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble")); @@ -269,7 +269,7 @@ public class CharacterArcTests : ServiceTestFixture [Test] public async Task Clearing_a_stages_beats_with_an_empty_list_ungroups_them() { - var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); + var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall")); var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Snaps at the crew", CharacterIds: [_characterId])); var stage = await Arcs.CreateAsync(_characterId, new CreateArcStageRequest("Spoiled noble")); await Arcs.SetBeatsAsync(stage.Id, new SetArcStageBeatsRequest([beat!.Id])); diff --git a/tests/Novelly.Api.Tests/CharacterServiceTests.cs b/tests/Novelly.Api.Tests/CharacterServiceTests.cs index 4326329..eb925c1 100644 --- a/tests/Novelly.Api.Tests/CharacterServiceTests.cs +++ b/tests/Novelly.Api.Tests/CharacterServiceTests.cs @@ -2,24 +2,24 @@ using Microsoft.EntityFrameworkCore; using Novelly.Api.Beats; using Novelly.Api.Chapters; using Novelly.Api.Characters; -using Novelly.Api.Projects; +using Novelly.Api.Novels; namespace Novelly.Api.Tests; [TestFixture] public class CharacterServiceTests : ServiceTestFixture { - private Guid _projectId; + private Guid _novelId; protected override void OnSetUp() { - _projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id; + _novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id; } [Test] public async Task New_characters_default_to_supporting_role_and_importance() { - var character = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); + var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); Assert.Multiple(() => { @@ -31,7 +31,7 @@ public class CharacterServiceTests : ServiceTestFixture [Test] public async Task Promoting_a_character_to_main_sticks_until_changed_again() { - var character = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); + var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); var promoted = (await Characters.UpdateAsync( character.Id, new UpdateCharacterRequest(Importance: CharacterImportance.Main)))!; @@ -47,7 +47,7 @@ public class CharacterServiceTests : ServiceTestFixture [Test] public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string() { - var character = await Characters.CreateAsync(_projectId, new CreateCharacterRequest( + var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest( "Ines", Want: "To find her sister.", Need: "To let go of the guilt.")); var renamed = (await Characters.UpdateAsync(character.Id, new UpdateCharacterRequest(Name: "Ines Vell")))!; @@ -69,23 +69,23 @@ public class CharacterServiceTests : ServiceTestFixture } [Test] - public async Task Relating_characters_across_projects_is_refused() + public async Task Relating_characters_across_novels_is_refused() { - var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); - var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); + var other = await Novels.CreateAsync(new CreateNovelRequest("Other Book")); + var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger")); Assert.That( async () => await Characters.AddRelationshipAsync( ines.Id, new CreateRelationshipRequest(stranger.Id, "sister")), - Throws.TypeOf().With.Message.Contains("same project")); + Throws.TypeOf().With.Message.Contains("same novel")); } [Test] public async Task Removing_a_relationship_leaves_both_characters_in_place() { - var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); - var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); + var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); + var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara")); var withRelationship = (await Characters.AddRelationshipAsync( ines.Id, new CreateRelationshipRequest(mara.Id, "sister")))!; @@ -104,8 +104,8 @@ public class CharacterServiceTests : ServiceTestFixture [Test] public async Task Adding_a_relationship_records_it_on_both_characters() { - var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); - var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); + var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); + var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara")); await Characters.AddRelationshipAsync( ines.Id, new CreateRelationshipRequest(mara.Id, "sister", ReciprocalRelationshipType: "brother")); @@ -127,8 +127,8 @@ public class CharacterServiceTests : ServiceTestFixture [Test] public async Task A_relationship_with_no_reciprocal_type_mirrors_the_same_type_both_ways() { - var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); - var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); + var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); + var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara")); await Characters.AddRelationshipAsync(ines.Id, new CreateRelationshipRequest(mara.Id, "rival")); @@ -140,8 +140,8 @@ public class CharacterServiceTests : ServiceTestFixture [Test] public async Task Removing_a_relationship_removes_the_reciprocal_side_too() { - var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); - var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); + var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); + var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara")); var withRelationship = (await Characters.AddRelationshipAsync( ines.Id, new CreateRelationshipRequest(mara.Id, "sister", ReciprocalRelationshipType: "brother")))!; @@ -156,8 +156,8 @@ public class CharacterServiceTests : ServiceTestFixture [Test] public async Task Deleting_a_character_detaches_it_from_beats_rather_than_deleting_them() { - var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); - var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); + var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall")); + var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")); var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("She finds the map", CharacterIds: [ines.Id])); await Characters.DeleteAsync(ines.Id); @@ -168,7 +168,7 @@ public class CharacterServiceTests : ServiceTestFixture } [Test] - public async Task Creating_a_character_under_a_missing_project_returns_null_rather_than_throwing() => + public async Task Creating_a_character_under_a_missing_novel_returns_null_rather_than_throwing() => Assert.That( await Characters.CreateAsync(Guid.NewGuid(), new CreateCharacterRequest("Ines")), Is.Null); @@ -181,7 +181,7 @@ public class CharacterServiceTests : ServiceTestFixture public async Task Aliases_round_trip_on_create_and_update() { var created = await Characters.CreateAsync( - _projectId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man", "Kael"])); + _novelId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man", "Kael"])); Assert.That(created.Aliases, Is.EqualTo(new[] { "The Grey Man", "Kael" })); @@ -195,7 +195,7 @@ public class CharacterServiceTests : ServiceTestFixture public async Task Clearing_aliases_with_an_empty_list_empties_them() { var created = await Characters.CreateAsync( - _projectId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man"])); + _novelId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man"])); var cleared = (await Characters.UpdateAsync(created.Id, new UpdateCharacterRequest(Aliases: [])))!; @@ -205,8 +205,8 @@ public class CharacterServiceTests : ServiceTestFixture [Test] public async Task Linking_a_character_to_its_true_identity_records_it_on_both_sides() { - var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael")); - var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger")); + var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael")); + var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger")); var linked = (await Characters.LinkIdentityAsync( stranger.Id, new LinkCharacterIdentityRequest(kael.Id, Note: "Same man, after the exile.")))!; @@ -225,9 +225,9 @@ public class CharacterServiceTests : ServiceTestFixture [Test] public async Task Linking_to_a_character_that_is_itself_an_alias_flattens_to_the_canonical() { - var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael")); - var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger")); - var exile = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Exile")); + var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael")); + var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger")); + var exile = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Exile")); await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id)); var linked = (await Characters.LinkIdentityAsync(exile.Id, new LinkCharacterIdentityRequest(stranger.Id)))!; @@ -238,7 +238,7 @@ public class CharacterServiceTests : ServiceTestFixture [Test] public async Task Linking_a_character_to_itself_is_rejected() { - var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael")); + var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael")); Assert.That( async () => await Characters.LinkIdentityAsync(kael.Id, new LinkCharacterIdentityRequest(kael.Id)), @@ -246,22 +246,22 @@ public class CharacterServiceTests : ServiceTestFixture } [Test] - public async Task Linking_identities_across_projects_is_refused() + public async Task Linking_identities_across_novels_is_refused() { - var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); - var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael")); + var other = await Novels.CreateAsync(new CreateNovelRequest("Other Book")); + var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael")); var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger")); Assert.That( async () => await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id)), - Throws.TypeOf().With.Message.Contains("same project")); + Throws.TypeOf().With.Message.Contains("same novel")); } [Test] public async Task Deleting_the_canonical_character_leaves_its_other_identities_alive() { - var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael")); - var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger")); + var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael")); + var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger")); await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id)); await Characters.DeleteAsync(kael.Id); @@ -274,9 +274,9 @@ public class CharacterServiceTests : ServiceTestFixture [Test] public async Task Unlinking_an_identity_clears_the_reveal_chapter_and_note() { - var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("The Reveal")); - var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael")); - var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger")); + var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("The Reveal")); + var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael")); + var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger")); await Characters.LinkIdentityAsync( stranger.Id, new LinkCharacterIdentityRequest(kael.Id, chapter.Id, "Same man.")); diff --git a/tests/Novelly.Api.Tests/ExceptionHandlingTests.cs b/tests/Novelly.Api.Tests/ExceptionHandlingTests.cs index 237cc7c..0a512ae 100644 --- a/tests/Novelly.Api.Tests/ExceptionHandlingTests.cs +++ b/tests/Novelly.Api.Tests/ExceptionHandlingTests.cs @@ -1,7 +1,7 @@ using Novelly.Api.Chapters; using Novelly.Api.Common; using Novelly.Api.Common.Validation; -using Novelly.Api.Projects; +using Novelly.Api.Novels; namespace Novelly.Api.Tests; @@ -10,22 +10,22 @@ public class ExceptionHandlingTests : ServiceTestFixture { [Test] public void Guard_rejects_an_empty_guid_passed_as_a_required_id() => - Assert.That(() => Projects.GetAsync(Guid.Empty), Throws.TypeOf()); + Assert.That(() => Novels.GetAsync(Guid.Empty), Throws.TypeOf()); [Test] public void Guard_rejects_a_null_request_object() => Assert.That( - () => Projects.CreateAsync(null!), + () => Novels.CreateAsync(null!), Throws.TypeOf()); [Test] - public async Task Deleting_a_missing_project_returns_false_rather_than_throwing() => - Assert.That(await Projects.DeleteAsync(Guid.NewGuid()), Is.False); + public async Task Deleting_a_missing_novel_returns_false_rather_than_throwing() => + Assert.That(await Novels.DeleteAsync(Guid.NewGuid()), Is.False); [Test] - public void A_blank_title_fails_the_create_project_validator() + public void A_blank_title_fails_the_create_novel_validator() { - var result = new CreateProjectRequestValidator().Validate(new CreateProjectRequest("")); + var result = new CreateNovelRequestValidator().Validate(new CreateNovelRequest("")); Assert.Multiple(() => { @@ -37,10 +37,10 @@ public class ExceptionHandlingTests : ServiceTestFixture [Test] public void Calling_a_service_directly_with_an_invalid_request_throws_rather_than_silently_accepting_it() => Assert.That( - () => Projects.CreateAsync(new CreateProjectRequest("")), + () => Novels.CreateAsync(new CreateNovelRequest("")), Throws.TypeOf()); [Test] - public async Task Creating_a_chapter_under_a_missing_project_returns_null_rather_than_throwing() => + public async Task Creating_a_chapter_under_a_missing_novel_returns_null_rather_than_throwing() => Assert.That(await Chapters.CreateAsync(Guid.NewGuid(), new CreateChapterRequest("Landfall")), Is.Null); } diff --git a/tests/Novelly.Api.Tests/GenreServiceTests.cs b/tests/Novelly.Api.Tests/GenreServiceTests.cs index cf23065..5287491 100644 --- a/tests/Novelly.Api.Tests/GenreServiceTests.cs +++ b/tests/Novelly.Api.Tests/GenreServiceTests.cs @@ -1,4 +1,4 @@ -using Novelly.Api.Projects; +using Novelly.Api.Novels; namespace Novelly.Api.Tests; @@ -20,24 +20,24 @@ public class GenreServiceTests : ServiceTestFixture } [Test] - public async Task A_project_can_be_filed_under_a_genre_off_the_list() + public async Task A_novel_can_be_filed_under_a_genre_off_the_list() { var fantasy = (await Genres.ListAsync()).First(g => g.Name == "Fantasy"); - var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road", Genre: fantasy.Name)); + var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road", Genre: fantasy.Name)); - Assert.That(project.Genre, Is.EqualTo("Fantasy")); + Assert.That(novel.Genre, Is.EqualTo("Fantasy")); } [Test] - public async Task A_project_can_still_carry_a_genre_that_is_not_on_the_list() + public async Task A_novel_can_still_carry_a_genre_that_is_not_on_the_list() { - var project = await Projects.CreateAsync( - new CreateProjectRequest("The Salt Road", Genre: "Nautical Gothic")); + var novel = await Novels.CreateAsync( + new CreateNovelRequest("The Salt Road", Genre: "Nautical Gothic")); Assert.Multiple(async () => { - Assert.That(project.Genre, Is.EqualTo("Nautical Gothic")); + Assert.That(novel.Genre, Is.EqualTo("Nautical Gothic")); Assert.That((await Genres.ListAsync()).Select(g => g.Name), Has.No.Member("Nautical Gothic")); }); } diff --git a/tests/Novelly.Api.Tests/ImportAgentToolsetTests.cs b/tests/Novelly.Api.Tests/ImportAgentToolsetTests.cs index 9e8fa4c..a610929 100644 --- a/tests/Novelly.Api.Tests/ImportAgentToolsetTests.cs +++ b/tests/Novelly.Api.Tests/ImportAgentToolsetTests.cs @@ -13,8 +13,8 @@ public class ImportAgentToolsetTests : ServiceTestFixture { _root = Directory.CreateTempSubdirectory("novelly-import-toolset-test-").FullName; _toolset = new ImportAgentToolset( - Projects, Characters, Arcs, Chapters, Beats, new CapturingLogger()); - _toolset.Initialize(_root, existingProjectId: null); + Novels, Characters, Arcs, Chapters, Beats, new CapturingLogger()); + _toolset.Initialize(_root, existingNovelId: null); } [TearDown] @@ -64,7 +64,7 @@ public class ImportAgentToolsetTests : ServiceTestFixture [Test] public async Task Write_ledger_can_only_ever_touch_the_ledger_file_no_matter_what_path_is_asked_for() { - await _toolset.ExecuteAsync("write_ledger", Input(new { json = """{"completedPasses": ["project"]}""" })); + await _toolset.ExecuteAsync("write_ledger", Input(new { json = """{"completedPasses": ["novel"]}""" })); Assert.Multiple(() => { @@ -86,25 +86,25 @@ public class ImportAgentToolsetTests : ServiceTestFixture } [Test] - public async Task Create_project_binds_the_toolsets_project_id_for_later_calls() + public async Task Create_novel_binds_the_toolsets_novel_id_for_later_calls() { - await _toolset.ExecuteAsync("create_project", Input(new { title = "The Blade Itself", author = "Joe Abercrombie" })); + await _toolset.ExecuteAsync("create_novel", Input(new { title = "The Blade Itself", author = "Joe Abercrombie" })); - Assert.That(_toolset.ProjectId, Is.Not.Null); + Assert.That(_toolset.NovelId, Is.Not.Null); - var project = await Projects.GetAsync(_toolset.ProjectId!.Value); - Assert.That(project!.Title, Is.EqualTo("The Blade Itself")); + var novel = await Novels.GetAsync(_toolset.NovelId!.Value); + Assert.That(novel!.Title, Is.EqualTo("The Blade Itself")); } [Test] - public async Task Domain_tools_refuse_to_run_before_a_project_exists() + public async Task Domain_tools_refuse_to_run_before_a_novel_exists() { var result = await _toolset.ExecuteAsync("create_character", Input(new { name = "Logen" })); Assert.Multiple(() => { Assert.That(result.IsError, Is.True); - Assert.That(result.Content, Does.Contain("create_project first")); + Assert.That(result.Content, Does.Contain("create_novel first")); }); } diff --git a/tests/Novelly.Api.Tests/ImportServiceTests.cs b/tests/Novelly.Api.Tests/ImportServiceTests.cs index 8396614..154f8b3 100644 --- a/tests/Novelly.Api.Tests/ImportServiceTests.cs +++ b/tests/Novelly.Api.Tests/ImportServiceTests.cs @@ -1,6 +1,6 @@ using System.Threading.Channels; using Novelly.Api.Imports; -using Novelly.Api.Projects; +using Novelly.Api.Novels; namespace Novelly.Api.Tests; @@ -17,7 +17,7 @@ public class ImportServiceTests : ServiceTestFixture _queue = Channel.CreateUnbounded(); _imports = new ImportService( Db.Context, - Projects, + Novels, _queue, UserContext, new CapturingLogger(), @@ -44,7 +44,7 @@ public class ImportServiceTests : ServiceTestFixture Assert.Multiple(() => { Assert.That(inspection.Readiness, Is.EqualTo(ImportReadiness.Fresh)); - Assert.That(inspection.ProjectId, Is.Null); + Assert.That(inspection.NovelId, Is.Null); Assert.That(inspection.ChaptersTotal, Is.EqualTo(3)); Assert.That(inspection.ChaptersCompleted, Is.EqualTo(0)); }); @@ -54,7 +54,7 @@ public class ImportServiceTests : ServiceTestFixture public async Task Inspecting_a_folder_with_an_incomplete_ledger_reports_resumable() { WriteChapterFiles(3); - WriteLedger("""{"projectId": "11111111-1111-1111-1111-111111111111", "completedPasses": ["project"], "completedChapters": [1]}"""); + WriteLedger("""{"novelId": "11111111-1111-1111-1111-111111111111", "completedPasses": ["novel"], "completedChapters": [1]}"""); var inspection = await _imports.InspectAsync(new InspectImportRequest(_root)); @@ -72,8 +72,8 @@ public class ImportServiceTests : ServiceTestFixture WriteChapterFiles(2); WriteLedger(""" { - "projectId": "11111111-1111-1111-1111-111111111111", - "completedPasses": ["project", "characters", "chapters", "arcs"], + "novelId": "11111111-1111-1111-1111-111111111111", + "completedPasses": ["novel", "characters", "chapters", "arcs"], "completedChapters": [1, 2] } """); @@ -117,17 +117,17 @@ public class ImportServiceTests : ServiceTestFixture } [Test] - public async Task Force_restarting_a_completed_import_deletes_the_ledger_and_its_project() + public async Task Force_restarting_a_completed_import_deletes_the_ledger_and_its_novel() { - var project = await Projects.CreateAsync(new CreateProjectRequest("The Blade Itself")); - WriteLedger($$"""{"projectId": "{{project.Id}}", "completedPasses": ["project", "characters", "chapters", "arcs"], "completedChapters": [1]}"""); + var novel = await Novels.CreateAsync(new CreateNovelRequest("The Blade Itself")); + WriteLedger($$"""{"novelId": "{{novel.Id}}", "completedPasses": ["novel", "characters", "chapters", "arcs"], "completedChapters": [1]}"""); await _imports.StartOrResumeAsync(new StartImportRequest(_root, ForceRestart: true)); Assert.Multiple(() => { Assert.That(File.Exists(Path.Combine(_root, ".novelly-import.json")), Is.False); - Assert.That(Projects.GetAsync(project.Id).Result, Is.Null); + Assert.That(Novels.GetAsync(novel.Id).Result, Is.Null); }); } diff --git a/tests/Novelly.Api.Tests/ListingTests.cs b/tests/Novelly.Api.Tests/ListingTests.cs index 59401fa..0241d54 100644 --- a/tests/Novelly.Api.Tests/ListingTests.cs +++ b/tests/Novelly.Api.Tests/ListingTests.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.Options; using Novelly.Api.Agent; using Novelly.Api.Chapters; using Novelly.Api.Characters; -using Novelly.Api.Projects; +using Novelly.Api.Novels; namespace Novelly.Api.Tests; @@ -11,14 +11,14 @@ namespace Novelly.Api.Tests; public class ListingTests : ServiceTestFixture { [Test] - public async Task Projects_are_listed_most_recently_updated_first() + public async Task Novels_are_listed_most_recently_updated_first() { - var older = await Projects.CreateAsync(new CreateProjectRequest("Older Book")); - var newer = await Projects.CreateAsync(new CreateProjectRequest("Newer Book")); + var older = await Novels.CreateAsync(new CreateNovelRequest("Older Book")); + var newer = await Novels.CreateAsync(new CreateNovelRequest("Newer Book")); - await Projects.UpdateAsync(older.Id, new UpdateProjectRequest(Logline: "Revised.")); + await Novels.UpdateAsync(older.Id, new UpdateNovelRequest(Logline: "Revised.")); - var listed = await Projects.ListAsync(); + var listed = await Novels.ListAsync(); Assert.Multiple(() => { @@ -28,16 +28,16 @@ public class ListingTests : ServiceTestFixture } [Test] - public async Task Project_summaries_aggregate_counts_and_words_across_chapters() + public async Task Novel_summaries_aggregate_counts_and_words_across_chapters() { - var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); - await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines")); - await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Mara")); + var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road")); + await Characters.CreateAsync(novel.Id, new CreateCharacterRequest("Ines")); + await Characters.CreateAsync(novel.Id, new CreateCharacterRequest("Mara")); - await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall", Prose: "One two three")); - await Chapters.CreateAsync(project.Id, new CreateChapterRequest("The Harbour", Prose: "Four five")); + await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Landfall", Prose: "One two three")); + await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("The Harbour", Prose: "Four five")); - var summary = (await Projects.ListAsync()).Single(); + var summary = (await Novels.ListAsync()).Single(); Assert.Multiple(() => { @@ -48,11 +48,11 @@ public class ListingTests : ServiceTestFixture } [Test] - public async Task A_project_with_no_chapters_reports_zero_words_rather_than_failing() + public async Task A_novel_with_no_chapters_reports_zero_words_rather_than_failing() { - await Projects.CreateAsync(new CreateProjectRequest("Empty")); + await Novels.CreateAsync(new CreateNovelRequest("Empty")); - var summary = (await Projects.ListAsync()).Single(); + var summary = (await Novels.ListAsync()).Single(); Assert.Multiple(() => { @@ -64,11 +64,11 @@ public class ListingTests : ServiceTestFixture [Test] public async Task Chapters_are_listed_in_manuscript_order_with_word_counts() { - var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); - var second = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Second", Number: 2, Prose: "One two three")); - var first = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("First", Number: 1)); + var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road")); + var second = await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Second", Number: 2, Prose: "One two three")); + var first = await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("First", Number: 1)); - var listed = await Chapters.ListAsync(project.Id); + var listed = await Chapters.ListAsync(novel.Id); Assert.Multiple(() => { @@ -81,19 +81,19 @@ public class ListingTests : ServiceTestFixture [Test] public async Task Conversations_are_listed_most_recently_updated_first() { - var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); + var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road")); var agent = new NovelAgentService( Db.Context, new ScriptedModelClient([[new AgentTextBlock("Reply.")]]), - new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger.Instance), + new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger.Instance), Options.Create(new AgentOptions()), NullLogger.Instance, new SendAgentMessageRequestValidator()); - await agent.SendMessageAsync(project.Id, new SendAgentMessageRequest("First question.")); - await agent.SendMessageAsync(project.Id, new SendAgentMessageRequest("Second question.")); + await agent.SendMessageAsync(novel.Id, new SendAgentMessageRequest("First question.")); + await agent.SendMessageAsync(novel.Id, new SendAgentMessageRequest("Second question.")); - var listed = await agent.ListConversationsAsync(project.Id); + var listed = await agent.ListConversationsAsync(novel.Id); Assert.Multiple(() => { @@ -106,12 +106,12 @@ public class ListingTests : ServiceTestFixture [Test] public async Task Characters_are_listed_by_role_then_name() { - var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); - await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Zeno", CharacterRole.Supporting)); - await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines", CharacterRole.Protagonist)); - await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Anders", CharacterRole.Supporting)); + var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road")); + await Characters.CreateAsync(novel.Id, new CreateCharacterRequest("Zeno", CharacterRole.Supporting)); + await Characters.CreateAsync(novel.Id, new CreateCharacterRequest("Ines", CharacterRole.Protagonist)); + await Characters.CreateAsync(novel.Id, new CreateCharacterRequest("Anders", CharacterRole.Supporting)); - var listed = await Characters.ListAsync(project.Id); + var listed = await Characters.ListAsync(novel.Id); Assert.That(listed.Select(c => c.Name), Is.EqualTo(new[] { "Ines", "Anders", "Zeno" })); } diff --git a/tests/Novelly.Api.Tests/LoggingTests.cs b/tests/Novelly.Api.Tests/LoggingTests.cs index bfa9404..0b14209 100644 --- a/tests/Novelly.Api.Tests/LoggingTests.cs +++ b/tests/Novelly.Api.Tests/LoggingTests.cs @@ -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; namespace Novelly.Api.Tests; @@ -27,53 +27,53 @@ public class LoggingTests : ServiceTestFixture } [Test] - public async Task Creating_a_chapter_logs_the_project_and_title_at_information() + public async Task Creating_a_chapter_logs_the_novel_and_title_at_information() { - var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); + var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road")); ChapterLogs.Entries.Clear(); - await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall")); + await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Landfall")); var info = ChapterLogs.Entries.Single(e => e.Level == LogLevel.Information); Assert.Multiple(() => { Assert.That(info.Message, Does.Contain("Landfall")); - Assert.That(info.Message, Does.Contain(project.Id.ToString())); + Assert.That(info.Message, Does.Contain(novel.Id.ToString())); }); } [Test] public async Task Logged_values_never_include_a_chapter_summary_body() { - var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); + var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road")); const string secretSummary = "A very specific plot twist nobody should see in a log line."; ChapterLogs.Entries.Clear(); - await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall", Summary: secretSummary)); + await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Landfall", Summary: secretSummary)); Assert.That(ChapterLogs.Entries.Select(e => e.Message), Has.None.Contain(secretSummary)); } [Test] - public async Task Deleting_a_project_logs_information_before_the_lookup() + public async Task Deleting_a_novel_logs_information_before_the_lookup() { - var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); - ProjectLogs.Entries.Clear(); + var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road")); + NovelLogs.Entries.Clear(); - await Projects.DeleteAsync(project.Id); + await Novels.DeleteAsync(novel.Id); Assert.That( - ProjectLogs.Entries, - Has.Some.Matches(e => e.Level == LogLevel.Information && e.Message.Contains(project.Id.ToString()))); + NovelLogs.Entries, + Has.Some.Matches(e => e.Level == LogLevel.Information && e.Message.Contains(novel.Id.ToString()))); } [Test] public async Task Rejecting_a_beat_with_a_foreign_character_logs_a_warning_not_an_error() { - var projectA = await Projects.CreateAsync(new CreateProjectRequest("Project A")); - var projectB = await Projects.CreateAsync(new CreateProjectRequest("Project B")); - var chapter = await Chapters.CreateAsync(projectA.Id, new CreateChapterRequest("Landfall")); - var foreignCharacter = await Characters.CreateAsync(projectB.Id, new CreateCharacterRequest("Ines")); + var novelA = await Novels.CreateAsync(new CreateNovelRequest("Novel A")); + var novelB = await Novels.CreateAsync(new CreateNovelRequest("Novel B")); + var chapter = await Chapters.CreateAsync(novelA.Id, new CreateChapterRequest("Landfall")); + var foreignCharacter = await Characters.CreateAsync(novelB.Id, new CreateCharacterRequest("Ines")); BeatLogs.Entries.Clear(); Assert.That( diff --git a/tests/Novelly.Api.Tests/ProjectAccessTests.cs b/tests/Novelly.Api.Tests/NovelAccessTests.cs similarity index 58% rename from tests/Novelly.Api.Tests/ProjectAccessTests.cs rename to tests/Novelly.Api.Tests/NovelAccessTests.cs index a75276c..3c7822b 100644 --- a/tests/Novelly.Api.Tests/ProjectAccessTests.cs +++ b/tests/Novelly.Api.Tests/NovelAccessTests.cs @@ -1,13 +1,13 @@ using Microsoft.EntityFrameworkCore; using Novelly.Api.Chapters; using Novelly.Api.Common; -using Novelly.Api.Projects; +using Novelly.Api.Novels; using Novelly.Api.Users; namespace Novelly.Api.Tests; [TestFixture] -public class ProjectAccessTests : ServiceTestFixture +public class NovelAccessTests : ServiceTestFixture { private Guid AsNewUser(GlobalRole globalRole) { @@ -27,9 +27,9 @@ public class ProjectAccessTests : ServiceTestFixture return user.Id; } - private void GrantProjectRole(Guid projectId, Guid userId, ProjectRole role) + private void GrantNovelRole(Guid novelId, Guid userId, NovelRole role) { - Db.Context.ProjectMembers.Add(new ProjectMember { ProjectId = projectId, UserId = userId, ProjectRole = role, GrantedByUserId = userId }); + Db.Context.NovelMembers.Add(new NovelMember { NovelId = novelId, UserId = userId, NovelRole = role, GrantedByUserId = userId }); Db.Context.SaveChanges(); } @@ -43,31 +43,31 @@ public class ProjectAccessTests : ServiceTestFixture public async Task A_writer_sees_only_novels_they_own_or_have_been_granted() { var writerId = AsNewUser(GlobalRole.Writer); - var ownedProject = await Projects.CreateAsync(new CreateProjectRequest("Owned by writer")); + var ownedNovel = await Novels.CreateAsync(new CreateNovelRequest("Owned by writer")); AsAdmin(); - var otherProject = await Projects.CreateAsync(new CreateProjectRequest("Owned by someone else")); + var otherNovel = await Novels.CreateAsync(new CreateNovelRequest("Owned by someone else")); UserContext.UserId = writerId; UserContext.GlobalRole = GlobalRole.Writer; - var visibleBeforeGrant = await Projects.ListAsync(); - Assert.That(visibleBeforeGrant.Select(p => p.Id), Is.EquivalentTo(new[] { ownedProject.Id })); + var visibleBeforeGrant = await Novels.ListAsync(); + Assert.That(visibleBeforeGrant.Select(p => p.Id), Is.EquivalentTo(new[] { ownedNovel.Id })); - GrantProjectRole(otherProject.Id, writerId, ProjectRole.Reviewer); + GrantNovelRole(otherNovel.Id, writerId, NovelRole.Reviewer); - var visibleAfterGrant = await Projects.ListAsync(); - Assert.That(visibleAfterGrant.Select(p => p.Id), Is.EquivalentTo(new[] { ownedProject.Id, otherProject.Id })); + var visibleAfterGrant = await Novels.ListAsync(); + Assert.That(visibleAfterGrant.Select(p => p.Id), Is.EquivalentTo(new[] { ownedNovel.Id, otherNovel.Id })); } [Test] public async Task An_editor_can_rewrite_a_chapter_but_cannot_delete_it() { - var project = await Projects.CreateAsync(new CreateProjectRequest("Editable Novel")); - var chapter = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Chapter One")); + var novel = await Novels.CreateAsync(new CreateNovelRequest("Editable Novel")); + var chapter = await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Chapter One")); var editorId = AsNewUser(GlobalRole.Reviewer); - GrantProjectRole(project.Id, editorId, ProjectRole.Editor); + GrantNovelRole(novel.Id, editorId, NovelRole.Editor); var updated = await Chapters.UpdateAsync(chapter!.Id, new UpdateChapterRequest(Title: "Renamed")); Assert.That(updated!.Title, Is.EqualTo("Renamed")); @@ -80,17 +80,17 @@ public class ProjectAccessTests : ServiceTestFixture { AsNewUser(GlobalRole.Editor); - Assert.That(() => Projects.CreateAsync(new CreateProjectRequest("Should not exist")), Throws.TypeOf()); + Assert.That(() => Novels.CreateAsync(new CreateNovelRequest("Should not exist")), Throws.TypeOf()); } [Test] public async Task A_reviewer_can_read_a_chapter_but_not_change_it() { - var project = await Projects.CreateAsync(new CreateProjectRequest("Reviewed Novel")); - var chapter = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Chapter One")); + var novel = await Novels.CreateAsync(new CreateNovelRequest("Reviewed Novel")); + var chapter = await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Chapter One")); var reviewerId = AsNewUser(GlobalRole.Reviewer); - GrantProjectRole(project.Id, reviewerId, ProjectRole.Reviewer); + GrantNovelRole(novel.Id, reviewerId, NovelRole.Reviewer); var read = await Chapters.GetAsync(chapter!.Id); Assert.That(read, Is.Not.Null); @@ -103,13 +103,13 @@ public class ProjectAccessTests : ServiceTestFixture [Test] public async Task A_writer_granted_access_to_someone_elses_novel_still_cannot_grant_access_to_others() { - var project = await Projects.CreateAsync(new CreateProjectRequest("Someone Else's Novel")); + var novel = await Novels.CreateAsync(new CreateNovelRequest("Someone Else's Novel")); var grantedWriterId = AsNewUser(GlobalRole.Writer); - GrantProjectRole(project.Id, grantedWriterId, ProjectRole.Writer); + GrantNovelRole(novel.Id, grantedWriterId, NovelRole.Writer); Assert.That( - () => Access.RequireAsync(project.Id, ProjectPermission.ManageAccess), + () => Access.RequireAsync(novel.Id, NovelPermission.ManageAccess), Throws.TypeOf()); } @@ -117,19 +117,19 @@ public class ProjectAccessTests : ServiceTestFixture public async Task An_admin_reaches_every_novel() { AsNewUser(GlobalRole.Writer); - await Projects.CreateAsync(new CreateProjectRequest("Writer's Novel")); + await Novels.CreateAsync(new CreateNovelRequest("Writer's Novel")); AsAdmin(); - await Projects.CreateAsync(new CreateProjectRequest("Admin's Novel")); + await Novels.CreateAsync(new CreateNovelRequest("Admin's Novel")); - var visible = await Projects.ListAsync(); + var visible = await Novels.ListAsync(); Assert.That(visible, Has.Count.EqualTo(2)); } [Test] public void Deleting_a_user_does_not_cascade_to_their_novels() { - var ownerNavigation = Db.Context.Model.FindEntityType(typeof(Project))!.FindNavigation(nameof(Project.Owner))!; + var ownerNavigation = Db.Context.Model.FindEntityType(typeof(Novel))!.FindNavigation(nameof(Novel.Owner))!; Assert.That(ownerNavigation.ForeignKey.DeleteBehavior, Is.EqualTo(DeleteBehavior.Restrict)); } @@ -137,10 +137,10 @@ public class ProjectAccessTests : ServiceTestFixture public async Task The_creator_of_a_novel_sees_their_role_as_owner() { var writerId = AsNewUser(GlobalRole.Writer); - var project = await Projects.CreateAsync(new CreateProjectRequest("Owned by writer")); + var novel = await Novels.CreateAsync(new CreateNovelRequest("Owned by writer")); UserContext.UserId = writerId; - var role = await Access.GetMyRoleAsync(project); + var role = await Access.GetMyRoleAsync(novel); Assert.That(role, Is.EqualTo("Owner")); } @@ -149,10 +149,10 @@ public class ProjectAccessTests : ServiceTestFixture public async Task An_admin_sees_their_role_as_admin_even_on_a_novel_they_do_not_own() { AsNewUser(GlobalRole.Writer); - var project = await Projects.CreateAsync(new CreateProjectRequest("Owned by someone else")); + var novel = await Novels.CreateAsync(new CreateNovelRequest("Owned by someone else")); AsAdmin(); - var role = await Access.GetMyRoleAsync(project); + var role = await Access.GetMyRoleAsync(novel); Assert.That(role, Is.EqualTo("Admin")); } @@ -160,11 +160,11 @@ public class ProjectAccessTests : ServiceTestFixture [Test] public async Task A_user_granted_editor_sees_their_role_as_editor() { - var project = await Projects.CreateAsync(new CreateProjectRequest("Granted Novel")); + var novel = await Novels.CreateAsync(new CreateNovelRequest("Granted Novel")); var editorId = AsNewUser(GlobalRole.Reviewer); - GrantProjectRole(project.Id, editorId, ProjectRole.Editor); + GrantNovelRole(novel.Id, editorId, NovelRole.Editor); - var role = await Access.GetMyRoleAsync(project); + var role = await Access.GetMyRoleAsync(novel); Assert.That(role, Is.EqualTo("Editor")); } @@ -172,10 +172,10 @@ public class ProjectAccessTests : ServiceTestFixture [Test] public async Task A_user_with_no_access_sees_a_null_role() { - var project = await Projects.CreateAsync(new CreateProjectRequest("Someone Else's Novel")); + var novel = await Novels.CreateAsync(new CreateNovelRequest("Someone Else's Novel")); AsNewUser(GlobalRole.Writer); - var role = await Access.GetMyRoleAsync(project); + var role = await Access.GetMyRoleAsync(novel); Assert.That(role, Is.Null); } diff --git a/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs b/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs index 29e152f..038b48a 100644 --- a/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs +++ b/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs @@ -2,7 +2,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Novelly.Api.Agent; -using Novelly.Api.Projects; +using Novelly.Api.Novels; namespace Novelly.Api.Tests; @@ -12,7 +12,7 @@ public class NovelAgentServiceTests : ServiceTestFixture private NovelAgentToolset _toolset = null!; protected override void OnSetUp() => - _toolset = new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger.Instance); + _toolset = new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger.Instance); private NovelAgentService BuildAgent(ScriptedModelClient model) => new( Db.Context, @@ -25,11 +25,11 @@ public class NovelAgentServiceTests : ServiceTestFixture [Test] public async Task A_plain_reply_is_persisted_as_a_conversation() { - var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; + var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; var model = new ScriptedModelClient([[new AgentTextBlock("Tell me about the ending.")]]); var agent = BuildAgent(model); - var turn = await agent.SendMessageAsync(projectId, new SendAgentMessageRequest("Where do I start?")); + var turn = await agent.SendMessageAsync(novelId, new SendAgentMessageRequest("Where do I start?")); Assert.That(turn.Content, Is.EqualTo("Tell me about the ending.")); @@ -44,9 +44,9 @@ public class NovelAgentServiceTests : ServiceTestFixture } [Test] - public async Task Tool_calls_are_executed_against_real_project_data() + public async Task Tool_calls_are_executed_against_real_novel_data() { - var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; + var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; var model = new ScriptedModelClient([ [ToolUse("t1", "create_character", new { name = "Ines", role = "Protagonist" })], @@ -54,9 +54,9 @@ public class NovelAgentServiceTests : ServiceTestFixture ]); var turn = await BuildAgent(model).SendMessageAsync( - projectId, new SendAgentMessageRequest("Add a protagonist called Ines.")); + novelId, new SendAgentMessageRequest("Add a protagonist called Ines.")); - var characters = await Characters.ListAsync(projectId); + var characters = await Characters.ListAsync(novelId); Assert.Multiple(() => { @@ -71,7 +71,7 @@ public class NovelAgentServiceTests : ServiceTestFixture [Test] public async Task Every_tool_result_comes_back_in_a_single_user_turn() { - var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; + var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; var model = new ScriptedModelClient([ [ @@ -81,10 +81,10 @@ public class NovelAgentServiceTests : ServiceTestFixture [new AgentTextBlock("Both added.")] ]); - await BuildAgent(model).SendMessageAsync(projectId, new SendAgentMessageRequest("Add two characters.")); + await BuildAgent(model).SendMessageAsync(novelId, new SendAgentMessageRequest("Add two characters.")); var resultTurn = model.Transcripts[1][^1]; - var listed = await Characters.ListAsync(projectId); + var listed = await Characters.ListAsync(novelId); Assert.Multiple(() => { @@ -97,7 +97,7 @@ public class NovelAgentServiceTests : ServiceTestFixture [Test] public async Task A_failing_tool_is_reported_back_rather_than_thrown() { - var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; + var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; var model = new ScriptedModelClient([ [ToolUse("t1", "update_character", new { character_id = Guid.NewGuid().ToString(), name = "Ines" })], @@ -105,7 +105,7 @@ public class NovelAgentServiceTests : ServiceTestFixture ]); var turn = await BuildAgent(model).SendMessageAsync( - projectId, new SendAgentMessageRequest("Rename her.")); + novelId, new SendAgentMessageRequest("Rename her.")); var errorResult = model.Transcripts[1][^1].Content.OfType().Single(); @@ -120,14 +120,14 @@ public class NovelAgentServiceTests : ServiceTestFixture [Test] public async Task Unknown_tools_are_reported_without_breaking_the_loop() { - var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; + var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; var model = new ScriptedModelClient([ [ToolUse("t1", "summon_muse", new { })], [new AgentTextBlock("Sorry — I do not have that tool.")] ]); - await BuildAgent(model).SendMessageAsync(projectId, new SendAgentMessageRequest("Summon the muse.")); + await BuildAgent(model).SendMessageAsync(novelId, new SendAgentMessageRequest("Summon the muse.")); var result = model.Transcripts[1][^1].Content.OfType().Single(); @@ -141,14 +141,14 @@ public class NovelAgentServiceTests : ServiceTestFixture [Test] public async Task The_loop_stops_at_the_iteration_ceiling() { - var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; + var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; var model = new ScriptedModelClient( Enumerable.Repeat>( [ToolUse("t", "list_characters", new { })], 20).ToList()); var turn = await BuildAgent(model).SendMessageAsync( - projectId, new SendAgentMessageRequest("Keep going forever.")); + novelId, new SendAgentMessageRequest("Keep going forever.")); Assert.Multiple(() => { @@ -160,16 +160,16 @@ public class NovelAgentServiceTests : ServiceTestFixture [Test] public async Task Follow_up_messages_continue_the_same_conversation() { - var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; + var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; var model = new ScriptedModelClient([ [new AgentTextBlock("First answer.")], [new AgentTextBlock("Second answer.")] ]); var agent = BuildAgent(model); - var first = await agent.SendMessageAsync(projectId, new SendAgentMessageRequest("Question one.")); + var first = await agent.SendMessageAsync(novelId, new SendAgentMessageRequest("Question one.")); var second = await agent.SendMessageAsync( - projectId, new SendAgentMessageRequest("Question two.", first.ConversationId)); + novelId, new SendAgentMessageRequest("Question two.", first.ConversationId)); var conversation = (await agent.GetConversationAsync(first.ConversationId))!; diff --git a/tests/Novelly.Api.Tests/ProjectDataTests.cs b/tests/Novelly.Api.Tests/NovelDataTests.cs similarity index 51% rename from tests/Novelly.Api.Tests/ProjectDataTests.cs rename to tests/Novelly.Api.Tests/NovelDataTests.cs index ef08880..7361826 100644 --- a/tests/Novelly.Api.Tests/ProjectDataTests.cs +++ b/tests/Novelly.Api.Tests/NovelDataTests.cs @@ -2,23 +2,23 @@ using Microsoft.EntityFrameworkCore; using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Common; -using Novelly.Api.Projects; +using Novelly.Api.Novels; namespace Novelly.Api.Tests; [TestFixture] -public class ProjectDataTests : ServiceTestFixture +public class NovelDataTests : ServiceTestFixture { - private async Task NewProjectAsync() => - (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id; + private async Task NewNovelAsync() => + (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; [Test] public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string() { - var id = (await Projects.CreateAsync( - new CreateProjectRequest("Draft", Genre: "Fantasy", Logline: "A cartographer goes to sea."))).Id; + var id = (await Novels.CreateAsync( + new CreateNovelRequest("Draft", Genre: "Fantasy", Logline: "A cartographer goes to sea."))).Id; - var afterPartialUpdate = (await Projects.UpdateAsync(id, new UpdateProjectRequest(Title: "The Salt Road")))!; + var afterPartialUpdate = (await Novels.UpdateAsync(id, new UpdateNovelRequest(Title: "The Salt Road")))!; Assert.Multiple(() => { @@ -27,7 +27,7 @@ public class ProjectDataTests : ServiceTestFixture Assert.That(afterPartialUpdate.Logline, Is.EqualTo("A cartographer goes to sea.")); }); - var afterClear = (await Projects.UpdateAsync(id, new UpdateProjectRequest(Genre: "")))!; + var afterClear = (await Novels.UpdateAsync(id, new UpdateNovelRequest(Genre: "")))!; Assert.Multiple(() => { @@ -37,23 +37,23 @@ public class ProjectDataTests : ServiceTestFixture } [Test] - public async Task New_projects_start_in_the_brainstorming_phase_and_can_be_advanced() + public async Task New_novels_start_in_the_brainstorming_phase_and_can_be_advanced() { - var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); - Assert.That(project.Phase, Is.EqualTo(ProjectPhase.Brainstorming)); + var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road")); + Assert.That(novel.Phase, Is.EqualTo(NovelPhase.Brainstorming)); - var afterAdvance = (await Projects.UpdateAsync(project.Id, new UpdateProjectRequest(Phase: ProjectPhase.Outlining)))!; - Assert.That(afterAdvance.Phase, Is.EqualTo(ProjectPhase.Outlining)); + var afterAdvance = (await Novels.UpdateAsync(novel.Id, new UpdateNovelRequest(Phase: NovelPhase.Outlining)))!; + Assert.That(afterAdvance.Phase, Is.EqualTo(NovelPhase.Outlining)); - var afterUnrelatedUpdate = (await Projects.UpdateAsync(project.Id, new UpdateProjectRequest(Genre: "Fantasy")))!; - Assert.That(afterUnrelatedUpdate.Phase, Is.EqualTo(ProjectPhase.Outlining)); + var afterUnrelatedUpdate = (await Novels.UpdateAsync(novel.Id, new UpdateNovelRequest(Genre: "Fantasy")))!; + Assert.That(afterUnrelatedUpdate.Phase, Is.EqualTo(NovelPhase.Outlining)); } [Test] - public async Task A_project_response_carries_the_owner_id_and_the_caller_s_role() + public async Task A_novel_response_carries_the_owner_id_and_the_caller_s_role() { - var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); - var response = project.ToResponse(await Access.GetMyRoleAsync(project)); + var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road")); + var response = novel.ToResponse(await Access.GetMyRoleAsync(novel)); Assert.Multiple(() => { @@ -65,10 +65,10 @@ public class ProjectDataTests : ServiceTestFixture [Test] public async Task Chapters_are_numbered_in_sequence_when_no_number_is_given() { - var projectId = await NewProjectAsync(); + var novelId = await NewNovelAsync(); - var first = await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall")); - var second = await Chapters.CreateAsync(projectId, new CreateChapterRequest("The Harbour")); + var first = await Chapters.CreateAsync(novelId, new CreateChapterRequest("Landfall")); + var second = await Chapters.CreateAsync(novelId, new CreateChapterRequest("The Harbour")); Assert.Multiple(() => { @@ -80,9 +80,9 @@ public class ProjectDataTests : ServiceTestFixture [Test] public async Task Word_count_is_recomputed_whenever_prose_changes() { - var projectId = await NewProjectAsync(); + var novelId = await NewNovelAsync(); - var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest( + var chapter = await Chapters.CreateAsync(novelId, new CreateChapterRequest( "Landfall", Prose: "Five words go right here")); Assert.That(chapter.WordCount, Is.EqualTo(5)); @@ -104,8 +104,8 @@ public class ProjectDataTests : ServiceTestFixture [Test] public async Task Chapter_updates_that_omit_prose_leave_the_draft_untouched() { - var projectId = await NewProjectAsync(); - var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest( + var novelId = await NewNovelAsync(); + var chapter = await Chapters.CreateAsync(novelId, new CreateChapterRequest( "Landfall", Prose: "The tide came in slow.")); var updated = (await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Status: DraftStatus.Revised)))!; @@ -119,45 +119,45 @@ public class ProjectDataTests : ServiceTestFixture } [Test] - public async Task Deleting_a_project_takes_its_characters_and_chapters() + public async Task Deleting_a_novel_takes_its_characters_and_chapters() { - var projectId = await NewProjectAsync(); - await Characters.CreateAsync(projectId, new CreateCharacterRequest("Ines")); - await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall")); + var novelId = await NewNovelAsync(); + await Characters.CreateAsync(novelId, new CreateCharacterRequest("Ines")); + await Chapters.CreateAsync(novelId, new CreateChapterRequest("Landfall")); - await Projects.DeleteAsync(projectId); + await Novels.DeleteAsync(novelId); using var verification = Db.CreateContext(); Assert.Multiple(async () => { - Assert.That(await verification.Projects.CountAsync(), Is.EqualTo(0)); + Assert.That(await verification.Novels.CountAsync(), Is.EqualTo(0)); Assert.That(await verification.Characters.CountAsync(), Is.EqualTo(0)); Assert.That(await verification.Chapters.CountAsync(), Is.EqualTo(0)); }); } [Test] - public async Task Relating_characters_across_projects_is_refused() + public async Task Relating_characters_across_novels_is_refused() { - var firstProject = await NewProjectAsync(); - var secondProject = (await Projects.CreateAsync(new CreateProjectRequest("Other Book"))).Id; + var firstNovel = await NewNovelAsync(); + var secondNovel = (await Novels.CreateAsync(new CreateNovelRequest("Other Book"))).Id; - var ines = await Characters.CreateAsync(firstProject, new CreateCharacterRequest("Ines")); - var stranger = await Characters.CreateAsync(secondProject, new CreateCharacterRequest("Stranger")); + var ines = await Characters.CreateAsync(firstNovel, new CreateCharacterRequest("Ines")); + var stranger = await Characters.CreateAsync(secondNovel, new CreateCharacterRequest("Stranger")); Assert.That( async () => await Characters.AddRelationshipAsync( ines.Id, new CreateRelationshipRequest(stranger.Id, "sister")), - Throws.TypeOf().With.Message.Contains("same project")); + Throws.TypeOf().With.Message.Contains("same novel")); } [Test] public async Task Relationships_resolve_the_other_character_by_name() { - var projectId = await NewProjectAsync(); - var ines = await Characters.CreateAsync(projectId, new CreateCharacterRequest("Ines")); - var mara = await Characters.CreateAsync(projectId, new CreateCharacterRequest("Mara")); + var novelId = await NewNovelAsync(); + var ines = await Characters.CreateAsync(novelId, new CreateCharacterRequest("Ines")); + var mara = await Characters.CreateAsync(novelId, new CreateCharacterRequest("Mara")); var updated = (await Characters.AddRelationshipAsync( ines.Id, new CreateRelationshipRequest(mara.Id, "sister", "Estranged since the fire.")))!; @@ -170,6 +170,6 @@ public class ProjectDataTests : ServiceTestFixture } [Test] - public async Task Reading_a_missing_project_returns_null_rather_than_throwing() => - Assert.That(await Projects.GetAsync(Guid.NewGuid()), Is.Null); + public async Task Reading_a_missing_novel_returns_null_rather_than_throwing() => + Assert.That(await Novels.GetAsync(Guid.NewGuid()), Is.Null); } diff --git a/tests/Novelly.Api.Tests/OpenQuestionTests.cs b/tests/Novelly.Api.Tests/OpenQuestionTests.cs index 551d072..35fbfc6 100644 --- a/tests/Novelly.Api.Tests/OpenQuestionTests.cs +++ b/tests/Novelly.Api.Tests/OpenQuestionTests.cs @@ -1,7 +1,7 @@ using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Common; -using Novelly.Api.Projects; +using Novelly.Api.Novels; using Novelly.Api.Questions; namespace Novelly.Api.Tests; @@ -9,21 +9,21 @@ namespace Novelly.Api.Tests; [TestFixture] public class OpenQuestionTests : ServiceTestFixture { - private Guid _projectId; + private Guid _novelId; private Guid _chapterId; private Guid _characterId; protected override void OnSetUp() { - _projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id; - _chapterId = Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")).Result.Id; - _characterId = Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")).Result.Id; + _novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id; + _chapterId = Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall")).Result.Id; + _characterId = Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")).Result.Id; } [Test] public async Task A_question_can_hang_off_a_chapter_and_a_character_at_once() { - var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest( "Does she know about the letter before the harbour?", ChapterId: _chapterId, CharacterId: _characterId)); @@ -41,7 +41,7 @@ public class OpenQuestionTests : ServiceTestFixture public async Task A_question_about_the_book_as_a_whole_needs_no_association() { var question = await Questions.CreateAsync( - _projectId, new CreateOpenQuestionRequest("Is this one book or two?")); + _novelId, new CreateOpenQuestionRequest("Is this one book or two?")); Assert.Multiple(() => { @@ -53,21 +53,21 @@ public class OpenQuestionTests : ServiceTestFixture [Test] public async Task The_outline_and_the_character_page_each_see_only_their_own_questions() { - await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest( "Where does the chapter break?", ChapterId: _chapterId)); - await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest( "What does she actually want?", CharacterId: _characterId)); - await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest("Is this one book or two?")); + await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest("Is this one book or two?")); - var forChapter = await Questions.ListAsync(_projectId, chapterId: _chapterId); - var forCharacter = await Questions.ListAsync(_projectId, characterId: _characterId); - var forProject = await Questions.ListAsync(_projectId); + var forChapter = await Questions.ListAsync(_novelId, chapterId: _chapterId); + var forCharacter = await Questions.ListAsync(_novelId, characterId: _characterId); + var forNovel = await Questions.ListAsync(_novelId); Assert.Multiple(() => { Assert.That(forChapter.Select(q => q.Question), Is.EqualTo(new[] { "Where does the chapter break?" })); Assert.That(forCharacter.Select(q => q.Question), Is.EqualTo(new[] { "What does she actually want?" })); - Assert.That(forProject, Has.Count.EqualTo(3)); + Assert.That(forNovel, Has.Count.EqualTo(3)); }); } @@ -75,13 +75,13 @@ public class OpenQuestionTests : ServiceTestFixture public async Task Resolved_questions_drop_off_the_list_unless_asked_for() { var settled = await Questions.CreateAsync( - _projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); - await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest("Is this one book or two?")); + _novelId, new CreateOpenQuestionRequest("Where does the chapter break?")); + await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest("Is this one book or two?")); await Questions.ResolveAsync(settled.Id, new ResolveOpenQuestionRequest("After the harbour.")); - var open = await Questions.ListAsync(_projectId); - var everything = await Questions.ListAsync(_projectId, includeResolved: true); + var open = await Questions.ListAsync(_novelId); + var everything = await Questions.ListAsync(_novelId, includeResolved: true); Assert.Multiple(() => { @@ -97,7 +97,7 @@ public class OpenQuestionTests : ServiceTestFixture public async Task Resolving_records_what_was_decided() { var question = await Questions.CreateAsync( - _projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); + _novelId, new CreateOpenQuestionRequest("Where does the chapter break?")); var resolved = (await Questions.ResolveAsync( question.Id, new ResolveOpenQuestionRequest("After the harbour burns.")))!; @@ -115,7 +115,7 @@ public class OpenQuestionTests : ServiceTestFixture { await Chapters.UpdateAsync(_chapterId, new UpdateChapterRequest(Notes: "Runs long.")); - var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest( "Where does the chapter break?", ChapterId: _chapterId, CharacterId: _characterId)); await Questions.ResolveAsync( @@ -136,7 +136,7 @@ public class OpenQuestionTests : ServiceTestFixture [Test] public async Task A_resolution_stays_off_the_notes_unless_asked_for() { - var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest( "Where does the chapter break?", ChapterId: _chapterId)); await Questions.ResolveAsync(question.Id, new ResolveOpenQuestionRequest("After the harbour.")); @@ -147,7 +147,7 @@ public class OpenQuestionTests : ServiceTestFixture [Test] public async Task Reopening_clears_the_resolution_but_leaves_the_note_behind() { - var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest( "Where does the chapter break?", ChapterId: _chapterId)); await Questions.ResolveAsync( @@ -167,7 +167,7 @@ public class OpenQuestionTests : ServiceTestFixture [Test] public async Task A_question_can_be_detached_from_what_it_was_about() { - var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest( "Where does the chapter break?", ChapterId: _chapterId, CharacterId: _characterId)); var detached = (await Questions.UpdateAsync( @@ -184,7 +184,7 @@ public class OpenQuestionTests : ServiceTestFixture [Test] public async Task Deleting_a_chapter_leaves_its_questions_open_rather_than_taking_them() { - var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( + var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest( "Does she know about the letter?", ChapterId: _chapterId)); await Chapters.DeleteAsync(_chapterId); @@ -202,52 +202,52 @@ public class OpenQuestionTests : ServiceTestFixture public async Task A_question_can_be_deleted_outright() { var question = await Questions.CreateAsync( - _projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); + _novelId, new CreateOpenQuestionRequest("Where does the chapter break?")); await Questions.DeleteAsync(question.Id); Assert.Multiple(async () => { - Assert.That(await Questions.ListAsync(_projectId, includeResolved: true), Is.Empty); + Assert.That(await Questions.ListAsync(_novelId, includeResolved: true), Is.Empty); Assert.That(await Questions.GetAsync(question.Id), Is.Null); }); } [Test] - public async Task Deleting_a_project_takes_its_questions() + public async Task Deleting_a_novel_takes_its_questions() { - await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest("Is this one book or two?")); + await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest("Is this one book or two?")); - await Projects.DeleteAsync(_projectId); + await Novels.DeleteAsync(_novelId); using var verification = Db.CreateContext(); Assert.That(verification.OpenQuestions.Count(), Is.EqualTo(0)); } [Test] - public void A_question_cannot_be_attached_to_another_project_s_chapter() + public void A_question_cannot_be_attached_to_another_novel_s_chapter() { var elsewhere = Chapters.CreateAsync( - Projects.CreateAsync(new CreateProjectRequest("Other Book")).Result.Id, + Novels.CreateAsync(new CreateNovelRequest("Other Book")).Result.Id, new CreateChapterRequest("Elsewhere")).Result; Assert.That( async () => await Questions.CreateAsync( - _projectId, new CreateOpenQuestionRequest("A question", ChapterId: elsewhere.Id)), - Throws.TypeOf().With.Message.Contains("same project")); + _novelId, new CreateOpenQuestionRequest("A question", ChapterId: elsewhere.Id)), + Throws.TypeOf().With.Message.Contains("same novel")); } [Test] public void A_blank_question_is_refused() => Assert.That( - async () => await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest(" ")), + async () => await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(" ")), Throws.TypeOf()); [Test] public async Task Resolving_with_nothing_decided_is_refused() { var question = await Questions.CreateAsync( - _projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); + _novelId, new CreateOpenQuestionRequest("Where does the chapter break?")); Assert.That( async () => await Questions.ResolveAsync(question.Id, new ResolveOpenQuestionRequest(" ")), diff --git a/tests/Novelly.Api.Tests/ServiceTestFixture.cs b/tests/Novelly.Api.Tests/ServiceTestFixture.cs index 48c76d3..ec19bd1 100644 --- a/tests/Novelly.Api.Tests/ServiceTestFixture.cs +++ b/tests/Novelly.Api.Tests/ServiceTestFixture.cs @@ -2,7 +2,7 @@ using Novelly.Api.Beats; using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Genres; -using Novelly.Api.Projects; +using Novelly.Api.Novels; using Novelly.Api.Questions; using Novelly.Api.Tags; using Novelly.Api.Users; @@ -13,9 +13,9 @@ public abstract class ServiceTestFixture { protected TestDatabase Db { get; private set; } = null!; protected TestUserContext UserContext { get; private set; } = null!; - protected ProjectAccessService Access { get; private set; } = null!; + protected NovelAccessService Access { get; private set; } = null!; protected TagService Tags { get; private set; } = null!; - protected ProjectService Projects { get; private set; } = null!; + protected NovelService Novels { get; private set; } = null!; protected CharacterService Characters { get; private set; } = null!; protected ChapterService Chapters { get; private set; } = null!; protected BeatService Beats { get; private set; } = null!; @@ -23,7 +23,7 @@ public abstract class ServiceTestFixture protected OpenQuestionService Questions { get; private set; } = null!; protected GenreService Genres { get; private set; } = null!; - protected CapturingLogger ProjectLogs { get; private set; } = null!; + protected CapturingLogger NovelLogs { get; private set; } = null!; protected CapturingLogger CharacterLogs { get; private set; } = null!; protected CapturingLogger ChapterLogs { get; private set; } = null!; protected CapturingLogger BeatLogs { get; private set; } = null!; @@ -37,7 +37,7 @@ public abstract class ServiceTestFixture { Db = new TestDatabase(); UserContext = new TestUserContext(); - Access = new ProjectAccessService(Db.Context, UserContext, new CapturingLogger()); + Access = new NovelAccessService(Db.Context, UserContext, new CapturingLogger()); Db.Context.Users.Add(new NovellyUser { @@ -50,7 +50,7 @@ public abstract class ServiceTestFixture Db.Context.SaveChanges(); TagLogs = new CapturingLogger(); - ProjectLogs = new CapturingLogger(); + NovelLogs = new CapturingLogger(); CharacterLogs = new CapturingLogger(); ChapterLogs = new CapturingLogger(); BeatLogs = new CapturingLogger(); @@ -59,8 +59,8 @@ public abstract class ServiceTestFixture GenreLogs = new CapturingLogger(); Tags = new TagService(Db.Context, Access, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator()); - Projects = new ProjectService( - Db.Context, Access, UserContext, ProjectLogs, new CreateProjectRequestValidator(), new UpdateProjectRequestValidator()); + Novels = new NovelService( + Db.Context, Access, UserContext, NovelLogs, new CreateNovelRequestValidator(), new UpdateNovelRequestValidator()); Characters = new CharacterService( Db.Context, Access, Tags, CharacterLogs, new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator(), diff --git a/tests/Novelly.Api.Tests/TagServiceTests.cs b/tests/Novelly.Api.Tests/TagServiceTests.cs index 6b54c70..7187ca2 100644 --- a/tests/Novelly.Api.Tests/TagServiceTests.cs +++ b/tests/Novelly.Api.Tests/TagServiceTests.cs @@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore; using Novelly.Api.Beats; using Novelly.Api.Chapters; using Novelly.Api.Characters; -using Novelly.Api.Projects; +using Novelly.Api.Novels; using Novelly.Api.Tags; namespace Novelly.Api.Tests; @@ -10,34 +10,34 @@ namespace Novelly.Api.Tests; [TestFixture] public class TagServiceTests : ServiceTestFixture { - private Guid _projectId; + private Guid _novelId; protected override void OnSetUp() => - _projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id; + _novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id; [Test] public async Task Applying_an_unknown_tag_by_name_creates_it() { var character = await Characters.CreateAsync( - _projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"])); + _novelId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"])); Assert.Multiple(async () => { Assert.That( character.Tags.Select(t => t.Name), Is.EquivalentTo(new[] { "betrayal", "the sea" })); - Assert.That(await Tags.ListAsync(_projectId), Has.Count.EqualTo(2)); + Assert.That(await Tags.ListAsync(_novelId), Has.Count.EqualTo(2)); }); } [Test] public async Task The_same_name_resolves_to_one_tag_regardless_of_casing() { - await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["Betrayal"])); + await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines", Tags: ["Betrayal"])); var chapter = await Chapters.CreateAsync( - _projectId, new CreateChapterRequest("Landfall", Tags: ["betrayal"])); + _novelId, new CreateChapterRequest("Landfall", Tags: ["betrayal"])); - var listed = await Tags.ListAsync(_projectId); + var listed = await Tags.ListAsync(_novelId); Assert.Multiple(() => { @@ -52,7 +52,7 @@ public class TagServiceTests : ServiceTestFixture public async Task Supplying_a_tag_list_replaces_the_existing_tags() { var character = await Characters.CreateAsync( - _projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"])); + _novelId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"])); var updated = (await Characters.UpdateAsync( character.Id, new UpdateCharacterRequest(Tags: ["the sea", "maps"])))!; @@ -64,7 +64,7 @@ public class TagServiceTests : ServiceTestFixture public async Task Omitting_the_tag_list_leaves_tags_alone() { var character = await Characters.CreateAsync( - _projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); + _novelId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); var updated = (await Characters.UpdateAsync( character.Id, new UpdateCharacterRequest(Occupation: "Cartographer")))!; @@ -80,14 +80,14 @@ public class TagServiceTests : ServiceTestFixture [Test] public async Task Cross_reference_gathers_everything_carrying_a_tag() { - await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); + await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); var chapter = await Chapters.CreateAsync( - _projectId, new CreateChapterRequest("Landfall", Tags: ["betrayal"])); + _novelId, new CreateChapterRequest("Landfall", Tags: ["betrayal"])); await Beats.CreateAsync(chapter.Id, new CreateBeatRequest( "She burns the atlas", WhatHappened: "In the galley stove.", Tags: ["betrayal"])); await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Unrelated beat")); - var tagId = (await Tags.ListAsync(_projectId)).Single().Id; + var tagId = (await Tags.ListAsync(_novelId)).Single().Id; var references = (await Tags.GetReferencesAsync(tagId))!; Assert.Multiple(() => @@ -106,12 +106,12 @@ public class TagServiceTests : ServiceTestFixture [Test] public async Task Usage_counts_are_reported_per_kind() { - await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["sea"])); - await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara", Tags: ["sea"])); - var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")); + await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines", Tags: ["sea"])); + await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara", Tags: ["sea"])); + var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall")); await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("A beat", Tags: ["sea"])); - var summary = (await Tags.ListAsync(_projectId)).Single(); + var summary = (await Tags.ListAsync(_novelId)).Single(); Assert.Multiple(() => { @@ -125,13 +125,13 @@ public class TagServiceTests : ServiceTestFixture [Test] public async Task Duplicate_tag_names_are_refused_on_create_and_rename() { - await Tags.CreateAsync(_projectId, new CreateTagRequest("betrayal")); + await Tags.CreateAsync(_novelId, new CreateTagRequest("betrayal")); Assert.That( - async () => await Tags.CreateAsync(_projectId, new CreateTagRequest("Betrayal")), + async () => await Tags.CreateAsync(_novelId, new CreateTagRequest("Betrayal")), Throws.TypeOf().With.Message.Contains("already has a tag")); - var other = await Tags.CreateAsync(_projectId, new CreateTagRequest("the sea")); + var other = await Tags.CreateAsync(_novelId, new CreateTagRequest("the sea")); Assert.That( async () => await Tags.UpdateAsync(other.Id, new UpdateTagRequest(Name: "betrayal")), @@ -139,19 +139,19 @@ public class TagServiceTests : ServiceTestFixture } [Test] - public async Task Tags_are_scoped_to_their_project() + public async Task Tags_are_scoped_to_their_novel() { - var otherProject = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); + var otherNovel = await Novels.CreateAsync(new CreateNovelRequest("Other Book")); - await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["sea"])); - await Characters.CreateAsync(otherProject.Id, new CreateCharacterRequest("Someone", Tags: ["sea"])); + await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines", Tags: ["sea"])); + await Characters.CreateAsync(otherNovel.Id, new CreateCharacterRequest("Someone", Tags: ["sea"])); using var verification = Db.CreateContext(); Assert.Multiple(async () => { - Assert.That(await Tags.ListAsync(_projectId), Has.Count.EqualTo(1)); - Assert.That(await Tags.ListAsync(otherProject.Id), Has.Count.EqualTo(1)); + Assert.That(await Tags.ListAsync(_novelId), Has.Count.EqualTo(1)); + Assert.That(await Tags.ListAsync(otherNovel.Id), Has.Count.EqualTo(1)); Assert.That(await verification.Tags.CountAsync(), Is.EqualTo(2)); }); } @@ -160,8 +160,8 @@ public class TagServiceTests : ServiceTestFixture public async Task Deleting_a_tag_leaves_what_carried_it_intact() { var character = await Characters.CreateAsync( - _projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); - var tagId = (await Tags.ListAsync(_projectId)).Single().Id; + _novelId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); + var tagId = (await Tags.ListAsync(_novelId)).Single().Id; await Tags.DeleteAsync(tagId); @@ -175,11 +175,11 @@ public class TagServiceTests : ServiceTestFixture } [Test] - public async Task Deleting_a_project_takes_its_tags() + public async Task Deleting_a_novel_takes_its_tags() { - await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); + await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); - await Projects.DeleteAsync(_projectId); + await Novels.DeleteAsync(_novelId); using var verification = Db.CreateContext(); Assert.That(await verification.Tags.CountAsync(), Is.EqualTo(0)); @@ -188,6 +188,6 @@ public class TagServiceTests : ServiceTestFixture [Test] public void A_blank_tag_name_is_refused() => Assert.That( - async () => await Tags.CreateAsync(_projectId, new CreateTagRequest(" ")), + async () => await Tags.CreateAsync(_novelId, new CreateTagRequest(" ")), Throws.TypeOf()); }